The Real-World Problem: When Pandas Crumbles Under 50 Million Rows
Last month, our team ran into a familiar bottleneck. We needed to parse 35 GB of raw monthly transaction logs across 48 CSV and Parquet files. Our goal was simple: roll up summary statistics, group users into cohorts, and flag anomalous credit card charges.
Naturally, we reached for Pandas first. That was a mistake. Running pd.read_csv() on just one 5.2 GB file sent resident memory past 16 GB within 40 seconds. The workstation froze, swap space churned, and Linux’s out-of-memory (OOM) killer terminated the Python process mid-read. We tried splitting inputs with chunksize=100_000. While that avoided crashes, computing stateful aggregations like distinct user counts across chunks required hundreds of lines of fragile glue code.
Spinning up a local Postgres or ClickHouse container in Docker was our fallback. But provisioning an analytical database brings its own headaches. You must write rigid DDL schemas, tune shared buffers, map host ports, and wait 20 minutes just to ingest data before running a single query.
Root Cause Analysis: Memory Footprint and Row-Oriented Limitations
Why do standard data tools struggle once datasets pass a few gigabytes? The issue comes down to how Python data structures and row-oriented engines operate under the hood:
- Python Object Overhead: Standard Pandas stores strings as generic Python object pointers unless you explicitly configure the PyArrow engine. Each cell brings 40 to 60 bytes of metadata overhead. A 1.2 GB CSV can easily balloon to 5.8 GB of physical RAM.
- Defensive Copying: Filtering expressions like
df[df['status'] == 'completed']allocate entire new DataFrames by default. Chaining three operations triples memory consumption unless you manage views and garbage collection manually. - Row-Based vs. Columnar Processing: SQLite stores rows contiguously. If you calculate
AVG(latency)across 40 million records, SQLite reads every timestamp, IP address, and payload off disk just to extract one integer. That wastes disk I/O and trashes CPU caches with useless attributes. - Single-Thread Bottlenecks: Both SQLite and core Pandas execute analytic operations on a single core. The remaining 7 or 15 cores on your CPU sit idle while one thread pegs at 100%.
Solutions Compared: Pandas, SQLite, Spark, and DuckDB
When you need to analyze 10 million to 100 million rows on a developer machine, four options dominate:
1. Pandas / Chunked Processing
Pandas shines when data fits comfortably within 20% of system RAM. Push past that limit, and the lack of an execution planner bites you. Chunking logic is brittle and turns simple aggregations into a maintenance nightmare.
2. SQLite
SQLite is bulletproof for transactional app storage. However, running grouped sums over 50 million records takes minutes instead of seconds because it lacks parallel scans and columnar storage.
3. Local Apache Spark / PySpark
Spark handles massive workloads without flinching. But running it locally on a single machine is cumbersome. You inherit JVM overhead, 15-second executor launch times, and arcane memory tuning flags for an exploratory script.
4. DuckDB
DuckDB bridges this gap. Think of it as SQLite purpose-built for analytics. It runs in-process as an embedded C++ engine with zero background services. Under the hood, it uses vectorized columnar execution, saturates all CPU cores, and streams datasets larger than RAM directly from disk.
The Best Approach: Hands-On Implementation with DuckDB and Python
The cleanest setup pairs DuckDB’s vectorized SQL engine directly with Python. You scan files on disk in place, process data in C++, and materialize only the final grouped results as a Pandas or Polars DataFrame.
1. Installation
DuckDB distributes pre-compiled wheels with zero external dependencies. Install the stack via pip:
pip install duckdb pyarrow pandas
2. Querying Raw Files Directly Without Database Import
DuckDB inspects headers and queries files directly. You skip table declarations and manual ingest steps entirely:
import duckdb
# Query 25 million records across partitioned Parquet files
query = """
SELECT
merchant_id,
COUNT(*) AS total_transactions,
ROUND(AVG(amount), 2) AS avg_amount,
ROUND(SUM(amount), 2) AS total_revenue
FROM 'data/transactions_*.parquet'
WHERE status = 'SUCCESS'
GROUP BY merchant_id
HAVING COUNT(*) > 1000
ORDER BY total_revenue DESC
LIMIT 10;
"""
# DuckDB streams the parquet footers and returns results in ~0.8s
results = duckdb.sql(query)
results.show()
3. Zero-Copy Integration with Python DataFrames
DuckDB shares memory buffers directly with Apache Arrow and Pandas. It pushes expensive compute down to its engine and hands back the slim summary rows with zero serialization overhead:
import duckdb
con = duckdb.connect(database=":memory:")
# Crunch 40 million rows down to 500 rows, then export to Pandas
summary_df = con.execute("""
SELECT
DATE_TRUNC('day', timestamp) AS tx_date,
country_code,
COUNT(DISTINCT user_id) AS active_users,
SUM(amount) AS gross_volume
FROM 'data/raw_access_logs.parquet'
GROUP BY 1, 2
""").df()
print(f"Processed summary shape: {summary_df.shape}")
print(summary_df.head())
4. Taming Datasets Larger Than RAM (Out-of-Core Processing)
What happens when you need to query an 80 GB clickstream dump on a 16 GB laptop? DuckDB handles memory-spilling automatically. You can explicitly cap memory consumption and point temp files to fast NVMe storage:
import duckdb
# Cap RAM usage and configure disk spilling
con = duckdb.connect(database="analytics_cache.duckdb")
con.execute("SET max_memory = '10GB';")
con.execute("SET temp_directory = '/tmp/duckdb_spill';")
con.execute("SET threads = 8;")
# Process out-of-core and save compressed Parquet back to disk
con.execute("""
COPY (
SELECT
user_id,
COUNT(*) AS event_count,
APPROX_COUNT_DISTINCT(session_id) AS approx_sessions
FROM 'data/huge_clickstream_dump.csv'
GROUP BY user_id
) TO 'output/user_behavior_metrics.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);
""")
con.close()
Production Insights from the Field
Here are three hard-won lessons from using DuckDB in daily ETL scripts and scheduled jobs:
- Favor Parquet over CSV: DuckDB parses CSVs quickly using parallel sniffers. But Parquet includes column statistics and byte offsets. This lets DuckDB skip entire file blocks when evaluating
WHEREfilters, cutting query times from 42 seconds to 3.5 seconds on a 20 GB dataset. - Query In-Memory Pandas DataFrames Directly: DuckDB treats any active DataFrame in your Python scope like an in-memory SQL table. You don’t need to convert it:
duckdb.sql("SELECT * FROM my_df WHERE score > 90")works right away. - Mind File Lock Contention: A DuckDB file supports multiple concurrent readers, but only one writer at a time. If your workflow runs parallel worker processes, have each worker write to an isolated temp file, then aggregate them through a unified view.
This workflow delivers. On our 35 GB pipeline, replacing manual chunking scripts and throwaway database containers with DuckDB dropped pipeline runtime from 18 minutes to 46 seconds. The resulting codebase is shorter, more reliable, and requires zero infrastructure upkeep.

