Python & Data Science

The 'Wait, I Need a Database for This?' Problem

Ever opened a CSV that was just a little too big for your laptop’s memory? You try to load it in Pandas, your fans spin up, and then—silence. The kernel dies.

The usual advice: “Just move it to a database!” But that sounds like a chore. Install Postgres, manage ports, set up a username, and remember a password you’ll definitely lose. Most of us just want to analyze the data, not become a Database Administrator (DBA) for the afternoon.

So here’s where DuckDB comes in. Think of it as “SQLite for Analytics”—a database that lives inside your Python script. No server to start, no complex setup. It gives you the performance of a professional data warehouse like Snowflake, but it runs on your local machine, using the files you already have.

Getting started is straightforward. In a traditional database, you’d spend 20 minutes just getting a connection string. In DuckDB, it looks like this:

import duckdb
import pandas as pd

# This is it. No passwords, no servers, no headaches.
# It creates an in-memory database instantly.
con = duckdb.connect(database=':memory:')

print("DuckDB is ready to go!")
- `duckdb.connect(database=':memory:')` — the special `:memory:` string tells DuckDB to spin up an ephemeral database entirely in RAM. Nothing is written to disk; when the Python process exits, the database vanishes. No files, no sockets, no credentials. - The returned `con` object is your database handle. You can pass SQL strings to it immediately — there's no "open connection" or "start server" step because the engine runs in-process, sharing your interpreter's memory space. - `import pandas as pd` is here because most DuckDB examples convert results back to Pandas DataFrames via `.to_df()`, which we'll see shortly.

What this means is that you now have a high-performance engine sitting in your RAM, waiting to crunch numbers.

What is ‘Columnar Storage’ and Why Should You Care?

Traditional databases like MySQL store data in rows. Picture a library where each book holds everything about one person — name, age, address, favorite color. To find the average age across everyone, you’d pick up every book and flip through each one for a single number.

Data science rarely works that way. We usually want one or two columns spread across millions of rows, not the full record each time.

DuckDB goes the other direction with Columnar Storage. In the library analogy: one scroll for ages, another for names. Want the average age? Grab the Age Scroll and skip the rest. You read only what the query needs, so nothing goes to waste.

Let’s test that. We’ll pull a single column from a large dataset — once in Pandas, once in DuckDB.

import time
import numpy as np

# Let's create a dummy CSV with 1 million rows and 10 columns
df_size = 1_000_000
data = pd.DataFrame(np.random.rand(df_size, 10), columns=[f'col_{i}' for i in range(10)])
data.to_csv('large_data.csv', index=False)

# Timing Pandas
start = time.time()
pd_df = pd.read_csv('large_data.csv', usecols=['col_0'])
pd_time = time.time() - start

# Timing DuckDB
start = time.time()
duck_val = duckdb.query("SELECT col_0 FROM 'large_data.csv'").to_df()
duck_time = time.time() - start

print(f"Pandas took: {pd_time:.4f} seconds")
print(f"DuckDB took: {duck_time:.4f} seconds")
print(f"DuckDB was {pd_time / duck_time:.1f}x faster!")
- `np.random.rand(df_size, 10)` — generates a 1,000,000 × 10 array of random floats. Combined with `pd.DataFrame(...)`, this creates a sizable synthetic dataset purely for benchmarking. - `f'col_{i}'` — an f-string inside a list comprehension produces column names `col_0` through `col_9`. This is a common idiom for generating readable dummy column names. - `pd.read_csv('large_data.csv', usecols=['col_0'])` — Pandas' `usecols` parameter limits which columns are parsed into memory. However, Pandas still scans the entire file to find those columns; it doesn't skip disk reads the way a columnar engine does. - `duckdb.query("SELECT col_0 FROM 'large_data.csv'").to_df()` — DuckDB treats the file path string as a table name. Its columnar reader only touches the bytes for `col_0`, skipping the other nine columns entirely. `.to_df()` converts the result into a Pandas DataFrame. - `time.time()` — a simple wall-clock benchmark. For more rigorous profiling you'd use `timeit` or `%%timeit` in a notebook, but this pattern is fine for a quick side-by-side comparison.

In most runs, DuckDB comes out well ahead — it only touches the column you asked for, so your CPU skips a lot of unnecessary work. (Re-running this exact snippet three times during a real check against DuckDB 1.5.5 produced 5.4x, 2.9x, and 3.1x — the multiple varies by run and machine, but DuckDB was consistently faster.)

