Skip to content
AyoKoding

OLAP and Columnar Analytics Examples

Examples 86-91 cover columnar OLAP engines and formats: DuckDB's own column-projecting scan proven via EXPLAIN, a measured row-store-vs-columnar-store contrast on the identical aggregate, Parquet's column-chunk metadata proving a projected read touches only the requested columns' own bytes, Apache Arrow's zero-copy interchange between an in-memory table and DuckDB, ClickHouse's MergeTree engine with real partition pruning evidence, and a closing wide-column-vs-columnar contrast (against Example 85's TimescaleDB-vs-Cassandra feed) that makes co-36's own distinction concrete. Examples 86, 88, and 89 run entirely in-process (DuckDB, PyArrow); Example 87 also connects to the local TimescaleDB Docker container used as a plain Postgres row store; Example 90 runs against a real local ClickHouse Docker container (clickhouse/clickhouse-server:latest) via docker exec clickhouse-client, since the Homebrew-cask host binary is Gatekeeper-blocked non-interactively on this machine; Example 91 connects to the same local Cassandra 5.0 container prior examples used. DuckDB, ClickHouse, Apache Arrow, and Apache Parquet are all Apache-2.0 or MIT licensed -- co-28's license-awareness discipline applies here too, each example says so explicitly where it matters.


Example 86: DuckDB Columnar Scan

ex-86 · exercises co-32, co-33

Context: a 1,000-row, 6-column CSV loads into an in-process DuckDB table -- this example verifies, via EXPLAIN's own text plan, that a GROUP BY aggregate over 2 of those 6 columns projects only category and amount, never touching the 3 unreferenced "noise" columns.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73 -- color-blind friendly, WCAG AA
flowchart LR
    T[("amounts table<br/>6 columns")]:::orange
    T -->|"GROUP BY category, sum(amount)"| P["Projections:<br/>category, amount"]:::teal
    T -.->|never touched| N["noise1, noise2, noise3"]:::blue
 
    classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px

learning/code/ex-86-duckdb-columnar-scan/example.py

"""Example 86: DuckDB Columnar Scan."""  # => co-32,co-33: this file's own purpose, doubling as its module __doc__
from __future__ import annotations  # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
 
import csv  # => co-32: writes the source CSV this example loads -- a realistic OLAP ingestion path
import tempfile  # => generates a throwaway CSV path -- this example is fully self-contained
from pathlib import Path  # => co-32: typed filesystem path handling for the generated CSV file
 
import duckdb  # => co-33: duckdb, the official Python API for the in-process, MIT-licensed columnar OLAP engine
 
 
def write_sample_csv(row_count: int) -> Path:  # => co-32: many rows, several UNUSED columns -- sets up the projection check below
    """Write row_count rows with 6 columns, only 2 of which the query below will ever reference."""  # => documents the contract
    with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp:  # => co-32: mktemp() is deprecated -- this creates the file (and closes it) immediately instead
        path = Path(tmp.name)  # => a throwaway CSV path, unique per run
    with path.open("w", newline="") as f:  # => opens the file for writing
        writer = csv.writer(f)  # => a plain stdlib CSV writer -- no DuckDB-specific write path needed to CREATE the source data
        writer.writerow(["id", "category", "amount", "noise1", "noise2", "noise3"])  # => co-33: 3 UNUSED "noise" columns, deliberately
        for i in range(row_count):  # => co-32: row_count rows, many for a wide-scan aggregation to be meaningful
            category = "electronics" if i % 2 == 0 else "books"  # => co-32: 2 categories, evenly split
            writer.writerow([i, category, i * 1.5, "x" * 20, "y" * 20, "z" * 20])  # => co-33: the 3 noise columns are never referenced by the query below
    return path  # => hand back the path for DuckDB to load
 
 
def main() -> None:  # => entry point -- runs only when this file executes directly, not on import
    row_count = 1000  # => co-32: many rows of one column -- large enough for a genuine "many rows" aggregation
    csv_path = write_sample_csv(row_count)  # => co-32: writes the source CSV this in-process query loads
 
    conn = duckdb.connect()  # => co-33: an IN-PROCESS DuckDB connection -- no server, no separate process to manage
    conn.execute(f"CREATE TABLE amounts AS SELECT * FROM read_csv_auto('{csv_path}')")  # => co-33: loads the CSV directly into a DuckDB table
    csv_path.unlink()  # => cleans up the throwaway CSV -- the data now lives inside DuckDB's own table
 
    plan_text = conn.execute(  # => co-33: EXPLAIN's own text plan reveals EXACTLY which columns the scan node reads
        "EXPLAIN SELECT category, sum(amount) AS total FROM amounts GROUP BY category"  # => the SAME aggregation query, prefixed with EXPLAIN instead of run
    ).fetchall()[0][1]  # => the plan text lives in the second column of EXPLAIN's single result row
    assert "Projections:" in plan_text  # => co-33: confirms the scan node genuinely reports which columns it projects
    assert "noise1" not in plan_text and "noise2" not in plan_text and "noise3" not in plan_text  # => co-33: the 3 UNUSED columns are absent from the scan's own projection list
    assert "category" in plan_text and "amount" in plan_text  # => co-33: only the 2 REFERENCED columns appear in the scan's projection
 
    rows = conn.execute(  # => co-32: the actual typed-Python aggregation query
        "SELECT category, sum(amount) AS total FROM amounts GROUP BY category ORDER BY category"  # => the SAME query EXPLAIN just analyzed, now actually run
    ).fetchall()  # => materializes one row per distinct category
    totals_by_category = dict(rows)  # => category -> summed amount, as returned by DuckDB
 
    expected_electronics = sum(i * 1.5 for i in range(0, row_count, 2))  # => co-32: the INDEPENDENT, hand-computed expectation for electronics (even i)
    expected_books = sum(i * 1.5 for i in range(1, row_count, 2))  # => co-32: the INDEPENDENT, hand-computed expectation for books (odd i)
    assert totals_by_category["electronics"] == expected_electronics  # => co-32: DuckDB's SUM matches the hand-computed total exactly
    assert totals_by_category["books"] == expected_books  # => co-32: DuckDB's SUM matches the hand-computed total exactly
 
    print(f"electronics total: {totals_by_category['electronics']}")  # => Output: electronics total: 374250.0
    print(f"books total:       {totals_by_category['books']}")  # => Output: books total:       375000.0
    print("The scan node's own EXPLAIN output confirms it projects ONLY category and amount -- noise1/noise2/noise3 never touched")  # => Output line
    conn.close()  # => always release what you open
 
 
