Homework 10 — Solutions
Lesson 10 · Data Engineering with Python
Solution to Task 1 — Force a write conflict and observe the retry
The key is to call table.append(…) in two processes within a short window so
that the catalog’s version pointer is still the same when both processes read it. Here is a
minimal sketch for the two scripts:
# proc1.py (run from data-engineering/sample-pipeline/)
import pyarrow as pa
from datetime import date
from iceberg.tables import create_or_load_events
t = create_or_load_events()
batch = pa.table({
"event_id": pa.array([1], type=pa.int64()),
"type": pa.array(["PushEvent"]),
"actor": pa.array(["alice"]),
"repo": pa.array(["alice/repo-a"]),
"created_at": pa.array([1748300000000000], type=pa.timestamp("us")),
"event_date": pa.array([date(2026, 5, 26)], type=pa.date32()),
})
t.append(batch)
print("proc1: committed")
# proc2.py (run from data-engineering/sample-pipeline/)
import pyarrow as pa
from datetime import date
from iceberg.tables import create_or_load_events
t = create_or_load_events()
batch = pa.table({
"event_id": pa.array([2], type=pa.int64()),
"type": pa.array(["IssuesEvent"]),
"actor": pa.array(["bob"]),
"repo": pa.array(["bob/repo-b"]),
"created_at": pa.array([1748300001000000], type=pa.timestamp("us")),
"event_date": pa.array([date(2026, 5, 26)], type=pa.date32()),
})
t.append(batch)
print("proc2: committed")
Run both scripts within about one second of each other in two terminals:
python proc1.py & python proc2.py
Expected log: one process prints “committed” immediately; the other may log an internal
CommitFailedException once before pyiceberg’s retry loop rebuilds the manifest
against the new metadata head and commits cleanly. Both processes always print
“committed” — the failure is handled internally unless retries are exhausted.
Common mistake: assuming the retry requires application code.
pyiceberg handles the retry internally up to commit_max_retries attempts
(default: 4). The user-facing experience is silent unless retries exhaust. If you want to
observe the retry in your logs, set PYICEBERG_LOG_LEVEL=DEBUG before running the
script.
Solution to Task 2 — Snapshot-based regression triage
Complete script for the per-type delta between the last two snapshots:
from iceberg.tables import create_or_load_events
from collections import Counter
t = create_or_load_events()
snaps = t.snapshots()
if len(snaps) < 2:
print("Need at least 2 snapshots; run write_from_parquet twice first.")
raise SystemExit(1)
snap_n_minus_1 = snaps[-2]
snap_n = snaps[-1]
def type_counts(snapshot_id: int) -> Counter:
arrow = t.scan(snapshot_id=snapshot_id).to_arrow()
return Counter(arrow["type"].to_pylist())
before = type_counts(snap_n_minus_1.snapshot_id)
after = type_counts(snap_n.snapshot_id)
all_types = sorted(set(before) | set(after))
print(f"Delta from snapshot {snap_n_minus_1.snapshot_id} to {snap_n.snapshot_id}:")
for event_type in all_types:
delta = after.get(event_type, 0) - before.get(event_type, 0)
if delta != 0:
print(f" {event_type}: {delta:+d}")
Common mistake: comparing the current snapshot to itself by using
snaps[0] and snaps[-1] when the table has been expired and only one
snapshot remains. Always use snaps[-2] and snaps[-1] (checking
len(snaps) >= 2 first) rather than assuming position 0 is an old snapshot.
After expire_snapshots(--retention-days 0) in the lab, you may need to run two
more appends before this script has two snapshots to compare.
Solution to Task 3 — Add an event_hour partition column
The critical constraint: schema evolution must come before partition-spec evolution. The new partition field must exist as a schema column before you can reference it in the spec.
from pyiceberg.types import IntegerType
from pyiceberg.transforms import IdentityTransform
from iceberg.tables import create_or_load_events
t = create_or_load_events()
# Step 1: evolve the schema (add the column first)
with t.update_schema() as upd:
upd.add_column("event_hour", IntegerType())
# Step 2: evolve the partition spec (reference the new column)
with t.update_spec() as spec:
spec.add_field("event_hour", IdentityTransform(), "event_hour")
print("Schema and partition spec updated.")
print("New schema fields:", [f.name for f in t.schema().fields])
print("Active partition spec:", t.spec())
Write one small batch with event_hour populated, then query both old and new
partitions in DuckDB:
import pyarrow as pa
from datetime import date
batch = pa.table({
"event_id": pa.array([9001], type=pa.int64()),
"type": pa.array(["PushEvent"]),
"actor": pa.array(["carol"]),
"repo": pa.array(["carol/repo-c"]),
"created_at": pa.array([1748310000000000], type=pa.timestamp("us")),
"event_date": pa.array([date(2026, 5, 27)], type=pa.date32()),
"event_hour": pa.array([14], type=pa.int32()),
})
t.append(batch)
print("New partition written.")
from iceberg.query_duckdb import open_with_iceberg
# Reopen after schema change so DuckDB picks up the new metadata:
with open_with_iceberg() as con:
rows = con.execute(
"SELECT event_date, event_hour, type, count(*) AS n "
"FROM events "
"GROUP BY event_date, event_hour, type "
"ORDER BY event_date DESC, event_hour NULLS LAST "
"LIMIT 10"
).fetchall()
for row in rows:
print(row)
Expected: old rows appear with event_hour = NULL; the new row appears with
event_hour = 14. The DuckDB query spans both partition specs transparently.
Common mistake: calling update_spec() before
update_schema(). The partition spec references the new field by name; if the field
does not exist in the schema yet, pyiceberg raises a validation error.
Always evolve schema first, partition spec second.