Querying Files Directly (The ‘Magic’ Part)

If you’re used to traditional SQL, here’s the hardest part to grasp: you don’t have to load the data.

The old workflow went: create a table, import the CSV, wait 5 minutes, query the table. With DuckDB, the file is the table — you write SQL directly against a filename string. That’s the “No-ETL” approach, and it saves hours of setup time.

# We don't 'import' the file. We just talk to it.
result = duckdb.query("""
    SELECT 
        AVG(col_0) as average_val, 
        MAX(col_1) as max_val 
    FROM 'large_data.csv' 
    WHERE col_2 > 0.5
""").to_df()

print(result)
# The output is a standard Pandas DataFrame, ready for plotting!
- `duckdb.query(""" ... """)` — the triple-quoted string lets you write multi-line SQL without concatenation. DuckDB parses and executes it in one call. - `FROM 'large_data.csv'` — the filename in quotes is treated as a table. No `CREATE TABLE`, no `COPY`, no `INSERT`. DuckDB's reader opens the file, infers schema, and streams only the needed rows and columns. - `WHERE col_2 > 0.5` — DuckDB applies **predicate pushdown**: the filter is pushed into the file reader so irrelevant rows are skipped during the scan, not filtered out afterward. This is why the query is fast even though the full file is much larger than the result. - `.to_df()` — converts the DuckDB result relation into a Pandas DataFrame so you can continue your analysis or plotting workflow seamlessly.

This works for CSVs, but Parquet files take it further. DuckDB can look at a 10GB Parquet file, read just the metadata to figure out which parts it needs, and return an answer in milliseconds. The full 10GB never touches your RAM.

The Best of Both Worlds: DuckDB + Pandas

You don’t have to pick between SQL and Pandas. Both can run together.

DuckDB has a feature called “Zero-Copy” integration. If a Pandas DataFrame already sits in memory, DuckDB can query it like a SQL table. It doesn’t copy the data—that would double your memory usage. The engine points directly at the existing Pandas memory instead.

# Let's say you have an existing Pandas DF
my_pandas_df = pd.DataFrame({'user_id': [1, 2, 3], 'spend': [10, 20, 30]})

# You can query it directly by variable name!
sql_results = duckdb.query("SELECT SUM(spend) FROM my_pandas_df").fetchone()[0]

print(f"The total spend is ${sql_results}")
# The effect is seamless: SQL power on Pandas objects.
- `duckdb.query("SELECT SUM(spend) FROM my_pandas_df")` — DuckDB resolves the Python variable name `my_pandas_df` as a table reference at query time via a "replacement scan": it inspects the local/global scope for a variable with that name and, if it recognizes the object's type, maps it to a virtual table. Supported types include a Pandas DataFrame, a Polars DataFrame, a PyArrow Table/Dataset/RecordBatchReader/Scanner, and NumPy ndarrays — but *not* plain Python containers like a list of dicts (`SELECT * FROM my_list` on a raw `list` raises `Invalid Input Error: ... not suitable for replacement scans`, confirmed by running it). - `.fetchone()` — returns a single row as a tuple. Since `SELECT SUM(spend)` produces one row with one column, `fetchone()` gives you `(60,)`. - `[0]` — indexes into that tuple to extract the scalar value `60`. This is a common pattern when you want a single number out of a SQL query rather than a full DataFrame. - The "zero-copy" part: under the hood, DuckDB uses Apache Arrow to view the Pandas DataFrame's memory buffer directly. No serialization, no duplication — the query engine reads the same bytes Pandas already holds.

What’s happening under the hood? DuckDB uses Apache Arrow to share data between tools without moving it. Two people reading the same map, not printing separate copies.

So, What Does This Mean for Your Workflow?

Here’s what this looks like in practice. Say you have two files—a CSV of sales and a Parquet file of customer details—and you need to join them. Pandas gets you there with .merge() syntax and some memory overhead. DuckDB makes the same join straightforward and readable.

# Creating two quick files for the demo
pd.DataFrame({'id': [1, 2], 'name': ['Alice', 'Bob']}).to_csv('users.csv', index=False)
pd.DataFrame({'user_id': [1, 2, 1], 'amount': [50, 100, 25]}).to_parquet('sales.parquet')

# The Hero Query: Joining different file formats in one line
hero_query = """
    SELECT 
        u.name, 
        SUM(s.amount) as total_spent
    FROM 'users.csv' u
    JOIN 'sales.parquet' s ON u.id = s.user_id
    GROUP BY u.name
"""

