Polars iter_batches memory profiling for HTSUS releases
A full HTSUS release from USITC arrives as a wide, multi-hundred-thousand-row export, and calling pl.read_csv(...).collect() on it can spike resident memory to several gigabytes — enough to OOM a modest ingestion worker mid-load and strand a tariff update that clearance depends on. This page shows how to measure the true peak footprint of loading an HTSUS release with Polars, compare eager LazyFrame.collect() against streaming via iter_batches and sink_parquet, pick a batch size from the numbers rather than folklore, and hand fixed-size batches to an idempotent ingestion step so a re-run never doubles memory or duplicates rows. Every measurement uses tracemalloc for Python-object allocation plus resident-set sampling for the real process footprint, because the two disagree in ways that matter.
Prerequisites
This procedure assumes a specific toolchain and a known upstream shape. Confirm each before profiling:
- Python 3.10+ and Polars 0.20+. The streaming engine,
LazyFrame.sink_parquet, andDataFrame.iter_batchesbehave differently across minor versions; pin the exact build in your lockfile so a profile is reproducible. - A real HTSUS release file, not a sample. Peak memory scales with the widest column and the row count together. Profiling a 500-row excerpt tells you nothing about the release-day footprint. Download the current USITC HTS export (CSV or JSON) and profile that.
- A defined column schema. Let Polars infer types once, then pin an explicit
schema_overridesmapping. Duty-rate and value columns must land asDecimalorUtf8for later exact math — neverFloat64, which cannot represent ISO 4217 minor units. Inference that guessesFloat64for a rate column is itself a memory and correctness bug. - A downstream idempotent sink. This page produces batches; it does not upsert them. The receiving step is the point-in-time schema described in HTS Schedule Database Design, and the ingestion contract that consumes these batches is owned by Tariff Update Ingestion Pipelines.
- Structured logging configured (stdlib
loggingwith the%(asctime)s | %(levelname)s | %(name)s | %(message)sformat, orstructlog) so every batch’s byte count is greppable when a profile is later questioned in an audit.
Implementation
The profiler wraps three load strategies behind one interface and records, for each, the peak Python-object allocation from tracemalloc and the peak resident-set size sampled on a background thread. Eager collection materializes the entire frame; streamed collection lets the Polars engine bound working memory; batched iteration yields fixed-row chunks you can feed straight to an upsert.
import logging
import threading
import time
import tracemalloc
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from typing import Callable, Iterator, Optional
import polars as pl
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger("htsus_mem_profiler")
# Pin duty/value columns to exact types. Float64 cannot hold ISO 4217 minor
# units and inflates width; Utf8 keeps the raw rate for later Decimal parsing.
HTSUS_SCHEMA: dict[str, pl.DataType] = {
"hts_number": pl.Utf8,
"description": pl.Utf8,
"unit_of_quantity": pl.Utf8,
"general_rate": pl.Utf8,
"special_rate": pl.Utf8,
"column_2_rate": pl.Utf8,
"effective_date": pl.Date,
}
@dataclass(frozen=True)
class ProfileResult:
strategy: str
rows: int
batches: int
py_peak_mib: Decimal # tracemalloc peak, Python objects only
rss_peak_mib: Decimal # sampled resident-set peak, whole process
wall_seconds: Decimal
class _RssSampler(threading.Thread):
"""Sample process resident-set size every interval on a daemon thread."""
def __init__(self, interval: float = 0.05) -> None:
super().__init__(daemon=True)
self._interval = interval
self._stop = threading.Event()
self.peak_bytes = 0
def run(self) -> None:
import resource
while not self._stop.is_set():
# ru_maxrss is KiB on Linux, bytes on macOS; normalize on Linux.
rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss * 1024
self.peak_bytes = max(self.peak_bytes, rss)
time.sleep(self._interval)
def stop(self) -> int:
self._stop.set()
self.join(timeout=1.0)
return self.peak_bytes
def _mib(num_bytes: int) -> Decimal:
return (Decimal(num_bytes) / Decimal(1024 * 1024)).quantize(Decimal("0.1"))
def _profile(strategy: str, run: Callable[[], tuple[int, int]]) -> ProfileResult:
"""Run a load strategy under tracemalloc + RSS sampling; return peaks."""
sampler = _RssSampler()
sampler.start()
tracemalloc.start()
start = time.perf_counter()
rows, batches = run()
wall = Decimal(str(time.perf_counter() - start)).quantize(Decimal("0.01"))
_, py_peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
rss_peak = sampler.stop()
result = ProfileResult(
strategy=strategy,
rows=rows,
batches=batches,
py_peak_mib=_mib(py_peak),
rss_peak_mib=_mib(rss_peak),
wall_seconds=wall,
)
logger.info(
"profile %-14s rows=%d batches=%d py_peak=%sMiB rss_peak=%sMiB wall=%ss",
result.strategy, result.rows, result.batches,
result.py_peak_mib, result.rss_peak_mib, result.wall_seconds,
)
return result
def load_eager(path: Path) -> tuple[int, int]:
"""Baseline: whole release materialized in one frame. Worst-case peak."""
frame = pl.read_csv(path, schema_overrides=HTSUS_SCHEMA)
# Touch the frame so lazy columns are actually realized.
return frame.height, 1
def load_streamed(path: Path, out: Path) -> tuple[int, int]:
"""Lazy scan sunk to Parquet via the streaming engine; bounded memory."""
lf = pl.scan_csv(path, schema_overrides=HTSUS_SCHEMA)
lf.sink_parquet(out) # never holds the full frame in RAM
rows = pl.scan_parquet(out).select(pl.len()).collect().item()
return rows, 1
def iter_release_batches(
path: Path, batch_rows: int
) -> Iterator[pl.DataFrame]:
"""Yield fixed-size batches for an idempotent upsert. Peak ~= one batch."""
reader = pl.read_csv_batched(
path, schema_overrides=HTSUS_SCHEMA, batch_size=batch_rows
)
while (chunk := reader.next_batches(1)) is not None:
yield chunk[0]
def load_batched(path: Path, batch_rows: int) -> tuple[int, int]:
rows = 0
batches = 0
for batch in iter_release_batches(path, batch_rows):
rows += batch.height
batches += 1
# Downstream upsert would consume `batch` here; we only count.
return rows, batches
def compare(path: Path, work: Path, batch_rows: int) -> list[ProfileResult]:
results = [
_profile("eager", lambda: load_eager(path)),
_profile("streamed", lambda: load_streamed(path, work / "hts.parquet")),
_profile("batched", lambda: load_batched(path, batch_rows)),
]
baseline = results[0].rss_peak_mib
for r in results:
ratio = (r.rss_peak_mib / baseline).quantize(Decimal("0.01"))
logger.info("strategy=%s rss_peak_ratio_vs_eager=%s", r.strategy, ratio)
return results
The important contrast is between py_peak_mib and rss_peak_mib. tracemalloc sees only Python-object allocations, so it under-reports Polars, whose columnar buffers live in Rust-owned Arrow memory outside the Python allocator. The resident-set sampler is what actually predicts an OOM. Report both, but size your worker off the RSS peak.
collect() forms one tall peak that can cross the worker ceiling; iter_batches streaming holds resident memory to roughly a single batch, a repeating sawtooth that stays well under the ceiling.Verification steps
Run these checks against a real release before trusting a batch size in production:
- Establish the eager baseline. Run
compare()once and record theeagerrss_peak_mib. This is the number a naivecollect()worker must survive. If it already exceeds your container limit, eager loading was never safe and the streamed path is mandatory, not an optimization. - Confirm the RSS/Python gap. Check that
rss_peak_mibis materially larger thanpy_peak_mibfor every strategy. If they are nearly equal,tracemallocis not seeing the Arrow buffers and your sampler interval is likely too coarse — drop it to 0.02s and re-run. - Sweep batch size. Run
load_batchedat 25k, 50k, 100k, and 250k rows. Plotrss_peak_mibagainstbatch_rows; the peak should rise roughly linearly with batch size whilewall_secondsfalls then flattens. Pick the largest batch whose peak leaves at least 30% headroom under the worker ceiling. - Assert streamed bound independence. The
streamedstrategy’srss_peak_mibshould stay flat regardless of release size, becausesink_parquetnever holds the whole frame. If it tracks the eager peak, the streaming engine silently fell back to in-memory collection — check for an unsupported operation in the lazy plan. - Row-count reconciliation. Assert
sum(batch.height)acrossiter_release_batchesequals the eagerframe.heightequals the streamed Parquet row count. A shortfall means a final partial batch was dropped by an off-by-one in the batched reader. - Decimal fidelity spot-check. Parse
general_ratefrom a batch intoDecimaland confirm no column arrived asFloat64. A single inferred float silently rounds a compound duty rate and corrupts everything downstream in the Duty Formula Calculation Frameworks.
Edge cases & gotchas
ru_maxrssis a high-water mark, not a live gauge.resource.getrusagereports the maximum RSS the process has ever reached, so a single earlier eager load poisons every later measurement in the same process. Profile each strategy in a fresh subprocess, or the batched run will inherit the eager peak and look far worse than it is.tracemallocunder-reports Polars by design. Arrow-backed columns live in Rust-owned memory the Python allocator never sees. Never size a worker offpy_peak_mibalone — it can be an order of magnitude below the real footprint and will greenlight a batch size that OOMs on release day.- Schema inference doubles the first-pass cost. Letting Polars scan the whole file to infer types reads it twice and can transiently spike memory above the eager steady state. Pin
schema_overrides(asHTSUS_SCHEMAdoes) so inference is skipped entirely. Float64rate columns are a silent width and correctness trap. Beyond corrupting duty math, a wide float column inflates every batch. Keep rates asUtf8through ingestion and parse toDecimalonly at the point of computation.- Batch size interacts with the downstream transaction. A larger batch lowers per-row overhead but enlarges the upsert transaction and its lock footprint. The right size is a joint optimum between load-side memory here and the write-side contract in Tariff Update Ingestion Pipelines; tune them together, not in isolation.
- Idempotency depends on stable batch boundaries. If a re-run reads the release with a different
batch_size, batch N covers different rows and any per-batch checkpoint is meaningless. Fixbatch_rowsin config and treat it as part of the release’s ingestion identity, so a retried load resumes cleanly instead of double-applying. sink_parquetneeds the operation to be streamable. Some lazy operations (certain joins, sorts, and pivots) force the engine to collect fully, quietly defeating the memory bound. Keep the scan-to-sink path a straight projection and cast; do heavier reshaping after the data has landed.