DuckDB vs Polars for Royalty Aggregation at Scale

Once monthly DSP sales CSVs have been parsed and typed, the next bottleneck is aggregation: grouping hundreds of millions of stream-count rows by isrc, territory_code, and reporting_period, then joining the result against a rights-share table to compute payee payouts. Two tools now dominate this stage of Automated CSV Parsing for Sales Reports: DuckDB, an embedded columnar SQL engine, and Polars (>=0.20), a Rust-backed dataframe library with a lazy query API. Both beat pandas at this scale, but they diverge in how they handle out-of-core spilling, join strategy, and how comfortably a royalty team can audit the transformation logic after the fact.

Aggregation Trade-offs

Dimension DuckDB Polars (>=0.20) Royalty Impact
Query interface SQL (GROUP BY, window functions) Lazy dataframe API (.group_by().agg()) SQL is easier for royalty managers to audit directly; Polars keeps everything in typed Python but requires reading chained method calls
Out-of-core spilling Automatic disk spilling past memory_limit Streaming engine (collect(streaming=True)) but less mature spill-to-disk DuckDB is safer for unbounded quarterly report sizes without a pre-sized cluster; Polars streaming can still OOM on skewed group keys like a viral isrc
Join to rights tables Native JOIN on Parquet/CSV directly, no load step .join() requires both frames materialized or lazily scanned DuckDB can join a 50M-row sales file to a rights table without an explicit load, reducing the chance of a stale in-memory rights snapshot
Multi-key group-by (ISRC + territory + period) Single GROUP BY isrc, territory_code, reporting_period .group_by(["isrc", "territory_code", "reporting_period"]) Both compute identical results; DuckDB’s query planner reorders join/filter pushdown automatically, reducing manual tuning for split calculations
Decimal/precision handling Native DECIMAL(18,4) type Decimal support present but less battle-tested than Float64 paths DuckDB’s DECIMAL avoids float rounding drift on cumulative gross_payout sums across millions of rows
Ecosystem fit Zero-copy queries over Parquet/CSV in place, embeds in any Python process First-class DataFrame ergonomics, easy chaining with existing pandas-adjacent code Teams migrating from pandas onboard faster with Polars; teams with existing SQL-literate royalty analysts onboard faster with DuckDB

Prerequisites & Assumptions

Examples assume Python 3.11+, duckdb>=0.10, polars>=0.20, and pyarrow>=14 for interchange, with royalty CSVs already normalized to Parquet by an upstream step such as optimizing pandas for 10GB royalty CSVs. The working dataset is a monthly sales fact table (isrc, territory_code, reporting_period, stream_count, gross_payout) joined against a rights_shares table (isrc, payee_id, right_share_pct). Both engines below run on a single machine with 16–32GB RAM against files in the 5–50GB range — the size band where pandas reliably fails but a distributed cluster is still overkill.

Step 1: Group-by aggregation in DuckDB

DuckDB queries Parquet files directly without an explicit load step, and its query planner pushes the GROUP BY down to the scan.

python
import duckdb

con = duckdb.connect(database=":memory:")
con.execute("SET memory_limit='24GB'")
con.execute("SET temp_directory='/var/tmp/duckdb_spill'")

result = con.execute("""
    SELECT
        isrc,
        territory_code,
        reporting_period,
        SUM(stream_count) AS total_streams,
        SUM(gross_payout)::DECIMAL(18,4) AS total_gross_payout
    FROM read_parquet('sales/2024-q3/*.parquet')
    GROUP BY isrc, territory_code, reporting_period
""").arrow()

Setting temp_directory explicitly is what lets DuckDB spill intermediate hash tables to disk once memory_limit is exceeded, rather than raising an out-of-memory error mid-aggregation — critical for a quarterly batch where row counts can double without warning.

Step 2: Equivalent aggregation in Polars

Polars expresses the same aggregation through its lazy API, deferring execution until .collect() so the query optimizer can fuse the scan, filter, and group-by into one pass.

