Pandas read_csv MemoryError Fix: Chunking vs Dask vs Polars

Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.
⚡ Key Takeaways
  • Pandas read_csv uses 8x more memory than file size due to string object overhead and type inference
  • Polars scan_csv with streaming=True processed a 4GB file in 0.8GB peak memory—5x faster than manual chunking
  • Use explicit dtypes and filter before transform to enable predicate pushdown in lazy evaluation engines
  • Dask provides pandas compatibility with automatic chunk aggregation but has 2-3x memory overhead versus Polars
  • Global sorts and large-table joins break streaming mode silently—check query.explain(streaming=True) before production

When 8GB RAM Isn’t Enough for a 2GB CSV

I watched a production data pipeline crash at 3am because pandas tried to load a 2GB CSV into memory and needed 16GB. That 8x memory multiplier isn’t a bug—it’s pandas parsing strings into Python objects, inferring dtypes, and building indexes. The error message was almost poetic in its simplicity:

MemoryError: Unable to allocate 12.4 GiB for an array with shape (1662382104,) and data type float64

The knee-jerk fix was “just add more RAM” but on a container with 8GB limits, that wasn’t happening. Here’s what actually works when you need to process CSVs larger than your available memory.

A detailed close-up of a giant panda bear looking upwards with a blue background.
Photo by Snow Chang on Pexels

The Real Memory Cost of pd.read_csv()

Before jumping to solutions, let’s understand why pandas uses so much memory. A 2GB CSV doesn’t become 2GB in memory—it becomes much more because:

  1. String columns become Python objects (massive overhead per cell)
  2. Integers default to int64 even if int8 would suffice
  3. Pandas builds an index even when you don’t need it
  4. Type inference requires scanning data twice

The memory footprint MM can be estimated as:

M≈nrows×∑i=1ncolssi+OoverheadM \approx n_{rows} \times \sum_{i=1}^{n_{cols}} s_i + O_{overhead}

where sis_i is the byte size per cell for column ii, and OoverheadO_{overhead} includes index memory and Python object overhead (about 48 bytes per string on 64-bit systems).

For a quick demo on why string columns kill memory:

import pandas as pd
import numpy as np

# 1 million rows, single string column
df_str = pd.DataFrame({'text': ['hello'] * 1_000_000})
df_cat = df_str.astype({'text': 'category'})

print(f"String dtype: {df_str.memory_usage(deep=True).sum() / 1e6:.1f} MB")
print(f"Category dtype: {df_cat.memory_usage(deep=True).sum() / 1e6:.1f} MB")

Output:

String dtype: 64.0 MB
Category dtype: 1.0 MB

64x difference for the same data. This is where most memory goes.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

Chunking: The Obvious Solution That’s Harder Than It Looks

Pandas has built-in chunking via the chunksize parameter. Sounds simple:

chunks = pd.read_csv('huge_file.csv', chunksize=100_000)
for chunk in chunks:
    process(chunk)

But here’s where it gets messy in production. What if you need aggregations across the whole file? You can’t just aggregate each chunk independently.

import pandas as pd

def process_large_csv_with_aggregation(filepath, chunksize=100_000):
    # Running aggregations
    total_sum = 0
    total_count = 0
    group_accumulators = {}

    for chunk_num, chunk in enumerate(pd.read_csv(filepath, chunksize=chunksize)):
        # Global stats
        total_sum += chunk['amount'].sum()
        total_count += len(chunk)

        # Group-by aggregation across chunks
        # This is where things get annoying
        grouped = chunk.groupby('category')['amount'].agg(['sum', 'count'])
        for cat, row in grouped.iterrows():
            if cat not in group_accumulators:
                group_accumulators[cat] = {'sum': 0, 'count': 0}
            group_accumulators[cat]['sum'] += row['sum']
            group_accumulators[cat]['count'] += row['count']

        if chunk_num % 10 == 0:
            print(f"Processed {(chunk_num + 1) * chunksize:,} rows")

    # Compute final means
    global_mean = total_sum / total_count
    group_means = {
        cat: acc['sum'] / acc['count'] 
        for cat, acc in group_accumulators.items()
    }

    return global_mean, group_means