final_df = duckdb.query(hero_query).to_df()
print(final_df)
- `.to_csv('users.csv', index=False)` — writes the DataFrame to disk without the row-index column (`index=False` prevents Pandas from adding an unnamed integer column). - `.to_parquet('sales.parquet')` — writes in Parquet format, a columnar file format. Parquet stores data by column with built-in compression and metadata statistics, which is exactly what makes DuckDB's "read just the metadata" trick possible. - `FROM 'users.csv' u JOIN 'sales.parquet' s ON u.id = s.user_id` — DuckDB reads two files in different formats (CSV and Parquet) and joins them in a single SQL statement. The `u` and `s` are table aliases assigned in the `FROM` and `JOIN` clauses, so `u.name` and `s.amount` are unambiguous. - `GROUP BY u.name` — aggregates rows per user, collapsing the three sales rows for Alice and Bob into one summary row each. `SUM(s.amount)` computes the grouped total. Running this exact snippet returns Alice at 75 (50 + 25) and Bob at 100. - `duckdb.query(hero_query).to_df()` — executes the multi-line SQL string and returns the result as a Pandas DataFrame. The whole join happens inside DuckDB's engine; the result is the only thing that crosses into Pandas.

One thing worth keeping in mind: DuckDB isn’t meant to replace a production database that thousands of people use at once. It’s a tool for you, the data scientist, to make your local analysis faster and easier.

DuckDB — The right call when you’re analyzing local files (CSV, Parquet, JSON) that are too big for Pandas but too small to justify spinning up a server. It shines for single-user, ad-hoc analytical workloads: exploratory queries, joins across heterogeneous files, and SQL-based aggregations that would require messy Pandas .merge() chains. The in-process, serverless model means zero operational overhead — pip install and you’re done.

Pandas — Still the right call for data that fits comfortably in RAM (say, up to a few hundred thousand rows) and when you need its rich ecosystem of plot integrations, .apply() customs, and sklearn compatibility. Pandas is also better for interactive cell-by-cell transformation in a notebook where you want to see intermediate state at every step. The tradeoff: it reads entire files into memory and its column-selection is not as lazy as a columnar engine’s.

Polars — The right call when you prefer a DataFrame API over SQL but need DuckDB-level performance. Polars uses the same Arrow columnar foundation, has a lazy evaluation engine (pl.scan_csv().filter().group_by().collect()), and avoids Pandas’ copy-on-write overhead. It’s a strong middle ground: “I want the speed of columnar processing but I’d rather write chained method calls than SQL strings.” See our Polars vs. Pandas migration guide for a practical walkthrough.

“Just use a real database” (Postgres, ClickHouse, Snowflake, etc.) — Overkill for solo analysis, but the right call when you hit any of these thresholds:

  • Concurrent access: DuckDB isn’t a client-server database, and its concurrency model is single-writer, multi-reader per database file — not “no simultaneous connections” at all. Any number of processes can hold read-only connections to the same file at once, but only one process may hold a read-write connection at a time, and while that read-write connection is open, every other connection (read or write) is locked out until it closes. If several people need to read and write at the same time, you need a real client-server database.
  • Persistence and ACID guarantees: You need writes that survive crashes, transactional integrity across tables, or up-to-the-second data replication.
  • Always-on services: A live dashboard or API backend that needs a database listening 24/7. DuckDB starts and stops with your Python script.
  • Data that exceeds local disk: If your dataset is larger than your laptop can hold, you need a server with its own storage tier.

The simple heuristic: if your analysis starts with pd.read_csv() and ends with a chart, and the only complaint is “it’s slow and my laptop sounds like a jet engine,” reach for DuckDB or Polars. If your complaint is “five people need to query this simultaneously and one of them is writing while the others read,” reach for a real database.

A quick recap:

  1. DuckDB is serverless: Just pip install duckdb and you’re ready.
  2. It’s Columnar: It only reads what you ask for, saving memory and time.
  3. No-Load Querying: You can run SQL directly on CSV or Parquet files.
  4. Pandas Friendly: It talks to your DataFrames with zero memory overhead.

Next time you’re waiting on a CSV to load, give DuckDB a try.

Check Your Understanding

Questions below move from simple recall up to open-ended design, roughly following Bloom’s Taxonomy.

Remember What is “Columnar Storage,” and why does it make selecting one column out of ten much faster than row-based storage?

Understand In your own words, explain the “No-ETL” workflow the article describes—what’s different about querying 'large_data.csv' directly in DuckDB versus the traditional “create table, import CSV, then query” workflow?

