Lesson 02 Parquet & Columnar Storage
Theme:

Solutions — Homework 02

Lesson 02 · Data Engineering with Python

Solution to Task 1 — Repartition by type

Swapping the partition key is a one-line change — replace partition_cols=["date"] with partition_cols=["type"]:

pq.write_to_dataset(
    table,
    root_path="data/parquet_by_type",
    partition_cols=["type"],
    compression="snappy",
)

The resulting directory tree:

data/parquet_by_type/
  type=PushEvent/part-0.parquet
  type=CreateEvent/part-0.parquet
  type=PullRequestEvent/part-0.parquet
  type=WatchEvent/part-0.parquet
  ...  (~20 partitions for a typical day)

Illustrative benchmark numbers (your values will differ by machine and hour):

Querydate-partitionedtype-partitioned
COUNT(*) WHERE type='PushEvent' ~50 ms ~0.8 ms ← partition pruning
GROUP BY type (all types) ~50 ms ~60 ms ← must scan every partition

Common mistake: assuming the right partition key is always "the column you filter on most". For a balanced workload that mixes single-type counts with cross-type rollups, date is usually a better default. Why? Because date queries are also well-served by the type-column statistics within each date partition, giving you predicate pushdown for free. Type-partitioning only helps queries that filter to a single type and hurts all others.

Solution to Task 2 — Compression face-off

Complete script:

import pyarrow as pa
import pyarrow.parquet as pq
import gzip, json, pathlib, time

# Build table from one hour (reuse your to_parquet implementation)
rows = []
with gzip.open("data/raw/2024-01-01-15.json.gz", "rt") as f:
    for line in f:
        ev = json.loads(line)
        rows.append({
            "type":       ev["type"],
            "actor":      ev["actor"]["login"],
            "repo":       ev["repo"]["name"],
            "created_at": ev["created_at"],
        })
table = pa.Table.from_pylist(rows)

pathlib.Path("data/cmp").mkdir(parents=True, exist_ok=True)

for codec in ["snappy", "gzip", "zstd", None]:
    name = codec or "none"
    out  = pathlib.Path(f"data/cmp/{name}.parquet")
    pq.write_table(table, out, compression=codec)
    t = time.perf_counter()
    pq.read_table(out)
    elapsed = round(time.perf_counter() - t, 4)
    size_mb = round(out.stat().st_size / 1_048_576, 2)
    print(f"{name:8s}  {size_mb:6.2f} MB   read {elapsed} s")

Expected output (illustrative — verify with your own data):

CompressionSizeRead latency
snappy2.1 MB35 ms
zstd1.4 MB45 ms
gzip1.5 MB80 ms
none6.2 MB30 ms

Recommended choice for read-heavy analytics: snappy. It is the fastest to decompress and its file size is good enough (roughly 3× smaller than uncompressed). If storage cost is the constraint and CPU is cheap, choose zstd — it achieves better compression ratios at higher CPU cost, but modern CPUs make the difference negligible for most batch workloads.

Common mistake: picking gzip "because everyone uses it in CSV pipelines". Gzip carries over from the CSV world where it was the only option. For Parquet, gzip is typically the worst trade-off: slower decompression than snappy with only marginally better compression than zstd.

Solution to Task 3 — DuckDB on raw JSON.gz vs Parquet

Timing script:

import duckdb, time

# Query against raw JSON.gz
t = time.perf_counter()
r1 = duckdb.query(
    "SELECT type, COUNT(*) AS n FROM read_json_auto('data/raw/*.json.gz') GROUP BY type ORDER BY n DESC"
).fetchdf()
t_json = round(time.perf_counter() - t, 3)

# Same query against partitioned Parquet
t = time.perf_counter()
r2 = duckdb.query(
    "SELECT type, COUNT(*) AS n FROM 'data/parquet/date=2024-01-01/*.parquet' GROUP BY type ORDER BY n DESC"
).fetchdf()
t_parquet = round(time.perf_counter() - t, 3)

print(f"JSON.gz:  {t_json} s")
print(f"Parquet:  {t_parquet} s")
print(f"Speedup:  {round(t_json / t_parquet)}x")

Typical results:

JSON.gz:  2.5 s
Parquet:  0.05 s
Speedup:  50x

Why is JSON.gz slow? Two compounding reasons:

  1. Row-oriented layout. To compute type counts, DuckDB must materialise every column for every row from the JSON — actor, repo, created_at, and all nested fields — even though the query only uses type. Column pruning is impossible in a row-oriented format.
  2. JSON parsing overhead. Each line requires tokenisation and string allocation. Parquet column data, by contrast, arrives as a typed binary buffer that can be scanned with SIMD instructions.

Common mistake: attributing all the slowness to "JSON is text". The row- oriented layout is actually the bigger culprit. Even a binary row-oriented format (like uncompressed MessagePack) would still be slower than Parquet for column-aggregation queries, because it can't skip the columns you don't need.

Looking ahead: In Lesson 03 you'll add Great Expectations to catch the day your Parquet writer silently produces a column with the wrong type — for example, when one gharchive hour has a null actor that gets serialised as the string "null" instead of a SQL NULL. That kind of silent schema drift is the #1 cause of downstream failures in real data pipelines.