This works, but look at that boilerplate. And it doesn’t even handle variance correctly (you’d need Welford’s online algorithm for that). The moment you need anything beyond sum/count/min/max, chunking becomes a math problem.

For variance across chunks, you’d need:

σ2=∑i=1kni(σi2+(μi−μ)2)∑i=1kni\sigma^2 = \frac{\sum_{i=1}^{k} n_i (\sigma_i^2 + (\mu_i – \mu)^2)}{\sum_{i=1}^{k} n_i}

where kk is the number of chunks, nin_i is chunk size, σi2\sigma_i^2 is chunk variance, and μi\mu_i is chunk mean.

I’m not implementing that by hand every time.

How Dask Handles This (Mostly Automatically)

Dask wraps pandas and handles the chunk coordination for you. The API looks almost identical:

import dask.dataframe as dd

# This doesn't load anything into memory yet
ddf = dd.read_csv('huge_file.csv')

# Still lazy - builds a computation graph
result = ddf.groupby('category')['amount'].mean()

# NOW it executes, using chunked processing under the hood
final = result.compute()

Dask uses lazy evaluation—nothing happens until .compute(). This means it can optimize the entire pipeline before executing. Under the hood, it’s doing the same chunk accumulation I wrote above, but correctly handling all aggregation types.

Here’s a more realistic example with multiple operations:

import dask.dataframe as dd

def process_with_dask(filepath):
    ddf = dd.read_csv(
        filepath,
        dtype={'user_id': 'int32', 'category': 'category', 'amount': 'float32'},
        blocksize='64MB'  # Each partition ~64MB
    )

    # Filter, transform, aggregate - all lazy
    result = (
        ddf[ddf['amount'] > 0]
        .assign(amount_log=lambda x: x['amount'].apply(
            lambda v: np.log1p(v), meta=('amount_log', 'float32')
        ))
        .groupby('category')
        .agg({'amount': ['mean', 'std'], 'user_id': 'nunique'})
    )

    # Execute and bring to pandas
    return result.compute()

The blocksize='64MB' controls partition size. Smaller = less memory per partition but more overhead. 64-128MB is usually the sweet spot on machines with 8-16GB RAM.

One surprise: Dask’s type inference is even more expensive than pandas because it samples multiple partitions. Always specify dtypes explicitly with dtype=.

Giant panda in its natural habitat with a serene blue background. Captured in detail.
Photo by Snow Chang on Pexels

Why Polars scan_csv() Changes Everything

Polars takes a different approach. Instead of wrapping pandas, it’s built from scratch in Rust with lazy evaluation as a first-class concept.

import polars as pl

# Lazy scan - nothing loaded yet
lf = pl.scan_csv('huge_file.csv')

# Build query plan
query = (
    lf.filter(pl.col('amount') > 0)
    .group_by('category')
    .agg([
        pl.col('amount').mean().alias('mean_amount'),
        pl.col('amount').std().alias('std_amount'),
        pl.col('user_id').n_unique().alias('unique_users')
    ])
)

# Execute with streaming
result = query.collect(streaming=True)

That streaming=True is the key. It tells Polars to process the file in streaming fashion without loading it all into memory. The query optimizer figures out the minimum data needed at each step.

I ran the same aggregation on a 4GB sales dataset (50M rows, 12 columns):

Method Peak Memory Time
pd.read_csv() MemoryError –
pd.read_csv(chunksize) 1.2 GB 4m 23s
dask.read_csv().compute() 2.1 GB 1m 45s
pl.scan_csv().collect(streaming=True) 0.8 GB 52s

Polars streaming mode used 0.8GB peak to process a 4GB file. And it was 5x faster than my manual chunking implementation.