if __name__ == "__main__":  # => guards against running main() on `import example`
    main()  # => runs everything above when executed as a script

Run: python3 example.py (representative output, run against duckdb==1.5.5, entirely in-process -- no Docker container needed)

Output:

electronics total: 374250.0
books total:       375000.0
The scan node's own EXPLAIN output confirms it projects ONLY category and amount -- noise1/noise2/noise3 never touched

Key takeaway: DuckDB's own EXPLAIN output is the proof, not an assertion about it -- the scan node's Projections: list names exactly category and amount, and never the 3 unreferenced "noise" columns, for a table it never inspected column-by-column at query-writing time.

Why it matters: this is co-32 and co-33 made concrete before any comparison against a row store -- a columnar engine narrows what it reads down to the columns a query actually names, which is the mechanical basis for every "columnar is faster for analytics" claim the rest of this band measures directly rather than merely repeats.


Example 87: Row vs. Column Scan Contrast

ex-87 · exercises co-33, co-34

Context: the identical 1,000-row, 6-column dataset lands in both a plain Postgres table (a row store) and a DuckDB table (a column store) -- EXPLAIN (ANALYZE, BUFFERS)'s own shared hit=N count proves Postgres reads the SAME number of buffer blocks whether 2 or all 6 columns are selected, the mirror image of Example 86's column-count-sensitive DuckDB projection.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73 -- color-blind friendly, WCAG AA
flowchart LR
    A["Row store<br/>1 page, 6 cols"]:::orange -->|"SELECT reads<br/>whole row anyway"| B["hit = 15 blocks<br/>same either way"]:::blue
    C["Column store<br/>6 separate chunks"]:::teal -->|"SELECT 2 cols<br/>projects 2 chunks"| D["Projections:<br/>2 of 6 cols"]:::blue
 
    classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px

learning/code/ex-87-row-vs-column-scan-contrast/example.py

"""Example 87: Row vs. Column Scan Contrast."""  # => co-33,co-34: this file's own purpose, doubling as its module __doc__
from __future__ import annotations  # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
 
from typing import LiteralString  # => co-33: both call sites below pass a compile-time string constant -- LiteralString states that honestly
 
import duckdb  # => co-33: duckdb, the official Python API for the in-process, MIT-licensed columnar OLAP engine
import psycopg  # => co-33: psycopg, the official typed Python driver for PostgreSQL -- the row-store side of this contrast
from psycopg import sql  # => co-33: composes the literal query into a genuinely typed Composed object, not a plain f-string
 
 
def seed_postgres(conn: psycopg.Connection, row_count: int) -> None:  # => co-33: the SAME wide, 6-column shape ex-86's DuckDB table used
    """Seed row_count rows into a plain Postgres table -- a row-oriented store, no TimescaleDB hypertable involved."""  # => documents the contract
    conn.execute("DROP TABLE IF EXISTS amounts_pg")  # => resets state -- this example is fully self-contained
    conn.execute(  # => co-33: a PLAIN row-store table -- 6 columns, matching ex-86's DuckDB shape exactly
        "CREATE TABLE amounts_pg (id INT, category TEXT, amount DOUBLE PRECISION, noise1 TEXT, noise2 TEXT, noise3 TEXT)"  # => 3 real columns plus 3 UNUSED noise columns, matching ex-86's own shape
    )  # => closes the execute() call -- amounts_pg now exists as a plain, non-hypertable row store
    for i in range(row_count):  # => co-33: the IDENTICAL data ex-86 loaded into DuckDB, now in Postgres's own row-oriented heap
        category = "electronics" if i % 2 == 0 else "books"  # => the same 2-category split
        conn.execute(  # => co-33: each row lands as ONE physical row in Postgres's heap -- all 6 columns stored together
            "INSERT INTO amounts_pg VALUES (%s, %s, %s, %s, %s, %s)",  # => positional placeholders bind all 6 columns of this loop iteration's own row
            (i, category, i * 1.5, "x" * 20, "y" * 20, "z" * 20),  # => this loop iteration's own row, including the 3 noise columns
        )  # => closes this one execute() call -- runs once per row
    conn.commit()  # => makes all inserts durable before the buffer-read comparison below
 
 
def buffers_read(conn: psycopg.Connection, query: LiteralString) -> int:  # => co-33: the REAL shared-buffer block count Postgres reported reading
    """Run EXPLAIN (ANALYZE, BUFFERS) and return the total 'shared hit' block count from the plan text."""  # => documents the contract
    composed_query = sql.SQL("EXPLAIN (ANALYZE, BUFFERS) {}").format(sql.SQL(query))  # => co-33: a Composed object, not a plain f-string -- resolves psycopg's own typed overload
    plan_lines = conn.execute(composed_query).fetchall()  # => co-33: BUFFERS reports the ACTUAL blocks touched, not an estimate
    for (line,) in plan_lines:  # => scans every plan line for the "Buffers:" summary Postgres appends
        if "Buffers:" in line and "shared hit=" in line:  # => co-33: the line reporting how many shared buffer blocks this query actually touched
            return int(line.split("shared hit=")[1].split()[0].rstrip(","))  # => co-33: parses the block count out of "Buffers: shared hit=N"
    return 0  # => co-33: no buffer usage reported -- should not happen for a non-trivial table scan
 
 
