Lesson 09 DuckDB Locally
Theme:

Homework 09 — Solutions

Lesson 09 · Data Engineering with Python · Worked solutions

Solution to Task 1 — weekly_repo_growth view

Add the following view definition inside open_pipeline_db() in query.py, immediately after the events view:

CREATE OR REPLACE VIEW weekly_repo_growth AS
WITH push_per_week AS (
  SELECT repo.name AS repo,
         date_trunc('week', date::date) AS iso_week,
         count(*) AS pushes
  FROM events
  WHERE type = 'PushEvent'
  GROUP BY repo, iso_week
)
SELECT repo,
       iso_week,
       pushes,
       pushes - lag(pushes) OVER (PARTITION BY repo ORDER BY iso_week) AS delta
FROM push_per_week;

Common mistake — missing PARTITION BY

Using lag(pushes) OVER (ORDER BY iso_week) without PARTITION BY repo causes cross-repo bleed: the delta for repository A’s second week gets compared against the last row from repository Z instead of against repository A’s own first week. Always partition window functions by the entity dimension.

Solution to Task 2 — top_repos.py with --out

The relevant diff to top_repos.py (the changed sections only):

def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--since", required=True, help="YYYY-MM-DD inclusive")
    parser.add_argument("--until", required=True, help="YYYY-MM-DD inclusive")
    parser.add_argument("--n", type=int, default=10)
    parser.add_argument("--out", default=None, help="Write result to this Parquet path")
    args = parser.parse_args()

    with open_pipeline_db() as con:
        if args.out:
            con.execute(
                f"""
                COPY (
                  SELECT repo.name AS repo, count(*) AS n
                  FROM events
                  WHERE date BETWEEN ? AND ?
                  GROUP BY repo
                  ORDER BY n DESC
                  LIMIT ?
                ) TO '{args.out}' (FORMAT PARQUET);
                """,
                [args.since, args.until, args.n],
            )
        else:
            rows = con.execute(
                """
                SELECT repo.name AS repo, count(*) AS n
                FROM events
                WHERE date BETWEEN ? AND ?
                GROUP BY repo
                ORDER BY n DESC
                LIMIT ?
                """,
                [args.since, args.until, args.n],
            ).fetchall()
            for repo, count in rows:
                print(f"{repo}\t{count}")

Common mistake — f-string interpolation for date arguments

It is tempting to write WHERE date BETWEEN '{args.since}' AND '{args.until}' by f-string interpolation. This breaks the parameter binding: the ? placeholders are no longer matched by the positional list and DuckDB raises a binding error. Keep ? for the date values and the row limit; only interpolate the output path (which is caller-controlled, not user-typed input, so the injection risk is lower).

Solution to Task 3 — pandas vs DuckDB benchmark

"""bench_pandas_vs_duckdb.py — Compare count-by-type via pandas vs DuckDB."""
import time
import duckdb
import pandas as pd
import pathlib

PARQUET_ROOT = pathlib.Path("data-engineering/sample-pipeline/data/parquet")


def pandas_path():
    files = sorted(PARQUET_ROOT.glob("date=*/*.parquet"))
    df = pd.concat([pd.read_parquet(p) for p in files])
    return df.groupby("type").size().to_dict()


def duckdb_path():
    con = duckdb.connect(":memory:")
    return dict(
        con.execute(
            f"SELECT type, count(*) FROM read_parquet('{PARQUET_ROOT}/date=*/*.parquet',"
            " hive_partitioning=true) GROUP BY type"
        ).fetchall()
    )


def bench(label, fn, runs=3):
    times = []
    for _ in range(runs):
        t0 = time.perf_counter()
        fn()
        times.append(time.perf_counter() - t0)
    best = min(times)
    print(f"  {label}: best of {runs} = {best:.3f}s")
    return best


print("Benchmarking...")
p = bench("pandas", pandas_path)
d = bench("duckdb", duckdb_path)
print(f"\npandas={p:.3f}s  duckdb={d:.3f}s  ratio={p/d:.1f}x")
# COMMENT: DuckDB wins because pandas reads every Parquet column into RAM
# before grouping; DuckDB applies column projection (reads only 'type') and
# vectorised aggregation, so far less data is decompressed.

Common mistake — using the mean instead of the minimum

Taking the mean of the three runs is noisier: a single garbage-collection pause or OS context-switch inflates the mean significantly. For steady-state performance comparisons always use min(times). The minimum represents the case where background noise was lowest and the CPU was hot — the closest approximation to the intrinsic cost of the operation.