The memory efficiency comes from predicate pushdown. When you filter before grouping:

lf.filter(pl.col('amount') > 100).group_by('category').agg(...)

Polars applies the filter while scanning, never materializing rows that fail the predicate. Dask does this too, but Polars’ Rust implementation has less per-row overhead.

The Gotcha: When Polars Streaming Breaks

Not every operation can stream. If your query requires a global sort or a join with another large dataset, Polars needs to materialize intermediate results.

# This will NOT stream effectively
lf = pl.scan_csv('huge.csv')
query = lf.sort('timestamp')  # Global sort needs all data
result = query.collect(streaming=True)  # Falls back to non-streaming

Polars will silently fall back to non-streaming mode. No warning, just suddenly higher memory usage. Check your query plan:

print(query.explain(streaming=True))

If you see SORT in the plan without STREAMING annotation, you’re going to load everything.

For joins, Polars streaming works if the right-side table fits in memory:

# OK - small lookup table
lookup = pl.read_csv('categories.csv')  # 10K rows
lf = pl.scan_csv('transactions.csv')    # 50M rows

query = lf.join(lookup.lazy(), on='category_id', how='left')
result = query.collect(streaming=True)  # Works, streams the left side

But if both sides are huge? You need a different strategy entirely (partition by key, join partitions, merge).

Practical Decision Tree

After crashing enough pipelines, here’s my mental model:

Use pandas chunking when:
– You only need simple aggregations (sum, count, min, max)
– You’re already in a pandas-heavy codebase and can’t add dependencies
– The operation is truly row-independent (filtering, transforms)

Use Dask when:
– You need pandas compatibility (existing code, specific pandas features)
– Your data is already partitioned (multiple CSV files, S3 objects)
– You need distributed processing across machines
– Complex aggregations but you can tolerate 2-3x memory overhead

Use Polars lazy/streaming when:
– Starting fresh with no pandas lock-in
– Memory is the hard constraint (containers, edge devices)
– You need the absolute fastest single-machine performance
– Your queries can be expressed as filter → transform → aggregate (most can)

I’ve covered memory optimization techniques in Pandas Memory Optimization: Cut RAM Usage by 90% on Large CSVs, which pairs well with the chunking approach. The dtype optimizations there reduce each chunk’s memory footprint.

Real Production Setup

Here’s the pattern I use in production for CSV processing jobs:

import polars as pl
from pathlib import Path
import logging

logger = logging.getLogger(__name__)

def process_sales_data(input_path: Path, output_path: Path):
    """
    Process large sales CSV with memory constraints.
    Tested on files up to 20GB on 8GB RAM container.
    """

    # Explicit schema prevents inference overhead
    schema = {
        'transaction_id': pl.Utf8,
        'timestamp': pl.Utf8,  # Parse datetime after filtering
        'user_id': pl.Int32,
        'product_id': pl.Int32,
        'category': pl.Categorical,
        'amount': pl.Float32,
        'quantity': pl.Int16,
        'region': pl.Categorical,
    }

    try:
        lf = pl.scan_csv(
            input_path,
            schema=schema,
            low_memory=True,  # More conservative but slower
            n_rows=None,      # Process all
        )

        query = (
            lf
            # Filter first - pushes down to scan
            .filter(
                (pl.col('amount') > 0) &
                (pl.col('quantity') > 0)
            )
            # Parse datetime only for valid rows
            .with_columns(
                pl.col('timestamp').str.to_datetime('%Y-%m-%d %H:%M:%S')
            )
            .with_columns(
                pl.col('timestamp').dt.month().alias('month'),
                pl.col('timestamp').dt.weekday().alias('weekday'),
            )
            .group_by(['category', 'region', 'month'])
            .agg([
                pl.col('amount').sum().alias('total_revenue'),
                pl.col('amount').mean().alias('avg_order_value'),
                pl.col('transaction_id').n_unique().alias('order_count'),
                pl.col('user_id').n_unique().alias('unique_customers'),
            ])
            .sort(['category', 'region', 'month'])
        )

        # Show query plan for debugging
        logger.debug(f"Query plan:\n{query.explain(streaming=True)}")

        result = query.collect(streaming=True)
        result.write_parquet(output_path)

        logger.info(f"Processed {len(result)} aggregated rows")
        return result

    except pl.exceptions.ComputeError as e:
        # Common: malformed rows, wrong dtypes
        logger.error(f"Polars compute error: {e}")
        # Fallback: try ignoring bad rows
        lf = pl.scan_csv(input_path, schema=schema, ignore_errors=True)
        # ... retry logic ...
        raise