python
import polars as pl

lazy_sales = pl.scan_parquet("sales/2024-q3/*.parquet")

result = (
    lazy_sales
    .group_by(["isrc", "territory_code", "reporting_period"])
    .agg([
        pl.col("stream_count").sum().alias("total_streams"),
        pl.col("gross_payout").sum().cast(pl.Decimal(18, 4)).alias("total_gross_payout"),
    ])
    .collect(streaming=True)
)

streaming=True enables Polars’ out-of-core execution engine, processing the query in batches rather than materializing the full intermediate hash table — but skewed keys (a single viral track dominating one isrc group) can still force a large partial aggregate into memory at once.

Step 3: Joining aggregated sales to the rights table

The payout-critical step is joining aggregated streams to rights_shares and multiplying by right_share_pct. Both engines support this, but DuckDB’s SQL join reads more directly as an auditable formula.

python
# DuckDB: join and compute payee-level payout in one statement
payout = con.execute("""
    SELECT
        s.isrc,
        s.territory_code,
        r.payee_id,
        s.total_gross_payout * (r.right_share_pct / 100.0) AS payee_payout
    FROM aggregated_sales s
    JOIN rights_shares r ON s.isrc = r.isrc
""").arrow()
python
# Polars: equivalent join expressed as a lazy chain
payout = (
    aggregated_sales_lazy
    .join(rights_shares_lazy, on="isrc", how="inner")
    .with_columns(
        (pl.col("total_gross_payout") * (pl.col("right_share_pct") / 100.0))
        .alias("payee_payout")
    )
    .collect(streaming=True)
)

Step 4: Choosing based on downstream consumers

If royalty managers or auditors need to inspect or modify the aggregation logic directly, DuckDB’s SQL is the lower-friction choice — it can also be pointed at the same Parquet files from a BI tool without any Python in between. If the pipeline is entirely Python-native and already leans on Polars elsewhere (for example alongside optimizing pandas for 10GB royalty CSVs migrations), staying in the lazy dataframe API avoids a second query language in the codebase.

Verification & Validation

Regardless of engine, validate the aggregation with a split-sum assertion before writing to the ledger: for every isrc, the sum of payee_payout across all payee_id rows should equal total_gross_payout within a cent of rounding tolerance.

python
tolerance = 0.01
check = payout.group_by("isrc").agg(pl.col("payee_payout").sum().alias("summed"))
# join back to total_gross_payout and assert abs(summed - total_gross_payout) < tolerance

Log the row count in, row count out, and total gross_payout before and after aggregation to the append-only audit trail, keyed by batch_id. A mismatch between pre- and post-aggregation totals — even a fractional-cent difference beyond Decimal rounding — should halt the batch rather than proceed to payout generation.

Edge Cases & Gotchas

Territory-code mismatches are the most common silent failure: a sales file using "GB" joined against a rights table keyed on "UK" produces an inner join that drops rows without raising an error in either engine — always assert row-count parity between the group-by output and the post-join output. DuckDB’s DECIMAL casts fail loudly on values exceeding the declared precision (e.g., a corrupted gross_payout of 1e20), whereas Polars’ Decimal cast can silently produce null in some versions — pin polars>=0.20.19 where this was hardened, and always check .null_count() on cast columns before proceeding. Orphaned ISRCs with no matching row in rights_shares disappear under an inner join in both engines; use a LEFT JOIN (DuckDB) or how="left" (Polars) and route unmatched rows to a fallback royalty queue instead of dropping them. Finally, DuckDB’s memory_limit and temp_directory settings are connection-scoped, not global — forgetting to set them on a freshly opened connection in a worker pool silently reverts to default limits under concurrent load.

Both engines slot in after the CSV-to-Parquet normalization covered in optimizing pandas for 10GB royalty CSVs, and the choice between them feeds directly into the reconciliation gates defined across Automated CSV Parsing for Sales Reports.