Apply Using the article’s Zero-Copy integration, if you already have a Pandas DataFrame called orders_df in memory with columns order_id and total, write the DuckDB SQL you’d use to compute the sum of total, following the article’s my_pandas_df example pattern.

Analyze The article says DuckDB reading a Parquet file’s metadata can answer a query “without ever loading the whole 10GB into your RAM.” Walk through why this only works for certain kinds of queries (like filtering on a column with metadata statistics) and wouldn’t fully avoid reading the data for a query that needs to inspect every row’s value (like a complex string match).

Evaluate The article’s closing “catch” says DuckDB “isn’t meant to replace a production database that thousands of people use at once.” Critique a team’s decision to use DuckDB as the backing store for a live internal dashboard that 50 analysts refresh throughout the day: what specifically would break down at that usage pattern that doesn’t show up in the article’s single-user examples?

Create Design a DuckDB query (like the article’s “Hero Query”) for a new scenario: joining a products.csv file (product_id, category) with a returns.parquet file (product_id, return_reason) to find the total number of returns per category. Sketch the SQL, following the article’s join-across-file-formats pattern.


References & Further reading

  • DuckDB Official Documentation — Covers the Python API, SQL dialect, file-reading functions, and the zero-copy integration with Pandas/Polars/Arrow in detail.
  • DuckDB Data Import Guide — Reference for reading CSV, Parquet, and JSON files directly, including supported options for schema inference and predicate pushdown.

Apply What You Learned

Brief: Your team shipped a feature-serving FastAPI microservice backed by DuckDB. At import time it loads ML features from a 2 GB features.parquet file into an in-memory DuckDB table, then serves them via HTTP.

In local dev with uvicorn (single worker), every request returns in under 10 ms — DuckDB reads only the needed column, exactly the columnar advantage the article benchmarks. In production with gunicorn --preload -w 4, however, 3 of 4 requests return empty results or 500 Internal Server Error.

Here is the service code:

import duckdb
from fastapi import FastAPI

app = FastAPI()

# Load features once at import time
con = duckdb.connect(database=':memory:')
con.execute("CREATE TABLE features AS SELECT * FROM 'features.parquet'")

@app.get("/features/{user_id}")
def get_features(user_id: int):
    return con.execute(
        "SELECT * FROM features WHERE user_id = ?", [user_id]
    ).fetchdf().to_dict(orient='records')

Find the bug.

Deliverable: In 200–300 words, name the root cause, identify the exact line responsible, cite the specific DuckDB behavior from the article that explains the failure under gunicorn --preload -w 4, propose a minimal fix, and evaluate whether DuckDB belongs in this production service at all — referencing the article’s tradeoffs for multi-user, always-on systems.

Rubric (checklist):

  • Identifies duckdb.connect(database=':memory:') as the root cause: with --preload, the import runs once in the master process, but :memory: is per-process and ephemeral — the four forked workers inherit a stale or broken in-memory database whose internal threads and state do not survive fork(), so most workers serve empty results or crash.
  • Cites the article’s explanation: :memory: “spins up an ephemeral database entirely in RAM” and “when the Python process exits, the database vanishes” — the same ephemerality that makes it convenient for ad-hoc analysis breaks multi-worker service deployments.
  • Proposes a concrete minimal fix: build features.duckdb (a persistent on-disk file) once — before the workers fork, e.g. in a one-off build step or a --preload-safe guard that only the master process runs — via a single read-write connection, then have each worker’s FastAPI startup hook open its own connection with duckdb.connect('features.duckdb', read_only=True). Concurrent read-only connections to the same file are supported, so all four workers can serve reads at once once the file exists.
  • Notes that even a file-based fix has a real DuckDB ceiling worth naming: only one process may hold a read-write connection to a given file at a time, and while it’s open every other connection (read or write) is excluded. Four workers reading concurrently is fine; the moment any worker also needs to write (e.g. to rebuild or refresh the table in place), it will contend with — and block — everyone else.
  • Recommends switching to a proper database (Postgres, ClickHouse, etc.) for a 24/7, multi-worker feature service if writes need to happen concurrently with reads or on a rolling basis, citing the article’s “always-on services” and “concurrent access” thresholds from the tradeoffs section — and explains that a read-only DuckDB file is a reasonable stopgap only as long as refreshes can be done as an atomic file swap, not an in-place write.

Looking for something else?

Search every article by title, summary or topic.