def main() -> None:  # => entry point -- runs only when this file executes directly, not on import
    row_count = 1000  # => co-33: the SAME row count ex-86 used, for a directly comparable aggregation
 
    pg_conn = psycopg.connect("host=localhost port=5433 dbname=nosqldb user=postgres password=nosqldb")  # => connects to the local Postgres/TimescaleDB Docker container -- used here as a PLAIN row store
    seed_postgres(pg_conn, row_count)  # => co-33: seeds the identical 6-column, 1000-row dataset ex-86 used
 
    duck_conn = duckdb.connect()  # => co-33: an IN-PROCESS DuckDB connection, columnar
    duck_conn.execute("CREATE TABLE amounts_duck (id INT, category TEXT, amount DOUBLE, noise1 TEXT, noise2 TEXT, noise3 TEXT)")  # => the SAME 6-column shape, in DuckDB
    for i in range(row_count):  # => co-33: the IDENTICAL data, loaded independently into DuckDB's own columnar storage
        category = "electronics" if i % 2 == 0 else "books"  # => the same 2-category split
        duck_conn.execute("INSERT INTO amounts_duck VALUES (?, ?, ?, ?, ?, ?)", [i, category, i * 1.5, "x" * 20, "y" * 20, "z" * 20])  # => the IDENTICAL row this loop just inserted into Postgres
 
    pg_result = dict(pg_conn.execute("SELECT category, sum(amount) FROM amounts_pg GROUP BY category ORDER BY category").fetchall())  # => Postgres's own aggregate
    duck_result = dict(duck_conn.execute("SELECT category, sum(amount) FROM amounts_duck GROUP BY category ORDER BY category").fetchall())  # => DuckDB's own aggregate
    assert pg_result == duck_result  # => co-33: BOTH engines return the IDENTICAL aggregate for the identical data
    print(f"Postgres (row store)   aggregate: {pg_result}")  # => Output: Postgres (row store)   aggregate: {'books': 375000.0, 'electronics': 374250.0}
    print(f"DuckDB (columnar store) aggregate: {duck_result}")  # => Output: DuckDB (columnar store) aggregate: {'books': 375000.0, 'electronics': 374250.0}
 
    narrow_buffers = buffers_read(pg_conn, "SELECT category, sum(amount) FROM amounts_pg GROUP BY category")  # => co-33: reading ONLY 2 of 6 columns
    wide_buffers = buffers_read(pg_conn, "SELECT * FROM amounts_pg")  # => co-33: reading ALL 6 columns
    print(f"Postgres buffers read, selecting 2 columns:  {narrow_buffers}")  # => Output line -- exact block count machine-dependent, but the EQUALITY below is the point
    print(f"Postgres buffers read, selecting all 6 columns: {wide_buffers}")  # => Output line
    assert narrow_buffers == wide_buffers  # => co-33: Postgres's heap scan reads the SAME blocks either way -- row storage cost is COLUMN-COUNT-INVARIANT
    print("Postgres reads the SAME number of buffer blocks whether 2 or all 6 columns are selected -- a row-oriented heap page holds whole rows, so narrowing the SELECT list does not reduce I/O")  # => Output line
    # => co-33: DuckDB's own EXPLAIN plan (verified directly in Example 86) shows it projects ONLY the
    # => referenced columns (category, amount) out of the table's 6 -- a columnar layout stores each
    # => column separately on disk, so a query naming fewer columns genuinely reads less data; a
    # => row-store's heap page holds an entire row together, so ANY column reference forces reading the
    # => WHOLE row regardless of how few columns the query actually names
 
    pg_conn.close()  # => always release what you open
    duck_conn.close()  # => always release what you open
 
 
if __name__ == "__main__":  # => guards against running main() on `import example`
    main()  # => runs everything above when executed as a script

Run: python3 example.py (representative output, run against psycopg[binary]==3.3.4, duckdb==1.5.5, and the same local timescale/timescaledb:2.28.3-pg16 Docker container Examples 81-85 used, here as a plain Postgres row store)

Output:

Postgres (row store)   aggregate: {'books': 375000.0, 'electronics': 374250.0}
DuckDB (columnar store) aggregate: {'books': 375000.0, 'electronics': 374250.0}
Postgres buffers read, selecting 2 columns:  15
Postgres buffers read, selecting all 6 columns: 15
Postgres reads the SAME number of buffer blocks whether 2 or all 6 columns are selected -- a row-oriented heap page holds whole rows, so narrowing the SELECT list does not reduce I/O

Key takeaway: EXPLAIN (ANALYZE, BUFFERS)'s own shared hit=N count proves the row-store side of the contrast the same way Example 86 proved the column-store side -- Postgres reads exactly 15 buffer blocks whether 2 or all 6 columns are selected, because a heap page holds whole rows, not columns.

Why it matters: this is co-33 and co-34 side by side, each proven with the target engine's OWN introspection tool rather than an outside claim -- a row store's I/O cost is invariant to how many columns a query names, while a columnar store's I/O cost shrinks with it, which is the entire mechanical reason analytical workloads reach for columnar engines.


Example 88: Parquet Roundtrip Projection

ex-88 · exercises co-35

Context: a 4-column Arrow table -- including one deliberately large raw_payload text column -- writes to an on-disk Parquet file; this example reads the file's own footer metadata to show a projected read of 2 columns touches a small, measurable fraction of the file's total compressed bytes, with raw_payload never read at all.

learning/code/ex-88-parquet-roundtrip-projection/example.py

"""Example 88: Parquet Roundtrip Projection."""  # => co-35: this file's own purpose, doubling as its module __doc__
from __future__ import annotations  # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
 
import tempfile  # => generates a throwaway Parquet file path -- this example is fully self-contained
from pathlib import Path  # => co-35: typed filesystem path handling for the generated Parquet file
 
import pyarrow as pa  # => co-35: pyarrow, the official Apache Arrow Python bindings -- Arrow and Parquet are BOTH Apache-2.0
import pyarrow.parquet as pq  # => co-35: pyarrow.parquet -- the Parquet reader/writer built on Arrow's own in-memory format
 
 
def build_sample_table(row_count: int) -> pa.Table:  # pyright: ignore[reportUnknownParameterType]  # => co-35: pyarrow ships zero type stubs, so pa.Table itself resolves as Unknown; narrowly scoped, not a global relaxation
    """Build an in-memory Arrow table: id, category, amount, and one large raw_payload text column."""  # => documents the contract
    return pa.table({  # => co-35: an Arrow Table -- the in-memory format Parquet itself is built on
        "id": pa.array(range(row_count)),  # => a small numeric column
        "category": pa.array(["electronics" if i % 2 == 0 else "books" for i in range(row_count)]),  # => a small, repetitive text column
        "amount": pa.array([i * 1.5 for i in range(row_count)]),  # => a small numeric column
        "raw_payload": pa.array([f"payload-{i}-" + ("z" * 200) for i in range(row_count)]),  # => co-35: a deliberately LARGE text column, never referenced by the projected read below
    })  # => closes the pa.table() call -- 4 columns, one deliberately large
 
 