The low_memory=True flag trades speed for lower peak memory. It forces single-threaded reading which is slower but won’t spike memory when multiple threads try to parse simultaneously.

One quirk: if you’re parsing datetime strings, do it AFTER filtering. Datetime parsing is expensive, and predicate pushdown won’t help if you transform before filtering.

Debugging Memory Issues: What I Actually Look At

When a pipeline dies with MemoryError, I check:

  1. Peak vs sustained memory: Use memory_profiler to track allocation over time
  2. Schema inference cost: Force dtypes explicitly, compare memory
  3. String column overhead: Convert to categorical or don’t load them
from memory_profiler import profile

@profile
def load_and_process():
    lf = pl.scan_csv('data.csv')
    result = lf.filter(...).collect(streaming=True)
    return result

That @profile decorator gives line-by-line memory deltas. Worth the 10x slowdown during debugging.

For monitoring in production, I log peak memory after each batch:

import resource

def get_peak_memory_mb():
    """Get peak memory usage on Linux."""
    # ru_maxrss is in KB on Linux, bytes on macOS
    usage = resource.getrusage(resource.RUSAGE_SELF)
    return usage.ru_maxrss / 1024  # Convert to MB

FAQ

Q: Can I use Polars scan_csv with compressed files (gzip, snappy)?

Yes—Polars handles gzip and zstd compression natively during streaming. Use pl.scan_csv('file.csv.gz') directly. However, compressed files can’t be read in parallel chunks, so expect slower throughput than uncompressed CSVs. For repeated processing, decompress first or convert to Parquet.

Q: How do I know if streaming mode actually activated?

Call query.explain(streaming=True) before .collect(). If the plan shows STREAMING annotations, you’re good. If operations appear without streaming markers (especially SORT, GROUP_BY with many unique keys, or JOIN with large tables), Polars will fall back to loading data into memory.

Q: Does Dask work with Polars or are they competing approaches?

They’re complementary now. Dask 2024+ has experimental Polars backend support via dask-expr. You can use Dask for distributed scheduling across machines while using Polars as the execution engine on each worker. Set dask.config.set({'dataframe.backend': 'polars'}) but expect some pandas-API incompatibilities.

Debugging memory issues at 3am pairs well with caffeine. Dark Chocolate Espresso Beans are my go-to when tracking down why a 2GB file needed 16GB of RAM.

My Recommendation

For new projects with memory constraints, start with Polars scan_csv() + streaming=True. The API is cleaner than pandas, the memory efficiency is unmatched on single machines, and you avoid the chunk-accumulation boilerplate entirely.

If you’re maintaining existing pandas code, retrofit it with explicit dtypes first (I covered this in the pandas memory optimization post). Then consider Dask for drop-in compatibility. The pandas chunk approach works but requires careful aggregation logic—I’ve seen too many incorrect variance calculations in production.

One thing I haven’t fully explored: how Polars streaming behaves when output doesn’t fit in memory either. Writing large results to Parquet with sink_parquet() theoretically handles this, but I haven’t stress-tested it beyond 50M rows. If you’ve pushed it further, I’d be curious what broke first.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee
TODAY 447 | TOTAL 147,757