def main() -> None:  # => entry point -- runs only when this file executes directly, not on import
    row_count = 1000  # => co-35: enough rows that the per-column compressed sizes below are meaningful, not noise
    table = build_sample_table(row_count)  # => co-35: builds the in-memory Arrow table this example writes to Parquet
 
    with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as tmp:  # => co-35: mktemp() is deprecated -- this creates the file (and closes it) immediately instead
        path = Path(tmp.name)  # => a throwaway Parquet file path, unique per run
    pq.write_table(table, path)  # => co-35: writes the Arrow table to an on-disk, columnar Parquet file
 
    parquet_file = pq.ParquetFile(path)  # => co-35: opens the file's own footer metadata WITHOUT reading any column data yet
    row_group = parquet_file.metadata.row_group(0)  # => co-35: this small file has exactly 1 row group
    column_sizes = {  # => co-35: EVERY column's compressed byte size, stored independently in the file's own footer
        row_group.column(i).path_in_schema: row_group.column(i).total_compressed_size for i in range(row_group.num_columns)  # => maps each column's own name to its own compressed byte size
    }  # => closes the column_sizes dict comprehension -- one entry per column in the row group
    total_bytes = sum(column_sizes.values())  # => co-35: the FULL file's total compressed column-chunk bytes
 
    projected_table = pq.read_table(path, columns=["category", "amount"])  # => co-35: reads back ONLY 2 of the file's 4 columns
    assert projected_table.column_names == ["category", "amount"]  # => co-35: the returned table genuinely contains ONLY the 2 requested columns
    assert projected_table.num_rows == row_count  # => confirms every row was still read for those 2 columns
 
    projected_bytes = column_sizes["category"] + column_sizes["amount"]  # => co-35: the compressed size of ONLY the 2 projected columns' own chunks
    print(f"Full file, all 4 columns:      {total_bytes} compressed bytes")  # => Output line -- exact bytes machine-dependent, ratio is the point
    print(f"Projected read, 2 columns:     {projected_bytes} compressed bytes")  # => Output line
    percent_of_total = projected_bytes / total_bytes * 100  # => co-35: what fraction of the whole file the projected read actually touched
    print(f"Projected read touched {percent_of_total:.1f}% of the file's total compressed bytes")  # => Output line -- percentage machine-dependent, always well under 100%
    assert projected_bytes < total_bytes  # => co-35: the projected read touched STRICTLY LESS data than the whole file
    print(f"raw_payload column alone: {column_sizes['raw_payload']} compressed bytes -- NEVER read by the projected query above")  # => Output line
    print("Parquet's column-chunk layout lets a projected read skip entire columns' worth of on-disk bytes, not just filter rows after a full read")  # => Output line
    path.unlink()  # => cleans up the throwaway Parquet file
 
 
if __name__ == "__main__":  # => guards against running main() on `import example`
    main()  # => runs everything above when executed as a script

Run: python3 example.py (representative output, run against pyarrow==25.0.0, entirely in-process -- no Docker container needed)

Output:

Full file, all 4 columns:      26750 compressed bytes
Projected read, 2 columns:     5565 compressed bytes
Projected read touched 20.8% of the file's total compressed bytes
raw_payload column alone: 15827 compressed bytes -- NEVER read by the projected query above
Parquet's column-chunk layout lets a projected read skip entire columns' worth of on-disk bytes, not just filter rows after a full read

Key takeaway: Parquet's own file footer stores each column's compressed size independently, so a projected read of category and amount provably touches only 20.8% of the file's total bytes -- raw_payload's 15,827 bytes, more than half the file, are never opened.

Why it matters: this is co-35 grounded in a file format most teams already produce or consume -- Parquet's column-chunk layout is what lets a downstream query engine (DuckDB, ClickHouse, Spark, or plain pyarrow itself) skip whole columns' worth of on-disk bytes, the file-format-level counterpart to Example 86's in-memory EXPLAIN projection proof. A data lake storing the same dataset as row-oriented CSV instead of Parquet pays this exact cost on every downstream query, regardless of which engine eventually reads it.


Example 89: Arrow Zero-Copy Interop

ex-89 · exercises co-35

Context: a 2,000,000-row Apache Arrow table, built entirely in Python memory, is queried by DuckDB via a Python-variable "replacement scan" -- this example measures Arrow's own allocator before and after the query to show DuckDB reads the SAME in-memory buffers directly, allocating nowhere near a full copy of the table.

learning/code/ex-89-arrow-zero-copy-interop/example.py

"""Example 89: Arrow Zero-Copy Interop."""  # => co-35: this file's own purpose, doubling as its module __doc__
from __future__ import annotations  # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
 
import duckdb  # => co-35: duckdb, the official Python API for the in-process, MIT-licensed columnar OLAP engine
import pyarrow as pa  # => co-35: pyarrow, the official Apache Arrow Python bindings -- the in-memory format both engines share
 
 
def build_large_arrow_table(row_count: int) -> pa.Table:  # pyright: ignore[reportUnknownParameterType]  # => co-35: pyarrow ships zero type stubs, so pa.Table itself resolves as Unknown; narrowly scoped, not a global relaxation
    """Build a 2-column, row_count-row Arrow table entirely in memory."""  # => documents the contract
    return pa.table({  # => co-35: an in-memory Arrow Table -- the SAME columnar format Parquet (Example 88) is built on
        "id": pa.array(range(row_count)),  # => a numeric id column
        "value": pa.array([i * 0.5 for i in range(row_count)]),  # => a numeric value column, the one DuckDB will sum below
    })  # => closes the pa.table() call -- 2 columns, entirely in-memory
 
 
def main() -> None:  # => entry point -- runs only when this file executes directly, not on import
    row_count = 2_000_000  # => co-35: large enough (millions of rows) that a full COPY would move a measurable number of bytes
    arrow_table = build_large_arrow_table(row_count)  # => co-35: builds the table entirely in Python/Arrow memory, no DuckDB involved yet
    table_bytes = arrow_table.nbytes  # => co-35: the table's own reported in-memory size -- what a COPY would have to duplicate
 
    bytes_before = pa.total_allocated_bytes()  # => co-35: Arrow's own allocator tracks EVERY byte it has allocated, process-wide
    duck_conn = duckdb.connect()  # => co-35: an IN-PROCESS DuckDB connection
    result_row = duck_conn.execute("SELECT sum(value) FROM arrow_table").fetchone()  # => co-35: DuckDB queries the Python variable BY NAME -- no to_pandas(), no CSV, no Parquet round trip
    assert result_row is not None  # => a SUM(*) query always returns exactly one row -- confirms it genuinely came back
    bytes_after = pa.total_allocated_bytes()  # => co-35: re-checks Arrow's allocator AFTER the query ran
 
    allocated_during_query = bytes_after - bytes_before  # => co-35: however many NEW bytes Arrow's allocator reports for running the query
    hand_computed_sum = sum(i * 0.5 for i in range(row_count))  # => co-35: an INDEPENDENT, hand-computed expectation
    assert abs(result_row[0] - hand_computed_sum) < 1e-6  # => co-35: DuckDB's sum matches the hand-computed sum, to floating-point precision
 
    print(f"Arrow table size:                 {table_bytes:,} bytes")  # => Output: Arrow table size:                 32,000,000 bytes
    print(f"Bytes newly allocated by the query: {allocated_during_query:,} bytes")  # => Output line -- exact bytes machine-dependent, ratio is the point
    percent_of_table = allocated_during_query / table_bytes * 100  # => co-35: what fraction of the table's own size the query newly allocated
    print(f"That is {percent_of_table:.3f}% of the table's own size -- nowhere near a full 32MB copy")  # => Output line
    assert allocated_during_query < table_bytes * 0.01  # => co-35: a genuine COPY would allocate close to table_bytes -- this stayed under 1% of it
    print(f"sum(value) = {result_row[0]}, matching the hand-computed expectation exactly")  # => Output line -- exact value machine-independent, deterministic
    print("DuckDB queried the SAME Arrow buffers directly -- zero-copy interchange, not a serialize-then-copy round trip")  # => Output line
    duck_conn.close()  # => always release what you open
 
 
if __name__ == "__main__":  # => guards against running main() on `import example`
    main()  # => runs everything above when executed as a script

Run: python3 example.py (representative output, run against duckdb==1.5.5 and pyarrow==25.0.0, entirely in-process -- no Docker container needed)

Output:

Arrow table size:                 32,000,000 bytes
Bytes newly allocated by the query: 888 bytes
That is 0.003% of the table's own size -- nowhere near a full 32MB copy
sum(value) = 999999500000.0, matching the hand-computed expectation exactly
DuckDB queried the SAME Arrow buffers directly -- zero-copy interchange, not a serialize-then-copy round trip

Key takeaway: Arrow's own allocator reports only 888 new bytes for a query that summed 2,000,000 rows -- 0.003% of the table's 32MB size -- proving DuckDB read the SAME buffers Python built, not a copy of them.

Why it matters: this is co-35's zero-copy promise made measurable rather than taken on faith -- because Arrow defines a single, shared, language-agnostic in-memory columnar layout, DuckDB (and pandas, Polars, and other Arrow-aware tools) can query data another process or library built without ever paying a serialize-then-copy round trip, the same in-memory format Parquet (Example 88) persists to disk.


Example 90: ClickHouse MergeTree Aggregate

ex-90 · exercises co-32, co-33

Context: a ClickHouse MergeTree table, partitioned by month, holds 4 rows spanning 3 distinct partitions -- this example runs a GROUP BY aggregate, then uses EXPLAIN indexes=1's own Min-Max index section to show a month-scoped range query prunes exactly 2 of the table's 3 total parts before reading a single row within them.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73 -- color-blind friendly, WCAG AA
flowchart LR
    Q["WHERE order_date<br/>in February"]:::orange
    Q -.->|pruned| J["Jan partition"]:::blue
    Q --> F["Feb partition<br/>Parts: 1/3 read"]:::teal
    Q -.->|pruned| M["Mar partition"]:::blue
 
    classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px

learning/code/ex-90-clickhouse-mergetree-aggregate/example.sh

#!/usr/bin/env bash
# Example 90: ClickHouse MergeTree Aggregate.
# Creates a MergeTree table (co-32, Apache-2.0) partitioned by month, inserts rows
# across 3 partitions, runs a GROUP BY aggregate, then uses EXPLAIN indexes=1 to
# verify a month-scoped range query prunes the 2 non-matching partitions (co-33).
set -euo pipefail  # => stop on the first failing command
 
# NOTE: this script runs clickhouse-client INSIDE the running Docker container via
# `docker exec`, rather than a host-installed binary -- the host macOS cask build of
# clickhouse is Gatekeeper-blocked non-interactively on this machine, so `docker exec`
# is the reliable, reproducible invocation of the SAME official clickhouse-client tool.
# The container is discovered by image name rather than a fixed --name, so this runs
# against any local `clickhouse/clickhouse-server:latest` container regardless of what
# the reader named it when starting it (`docker run -d clickhouse/clickhouse-server:latest`,
# per this topic's overview.md). No --password flag: the default image's `default` user
# has no password unless the reader set CLICKHOUSE_PASSWORD themselves at container start.
CH_CONTAINER="$(docker ps --filter ancestor=clickhouse/clickhouse-server:latest --format '{{.Names}}' | head -n1)"  # => co-32: discover the running container by image, not a hardcoded name
CH="docker exec ${CH_CONTAINER} clickhouse-client --user default"  # => co-32: the official ClickHouse CLI, run inside the discovered container
 
$CH --multiquery --query "
DROP TABLE IF EXISTS sales;
CREATE TABLE sales (order_date Date, category String, amount Float64)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(order_date)
ORDER BY (category, order_date);
"  # => co-32: MergeTree, ClickHouse's own column-oriented storage engine, partitioned by month
# => Output: (no output -- DDL statements print nothing on success)
 
$CH --query "
INSERT INTO sales VALUES
  ('2026-01-15','electronics',100.0),
  ('2026-01-20','books',50.0),
  ('2026-02-10','electronics',200.0),
  ('2026-03-05','books',75.0);
"  # => co-32: 4 rows spanning 3 DISTINCT monthly partitions (Jan, Feb, Mar)
# => Output: (no output -- a successful INSERT prints nothing)
 
$CH --query "SELECT category, sum(amount) FROM sales GROUP BY category ORDER BY category"  # => co-32: the partitioned GROUP BY aggregation itself
# => Output (clickhouse-client's real TSV output separates columns with a tab, ClickHouse's own
# => convention; shown here with a single space instead, for readability in this comment):
# => books 125
# => electronics 300
 
$CH --query "EXPLAIN indexes=1 SELECT sum(amount) FROM sales WHERE order_date >= '2026-02-01' AND order_date < '2026-03-01'" | grep -A 5 "Min-Max"  # => co-33: EXPLAIN's own Min-Max index section reports EXACTLY which of the 3 total parts survived pruning
# => Output:
# =>         Min-Max
# =>           Keys:
# =>             order_date
# =>           Condition: and((order_date in (-Inf, 20512]), (order_date in [20485, +Inf)))
# =>           Parts: 1/3
# =>           Granules: 1/3
# => co-33: "Parts: 1/3" means ONLY 1 of the table's 3 total parts (one per monthly partition)
# => survived the index check -- the January and March partitions were pruned entirely, before any
# => row within them was read, because the query's own date range never touches them

Run: bash example.sh (representative output, run against a local clickhouse/clickhouse-server:latest Docker container, clickhouse-client invoked via docker exec)

Output (the real terminal run separates category and sum(amount) with a tab, ClickHouse's own TSV convention; shown below with a single space instead of ClickHouse's real tab separator, for readability in this page's prose):

books 125
electronics 300
        Min-Max
          Keys:
            order_date
          Condition: and((order_date in (-Inf, 20512]), (order_date in [20485, +Inf)))
          Parts: 1/3
          Granules: 1/3

Key takeaway: EXPLAIN indexes=1's Min-Max section reports Parts: 1/3 for a query that names only February's date range -- ClickHouse pruned January's and March's partitions entirely, before reading a single row inside them, based purely on each part's own min/max date metadata.

Why it matters: this is co-32 and co-33 in a genuinely distributed-analytics-shaped engine -- a MergeTree table's partition-level Min-Max index is what lets a range-scoped query skip whole parts of a large table outright, the same pruning principle DuckDB's column projection (Examples 86-87) applies at the column level rather than the partition level.


Example 91: Wide-Column vs. Columnar, Same Query

ex-91 · exercises co-36, co-22

Context: the identical 5-row event dataset, partitioned by user_id, lands in both a Cassandra wide-column table and a DuckDB columnar table -- this example structurally proves co-36's own distinction: Cassandra's query planner ALLOWS a partition-scoped point read but REJECTS a cross-partition aggregate without an explicit ALLOW FILTERING opt-in, while DuckDB allows both equally, with the tradeoff visible only in what each engine's own EXPLAIN plan reports.

learning/code/ex-91-wide-column-vs-columnar-same-query/example.py

"""Example 91: Wide-Column vs. Columnar, Same Query."""  # => co-36,co-22: this file's own purpose, doubling as its module __doc__
from __future__ import annotations  # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
 
import statistics  # => co-36: median-of-N timing, the same discipline earlier timing-sensitive examples used
import time  # => co-36: perf_counter for the supplementary at-scale timing section
 
import duckdb  # => co-36: duckdb, the official Python API for the in-process, MIT-licensed columnar OLAP engine
from cassandra import InvalidRequest  # => co-22: Cassandra's own driver exception for a query its planner refuses to run
from cassandra.cluster import Cluster, Session  # => co-36: the official Cassandra Python driver -- the wide-column side of this contrast
from cassandra.concurrent import execute_concurrent_with_args  # => co-36: batches the at-scale seed instead of one round trip per row
 
SAMPLE_ROWS = [  # => co-36: the SAME 5 rows loaded into BOTH engines -- one dataset, two storage layouts
    ("user-1", 1, "click", 10.0),  # => user-1's first event -- lands in user-1's own partition
    ("user-1", 2, "view", 5.0),  # => user-1's second event -- SAME partition, clustered by event_id
    ("user-2", 1, "click", 20.0),  # => user-2's first event -- a DIFFERENT partition
    ("user-2", 2, "purchase", 100.0),  # => user-2's second event -- SAME partition as the row above
    ("user-3", 1, "view", 3.0),  # => user-3's only event -- a THIRD partition
]  # => 5 rows total, spread across 3 distinct partitions -- the shape both point-read and aggregate demos below rely on
 
 
def seed_cassandra(session: Session, rows: list[tuple[str, int, str, float]]) -> None:  # => co-22: the wide-column side, partitioned by user_id
    """Create events_91, partitioned by user_id and clustered by event_id, then load rows."""  # => documents the contract
    session.execute("DROP TABLE IF EXISTS events_91")  # => resets state -- this example is fully self-contained
    session.execute(  # => co-22: PRIMARY KEY ((user_id), event_id) -- user_id is the partition key, event_id clusters WITHIN it
        "CREATE TABLE events_91 (user_id text, event_id int, event_type text, amount double, PRIMARY KEY ((user_id), event_id))"  # => the DDL string itself
    )  # => co-22: closes the CREATE TABLE call -- no other column can route a query the way user_id does
    insert = session.prepare("INSERT INTO events_91 (user_id, event_id, event_type, amount) VALUES (?, ?, ?, ?)")  # => a prepared statement, reused per row
    for row in rows:  # => co-22: each row lands in the partition NAMED by its own user_id -- Cassandra ROUTES it there directly
        session.execute(insert, row)  # => a normal single-row insert -- fine at this small scale
 
 
def seed_duckdb(con: duckdb.DuckDBPyConnection, rows: list[tuple[str, int, str, float]]) -> None:  # => co-33: the columnar side, no partition key at all
    """Create events_91_duck, a plain columnar table with no partitioning or index, then load rows."""  # => documents the contract
    con.execute("CREATE TABLE events_91_duck (user_id TEXT, event_id INT, event_type TEXT, amount DOUBLE)")  # => co-33: a flat columnar table -- DuckDB has no partition-key concept
    con.executemany("INSERT INTO events_91_duck VALUES (?, ?, ?, ?)", rows)  # => co-33: the IDENTICAL rows, stored column-by-column instead of partition-by-partition
 
 
def demonstrate_partition_point_read(session: Session, con: duckdb.DuckDBPyConnection) -> None:  # => co-22,co-33: the SAME point-read query against both engines
    """Run 'all events for user-1' against both engines and show how each one executes it."""  # => documents the contract
    cass_rows = list(session.execute("SELECT event_id, event_type, amount FROM events_91 WHERE user_id = %s", ("user-1",)))  # => co-22: partition-key-scoped -- ALWAYS allowed, no restriction
    assert len(cass_rows) == 2  # => co-22: exactly user-1's own 2 rows, nothing else read
    print(f"Cassandra point read (user-1):  {[(r.event_id, r.event_type, r.amount) for r in cass_rows]}")  # => Output: Cassandra point read (user-1):  [(1, 'click', 10.0), (2, 'view', 5.0)]
 
    duck_rows = con.execute("SELECT event_id, event_type, amount FROM events_91_duck WHERE user_id = 'user-1'").fetchall()  # => co-33: the IDENTICAL logical query
    assert duck_rows == [(1, "click", 10.0), (2, "view", 5.0)]  # => co-33: the SAME 2 rows -- both engines agree on the DATA
    print(f"DuckDB point read (user-1):     {duck_rows}")  # => Output: DuckDB point read (user-1):     [(1, 'click', 10.0), (2, 'view', 5.0)]
 
    plan_text = con.execute("EXPLAIN SELECT event_id, event_type, amount FROM events_91_duck WHERE user_id = 'user-1'").fetchall()[0][1]  # => co-33: EXPLAIN's own plan for the point read
    assert "SEQ_SCAN" in plan_text  # => co-33: DuckDB has NO index on user_id -- it reads EVERY row and filters, even for a single-user lookup
    print("DuckDB's own EXPLAIN plan uses SEQ_SCAN for the point read -- no partition or index lets it skip straight to user-1's rows")  # => Output line
    print("Cassandra ROUTES straight to user-1's own partition -- it never even considers user-2 or user-3's rows")  # => Output line
 
 
def demonstrate_cross_partition_aggregate(session: Session, con: duckdb.DuckDBPyConnection) -> None:  # => co-22,co-33,co-36: the SAME analytical query against both engines
    """Run 'total amount by event_type across ALL users' -- a query that does NOT name the partition key."""  # => documents the contract
    try:  # => co-22: event_type is NOT the partition key -- Cassandra's own query planner refuses this shape by default
        session.execute("SELECT event_type, amount FROM events_91 WHERE event_type = %s", ("click",))  # => co-22: a filter on a non-partition-key column
        raise AssertionError("expected InvalidRequest")  # => this line should never run -- Cassandra should reject the query above
    except InvalidRequest as exc:  # => co-22: Cassandra's own driver exception for a query it refuses to run without an explicit opt-in
        print(f"Cassandra rejects the aggregate's filter without ALLOW FILTERING: {exc}")  # => Output line -- the exact server-side rejection message
 
    filtered_rows = list(session.execute("SELECT event_type, amount FROM events_91 WHERE event_type = %s ALLOW FILTERING", ("click",)))  # => co-22: the SAME query, now with Cassandra's own explicit "yes, scan everything" escape hatch
    assert len(filtered_rows) == 2  # => co-22: both click rows, found only by reading EVERY partition -- ALLOW FILTERING is Cassandra's OWN admission of a full-cluster scan
    print(f"Cassandra WITH ALLOW FILTERING (event_type='click'): {[(r.event_type, r.amount) for r in filtered_rows]}")  # => Output: Cassandra WITH ALLOW FILTERING (event_type='click'): [('click', 10.0), ('click', 20.0)]
 
    duck_agg = con.execute("SELECT event_type, sum(amount) FROM events_91_duck GROUP BY event_type ORDER BY event_type").fetchall()  # => co-33: the IDENTICAL analytical shape -- GROUP BY a non-partition column
    assert duck_agg == [("click", 30.0), ("purchase", 100.0), ("view", 8.0)]  # => co-33: DuckDB's own aggregate, matching a hand-checkable sum of the 5 sample rows
    print(f"DuckDB GROUP BY event_type (no restriction needed): {duck_agg}")  # => Output: DuckDB GROUP BY event_type (no restriction needed): [('click', 30.0), ('purchase', 100.0), ('view', 8.0)]
 
    plan_text = con.execute("EXPLAIN SELECT event_type, sum(amount) FROM events_91_duck GROUP BY event_type").fetchall()[0][1]  # => co-33: reuses the Example 86 EXPLAIN-projection technique
    assert "Projections:" in plan_text and "event_type" in plan_text and "amount" in plan_text and "user_id" not in plan_text  # => co-33: the scan projects ONLY the 2 columns the aggregate needs -- user_id is never touched
    print("DuckDB needs NO escape hatch and NO restriction -- its own EXPLAIN plan shows it simply projects event_type and amount and aggregates")  # => Output line
    print("co-36: Cassandra's wide-column layout is BUILT for partition-scoped reads; a cross-partition aggregate needs its own explicit opt-in")  # => Output line
    print("co-36: DuckDB's columnar layout is BUILT for exactly this shape -- a GROUP BY over a subset of columns, no partition concept at all")  # => Output line
 
 
def demonstrate_at_scale_timing(session: Session, con: duckdb.DuckDBPyConnection) -> None:  # => co-36: a SUPPLEMENTARY, honestly-caveated timing data point -- not the primary proof above
    """Seed a larger dataset and time the SAME partition point read against both engines, median of several runs."""  # => documents the contract
    users, events_per_user = 2000, 50  # => co-36: enough rows that a full DuckDB scan touches real work, not a handful of in-cache rows
    scale_rows = [(f"scaleuser-{u}", e, "click" if e % 2 == 0 else "view", float(e)) for u in range(users) for e in range(events_per_user)]  # => co-36: 100,000 rows, same 4-column shape as SAMPLE_ROWS
 
    session.execute("DROP TABLE IF EXISTS events_91_scale")  # => resets state for this section's own table
    session.execute("CREATE TABLE events_91_scale (user_id text, event_id int, event_type text, amount double, PRIMARY KEY ((user_id), event_id))")  # => co-22: the SAME partitioned shape, at scale
    insert = session.prepare("INSERT INTO events_91_scale (user_id, event_id, event_type, amount) VALUES (?, ?, ?, ?)")  # => a prepared statement, reused across ALL 100,000 rows
    execute_concurrent_with_args(session, insert, scale_rows, concurrency=100)  # => co-36: concurrent batched inserts -- one row per network round trip would be impractically slow at this size
 
    con.execute("CREATE TABLE events_91_scale_duck (user_id TEXT, event_id INT, event_type TEXT, amount DOUBLE)")  # => co-33: the IDENTICAL 100,000 rows, columnar
    con.executemany("INSERT INTO events_91_scale_duck VALUES (?, ?, ?, ?)", scale_rows)  # => co-33: bulk-loads all 100,000 rows in one call
 
    def time_cassandra_read() -> float:  # => co-36: one timed partition-scoped point read
        start = time.perf_counter()  # => marks the start of JUST the query, not connection setup
        list(session.execute("SELECT event_type, amount FROM events_91_scale WHERE user_id = %s", ("scaleuser-1000",)))  # => co-22: routed straight to ONE partition among 2000
        return time.perf_counter() - start  # => elapsed seconds for this single query
 
    def time_duckdb_read() -> float:  # => co-36: the IDENTICAL logical query, timed the same way
        start = time.perf_counter()  # => marks the start of JUST the query
        con.execute("SELECT event_type, amount FROM events_91_scale_duck WHERE user_id = ?", ["scaleuser-1000"]).fetchall()  # => co-33: a full SEQ_SCAN over all 100,000 rows, filtered in-flight
        return time.perf_counter() - start  # => elapsed seconds for this single query
 
    time_cassandra_read()  # => a warmup call each -- excludes one-time connection/JIT overhead from the timed samples below
    time_duckdb_read()  # => a warmup call each
    cassandra_median_ms = statistics.median(time_cassandra_read() for _ in range(9)) * 1000  # => co-36: median of 9 runs, damping single-sample noise
    duckdb_median_ms = statistics.median(time_duckdb_read() for _ in range(9)) * 1000  # => co-36: median of 9 runs, the SAME discipline
 
    print(f"At 100,000 rows / 2000 partitions -- Cassandra partition read: {cassandra_median_ms:.3f} ms (median of 9)")  # => Output line -- exact ms machine-dependent
    print(f"At 100,000 rows / 2000 partitions -- DuckDB full-table scan:   {duckdb_median_ms:.3f} ms (median of 9)")  # => Output line -- exact ms machine-dependent
    print("HONEST CAVEAT: on this single local machine, DuckDB's in-process vectorized scan of 100,000 in-memory rows can")  # => Output line -- see co-36 discussion below for why this does not undercut the architectural claim
    print("still beat Cassandra's own network round trip -- Cassandra's win is architectural: its point-read cost stays FLAT")  # => Output line
    print("as TOTAL cluster data grows (it only ever touches one partition), while DuckDB's SEQ_SCAN cost grows with EVERY")  # => Output line
    print("row in the table -- at distributed, multi-node, larger-than-one-machine's-memory scale, that flat-vs-growing curve is what wins")  # => Output line
 
 
def main() -> None:  # => entry point -- runs only when this file executes directly, not on import
    cluster = Cluster(["127.0.0.1"], port=9042)  # => co-22: connects to the local Cassandra Docker container
    session = cluster.connect()  # => a live CQL session
    session.execute("CREATE KEYSPACE IF NOT EXISTS nosqldb WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}")  # => idempotent -- reuses the keyspace earlier Cassandra examples created
    session.set_keyspace("nosqldb")  # => all statements below target this keyspace
 
    con = duckdb.connect()  # => co-33: an IN-PROCESS DuckDB connection, no server to manage
 
    seed_cassandra(session, SAMPLE_ROWS)  # => co-22: loads the 5-row sample into Cassandra's wide-column table
    seed_duckdb(con, SAMPLE_ROWS)  # => co-33: loads the IDENTICAL 5 rows into DuckDB's columnar table
 
    demonstrate_partition_point_read(session, con)  # => co-22,co-33: proves the point-read distinction structurally, via EXPLAIN and partition routing
    demonstrate_cross_partition_aggregate(session, con)  # => co-22,co-33,co-36: proves the analytical-aggregate distinction structurally, via ALLOW FILTERING and EXPLAIN
    demonstrate_at_scale_timing(session, con)  # => co-36: a supplementary, honestly-caveated real timing data point at 100,000 rows
 
    con.close()  # => always release what you open
    cluster.shutdown()  # => always release what you open
 
 
if __name__ == "__main__":  # => guards against running main() on `import example`
    main()  # => runs everything above when executed as a script

Run: python3 example.py (representative output, run against cassandra-driver==3.30.1, duckdb==1.5.5, and the same local Cassandra 5.0 Docker container prior examples used)

Output:

Cassandra point read (user-1):  [(1, 'click', 10.0), (2, 'view', 5.0)]
DuckDB point read (user-1):     [(1, 'click', 10.0), (2, 'view', 5.0)]
DuckDB's own EXPLAIN plan uses SEQ_SCAN for the point read -- no partition or index lets it skip straight to user-1's rows
Cassandra ROUTES straight to user-1's own partition -- it never even considers user-2 or user-3's rows
Cassandra rejects the aggregate's filter without ALLOW FILTERING: Error from server: code=2200 [Invalid query] message="Cannot execute this query as it might involve data filtering and thus may have unpredictable performance. If you want to execute this query despite the performance unpredictability, use ALLOW FILTERING"
Cassandra WITH ALLOW FILTERING (event_type='click'): [('click', 10.0), ('click', 20.0)]
DuckDB GROUP BY event_type (no restriction needed): [('click', 30.0), ('purchase', 100.0), ('view', 8.0)]
DuckDB needs NO escape hatch and NO restriction -- its own EXPLAIN plan shows it simply projects event_type and amount and aggregates
co-36: Cassandra's wide-column layout is BUILT for partition-scoped reads; a cross-partition aggregate needs its own explicit opt-in
co-36: DuckDB's columnar layout is BUILT for exactly this shape -- a GROUP BY over a subset of columns, no partition concept at all
At 100,000 rows / 2000 partitions -- Cassandra partition read: 3.008 ms (median of 9)
At 100,000 rows / 2000 partitions -- DuckDB full-table scan:   0.375 ms (median of 9)
HONEST CAVEAT: on this single local machine, DuckDB's in-process vectorized scan of 100,000 in-memory rows can
still beat Cassandra's own network round trip -- Cassandra's win is architectural: its point-read cost stays FLAT
as TOTAL cluster data grows (it only ever touches one partition), while DuckDB's SEQ_SCAN cost grows with EVERY
row in the table -- at distributed, multi-node, larger-than-one-machine's-memory scale, that flat-vs-growing curve is what wins

Key takeaway: Cassandra's query planner structurally ALLOWS the partition-scoped point read and structurally REJECTS the cross-partition aggregate without an explicit ALLOW FILTERING opt-in, while DuckDB allows both equally -- the restriction itself, not a timing race, is co-36's real, reproducible evidence.

Why it matters: this closes the course's OLAP arc by directly answering "wide-column and columnar are BOTH tables, so why not use either for either job" -- Cassandra's own query planner enforces the answer for you: it happily serves a partition-key lookup but demands an explicit acknowledgment before running a cross-partition scan, while a columnar engine like DuckDB (or ClickHouse, Example 90) was built for exactly that scan and needs no such guardrail, closing the loop opened by Example 85's TimescaleDB-vs-Cassandra contrast one architectural layer up.


← Previous: Time-Series Examples · Next: Capstone

Last updated July 26, 2026

Command Palette

Search for a command to run...