Python Lesson 66 of 71

Data Engineering with Python: Batch & Streaming Pipelines

Every pandas lesson so far started from a DataFrame that already existed — a CSV on disk, a table in a database, a JSON payload. Data engineering is the discipline that puts it there: moving and shaping data reliably, at scale, so that the analyst opening a notebook, the dashboard refreshing at 6am, and the ML model retraining nightly all get correct, fresh, query-cheap data without knowing or caring how it arrived. It is the plumbing. When it works, nobody notices; when it breaks, every number downstream is quietly wrong.

The gap between a data script and a data pipeline is the whole subject. A script runs once, on your laptop, and produces the right answer today. A pipeline runs unattended every day for two years, survives the source system going down mid-run, gets re-run three times by a nervous on-call engineer, back-fills six months of history when someone finds a bug, and must produce the same correct answer every single time — no duplicated rows, no doubled revenue, no silently dropped records. This lesson is about the handful of properties — idempotency, incrementality, partitioning, validation, orchestration — that turn the former into the latter, and it demonstrates every one of them on executed output rather than assertion.

Everything in the batch half of this lesson is executed on Python 3.12.3 with pandas 3.0.3 and pyarrow 25.0.0, and the numbers, row counts and file listings are real. The Airflow DAG is parse-validated on apache-airflow 2.10.5 (loaded in a real DagBag with zero import errors) but its task bodies were not run against a live scheduler or warehouse. The Kafka producer/consumer code is API-accurate for kafka-python 3.0.8 (every parameter verified by introspection) but was not run against a broker — there is no Kafka cluster here, and I will say so again at the point it matters. Nothing in the streaming section is fabricated runtime output.


Why this matters

You have learned to wrangle a DataFrame: group, merge and clean it, read it from and write it to disk, pull it out of a SQL database. Data engineering is what makes those inputs exist and stay trustworthy. It is the difference between a one-off analysis and a system: the code that runs at 3am when you are asleep, that a colleague re-runs without understanding it, that must not corrupt six months of history because a source file arrived twice.

The single most expensive lesson in the field is that a broken pipeline almost never raises an exception — it hands you a wrong number. A re-run that appends instead of overwrites doubles your revenue. A join to a dimension table with a duplicate key triples a customer’s orders. A CSV read where a Parquet was needed turns a two-second query into a two-minute one and a rupee into a hundred. A missing timezone in a partition key files an event under the wrong day. None of these throw. The analyst notices when the board deck disagrees with Finance, three weeks later, and now you are doing archaeology. Every property this lesson teaches exists to convert one of those silent, downstream, wrong-number failures into a loud, immediate, right-at-the-source one.

The mental model to hold for the whole lesson is a pipeline as a sequence of idempotent, observable steps over partitioned data. Idempotent so a re-run is safe. Observable so you know the row count, the freshness, and where a number came from. Partitioned so a query touches a slice, not the whole history. Get those three right and almost everything else is detail. Get any one wrong and you have a script that happens to be scheduled — which is the most dangerous thing in a data platform, because it looks like a pipeline right up until the day it silently isn’t.

There are two shapes of pipeline, and you need both. Batch processes bounded chunks of data on a cadence — “every night, roll up yesterday’s orders.” Streaming processes an unbounded flow of events as they arrive — “flag a fraudulent transaction within 200 milliseconds.” Most of the world’s data work is batch, it is where you should start, and it is where this lesson spends most of its words; streaming is the specialised tool for genuinely low-latency problems, and the last third of the lesson is an honest map of when you need it and what it costs.

Here is the whole lesson as one picture — two lanes, both ending in the same warehouse:

Two parallel data pipeline lanes drawn left to right across shared stages (source, ingest, process, serve, govern). The top lane is batch: files/API/DB are extracted incrementally with a watermark, transformed with pandas to clean, dedupe and aggregate, and loaded as date-partitioned Parquet, with idempotent overwrite-partition and partition pruning marked as the make-or-break points and an Airflow DAG scheduler orchestrating with dependencies, retries and backfill. The bottom lane is streaming: event producers append to a Kafka topic split into partitions with offsets, a consumer group processes them with windowing, and results land in a real-time sink, with offsets and windowing marked as the streaming make-or-break. A governance zone spans both with a data-quality gate, lineage, and fail-loud alerting.

Read it as two lanes. The top lane is batch — extract only new data, clean it with pandas, land it as partitioned Parquet — where idempotency and partitioning are the make-or-break (badges 4, 5) and a scheduler/DAG orchestrates the whole thing (badge 6). The bottom lane is streaming — producers append to a Kafka topic, a consumer group processes continuously — where offsets and windowing are the make-or-break (badges 2, 3). Both lanes converge on the same warehouse, and both are governed by the same data-quality discipline. Every one of those badges is a section of this lesson.


What data engineering is, and ETL vs ELT

Strip away the tooling and data engineering is three verbs repeated forever: Extract data from where it lives, Transform it into a clean, conformed shape, and Load it to where it can be queried. That acronym — ETL — is the oldest three letters in the field, and for decades it described the literal order: pull data out of source systems, run it through a transformation engine that cleaned and joined and aggregated it, then load the finished, analysis-ready tables into a warehouse. The transform happened before the load, on a separate box, because warehouse compute was scarce and expensive and you did not want to waste it on raw, dirty data.

Then cloud warehouses changed the economics and the order flipped. ELT — Extract, Load, Transform — loads the raw data into the warehouse first, cheaply, and does the transformation there, in SQL, using the warehouse’s own elastic compute. Snowflake, BigQuery, Databricks and Redshift made in-warehouse compute so cheap and so scalable that the old reason to transform-before-load evaporated. Loading raw and transforming in place turned out to have real advantages: you keep the raw data (so you can re-transform when requirements change without re-extracting), the transformation is version-controlled SQL that analysts can read and modify, and you are not maintaining a separate transformation cluster. The tool that won the “T” in ELT is dbt, which is essentially “SQL SELECT statements plus dependency management plus tests plus documentation,” and it is now the default way transformations are expressed in a modern warehouse.

Dimension ETL (transform then load) ELT (load then transform)
Order Extract → Transform → Load Extract → Load → Transform
Where the T runs a separate engine (Spark, an ETL server, pandas) inside the warehouse, in SQL
Raw data kept? often no — only the cleaned output lands yes — raw lands first, transform reads it
Re-transform on new requirements must re-extract from source just re-run SQL on the raw you already have
Best when source is heavy/remote; warehouse compute scarce; complex non-SQL logic cloud warehouse; SQL-expressible logic; analyst-owned transforms
Typical stack pandas / Spark / Informatica → warehouse Fivetran/Airbyte load → dbt transforms in Snowflake/BigQuery
Where Python fits the whole T (this lesson’s batch ETL) the E and L (ingestion), orchestration, and non-SQL transforms

The honest summary: ELT won for the cloud-warehouse world, but ETL never died — it is alive and well wherever the transformation is not naturally SQL (parsing logs, calling an ML model, reshaping nested API payloads, joining across systems the warehouse cannot see), and that is exactly where Python lives. When your transform is a pandas pipeline of clean-dedupe-derive-aggregate, you are doing ETL, and you are doing it in Python because SQL would be miserable for it. The two are not a religious war; a real platform uses both — ELT for the warehouse-native rollups, Python ETL for the awkward ingestion and the non-SQL logic, and one orchestrator scheduling all of it.

Where does the transformed data land? Two related destinations. A data warehouse is a structured, query-optimised store of modelled tables (Snowflake, BigQuery, Redshift) — schema-on-write, SQL-first, built for analytics. A data lake is cheap object storage (S3, GCS, ADLS) holding files — often Parquet — in a schema-on-read layout; you point a query engine at the files. The lakehouse (Delta Lake, Apache Iceberg, Apache Hudi) is the modern convergence: warehouse-like tables (ACID transactions, schema evolution, time travel) built on top of Parquet files in a lake, giving you the cheap storage of a lake with the correctness guarantees of a warehouse. The batch pipeline you build in this lesson writes partitioned Parquet — the raw material of a lakehouse — and the idempotency you will engineer by hand is exactly what Delta and Iceberg give you for free.

Destination Stores Schema ACID / transactions Cost Examples
Data warehouse modelled tables schema-on-write (defined up front) yes higher (managed compute + storage) Snowflake, BigQuery, Redshift
Data lake raw files (Parquet, JSON) schema-on-read (applied at query) no (files, not tables) lowest (object storage) S3, GCS, ADLS + Athena/Spark
Lakehouse Parquet files as tables schema-on-read + evolution tracked yes — on top of files low storage + table guarantees Delta Lake, Iceberg, Hudi

The batch pipeline, built for real

A batch pipeline is the three verbs made concrete. Let us build each one properly, because each has a decision that separates a toy from a system.

Extract: full vs incremental, and the watermark

Extraction pulls data from a source — files on disk or object storage, an HTTP API, a production database, a message queue’s archive. The critical decision is how much you pull each run. A full load re-reads the entire source every time: simple, always correct, and ruinous once the source is large — re-reading a billion-row table nightly to capture yesterday’s ten thousand new rows is pure waste, and it gets slower every day forever. An incremental load pulls only what changed since last time, which is what every real pipeline does past a certain size.

Incremental extraction needs a watermark: a stored high-water mark that says “I have already processed everything up to here.” The next run reads only rows above the watermark, processes them, and advances it. The watermark is usually a monotonically increasing column — an updated_at timestamp, an auto-incrementing ID, or a partition date. The subtle failures live here: a watermark on created_at misses rows that were updated after creation; a watermark compared with > instead of >= (or vice-versa) either skips or double-reads the boundary row; a source clock skewed from yours drops rows near the edge.

Extract strategy How it selects rows Cost per run Risk
Full load read everything, every run grows with total data forever slow and wasteful at scale; but always complete
Incremental (append-only) WHERE created_at > watermark grows with new data only misses updates to old rows; boundary > vs >= bugs
Incremental (upsert) WHERE updated_at > watermark grows with changed data needs a reliable updated_at; late updates before watermark are missed
CDC (change data capture) read the DB’s transaction log near-real-time deltas most complex; needs log access + a CDC tool (Debezium)
Snapshot + diff full extract, diff against last full read but small load expensive extract; used when source has no watermark column

The watermark column you pick decides which changes you can even see:

Watermark column Catches Misses Boundary risk
created_at new inserts updates to existing rows > skips / >= re-reads the edge row
updated_at inserts and updates hard deletes; rows updated with no updated_at bump needs the source to touch it on every write
auto-increment id new inserts updates and deletes gaps from rolled-back transactions
partition date a whole day’s data intra-day late arrivals timezone skew at the day boundary
CDC log offset inserts, updates, and deletes nothing (it is the source of truth) needs log access + a CDC tool

Change Data Capture (CDC) is the incremental method taken to its limit: instead of polling a column, you read the database’s own write-ahead log (the same log the DB uses for replication) and capture every insert/update/delete as it commits. Tools like Debezium stream those changes into Kafka, which is the bridge into the streaming half of this lesson — CDC is how a batch warehouse gets fed by a streaming source.

Transform: clean, dedupe, derive, validate — with pandas

The transform is where the pandas lessons pay off. It is the same verbs you already know — clean and normalise, drop duplicates, join, aggregate, handle missing values — now applied as a repeatable, validated stage rather than an ad-hoc notebook. A production transform does five things in order:

Transform step pandas tool Why it is here
Clean .str.strip(), .str.title(), pd.to_datetime, .astype() normalise whitespace, case, types so keys and categories actually match
Dedupe .drop_duplicates(subset=[key]) a retried API call or replayed file delivers the same record twice
Derive .assign(...), arithmetic, .dt accessors compute revenue, extract the partition date, bucket values
Aggregate .groupby().agg(), .pivot_table() roll events up to the grain the warehouse needs
Validate assertions on schema/nulls/ranges fail the run loudly rather than load bad data silently

The validation step is the one beginners skip and seniors never do. A transform that cleans perfectly today will meet a malformed row tomorrow — a null where a price should be, a category no one has seen, a negative quantity, a duplicate that survived the dedupe. The choice is between catching it at the transform (a loud failure, a paged engineer, no bad data downstream) and letting it flow (a silent wrong number, discovered weeks later). A data-quality gate — a block of assertions that raises if any invariant is violated — is cheap insurance:

problems = []
if df["event_id"].duplicated().any():
    problems.append("duplicate event_id after dedupe")
if not set(df["category"]).issubset(VALID_CATEGORIES):
    problems.append(f"unknown category {set(df['category']) - VALID_CATEGORIES}")
if (df["qty"] <= 0).any():
    problems.append("non-positive qty")
if problems:
    raise ValueError("data-quality check failed: " + "; ".join(problems))

Feed that gate a batch with a typo’d category and it stops the pipeline dead, exactly as you want:

ValueError: data-quality check failed: unknown category {'Dairyy'}

That is a real, executed traceback line. The checks worth writing fall into a small set of categories:

Check type Asserts pandas expression Catches
Schema expected columns + dtypes present set(cols) <= set(df.columns) a renamed/dropped source field
Not-null required columns have no holes df[req].isna().any() missing prices, unmatched keys
Uniqueness a key is unique df["id"].duplicated().any() a failed dedupe, a fan-out join
Range numbers are in bounds (df["qty"] <= 0).any() negative qty, absurd prices
Set membership categoricals are known values set(df["cat"]) <= VALID typos, new unhandled categories
Referential foreign keys exist in the dim ~df["cust_id"].isin(dim) orphan facts
Freshness newest data is recent enough df["ts"].max() >= cutoff a stalled upstream source
Volume row count is in a sane band not lo <= len(df) <= hi a truncated or duplicated extract

For richer validation than hand-rolled asserts, Great Expectations (and the lighter Pandera) let you declare a suite of expectations — “column revenue is non-null, between 0 and 100000, and 99% of the time under 5000” — and get a validation report, data docs, and a pass/fail you can wire into the pipeline. The principle is identical; the library just makes the invariants declarative, documented, and testable. Treat data quality as a first-class stage, not a comment.

Load: overwrite, append, or upsert

The load writes the transformed data to its destination, and the mode you choose is the whole idempotency story (which the next section demonstrates in full). Append adds rows — correct for immutable event logs, catastrophic on a re-run because it duplicates. Overwrite replaces everything — safe but wasteful, and it destroys history. Overwrite-partition replaces only the slice this run produced — the sweet spot for date-partitioned data, and the pattern this lesson builds. Upsert/merge (SQL MERGE, Delta merge) updates existing keys and inserts new ones — the correct mode when rows can change, and what CDC pipelines use.

Load mode Effect Idempotent on re-run? Use when
Append add rows to the target No — duplicates every re-run immutable log where you guarantee one write per record
Full overwrite replace the entire target Yes small tables; a full-refresh dimension
Overwrite-partition replace only this run’s partition(s) Yes date-partitioned facts (this lesson)
Upsert / merge update matched keys, insert new Yes rows mutate; CDC; slowly-changing dimensions

Real pipelines rarely load in one hop from raw to analytics-ready; they land data in layers, a pattern the lakehouse world calls medallion (bronze → silver → gold) and the dbt world calls staging → intermediate → marts. Bronze/raw is the source data landed as-is, immutable, so you can always re-derive everything downstream from it. Silver/cleaned is the conformed, deduplicated, validated version — the output of your transform, the partitioned Parquet this lesson builds. Gold/marts are the business-level aggregates analysts actually query — revenue by region by day, the tables a dashboard hits. Each layer is a separate, idempotent load, and the value of the split is blast radius: a bug in an aggregate is fixed by re-running gold from silver, without re-extracting from the source, and a new requirement becomes a new gold table over the silver you already trust. The event warehouse you build below is a silver table; the daily rollup in the final step is a gold one.

Layer Also called Holds Rebuilt from
Bronze raw / landing / staging source data, as-is, immutable the source (re-extract)
Silver cleaned / conformed / core validated, deduped, typed, partitioned bronze
Gold marts / serving / aggregates business rollups analysts query silver

File formats and partitioning: the biggest cost lever

Before the pipeline can be idempotent, it has to write files, and which format and how partitioned is the single largest lever on query cost and speed in the entire field. This is not a detail; it is often a 10-to-100× difference in money and minutes, and it is executed here on 500,000 real rows.

Row formats vs columnar: CSV/JSON vs Parquet

CSV and JSON are row formats: values are stored one record at a time, as text. They are human-readable, universal, and the right choice for interchange and small data — and wrong for analytics at scale, for two reasons. First, text is bulky and uncompressed: every number is re-parsed from its decimal string on every read. Second, they are row-oriented, so a query that wants one column out of twenty must still read all twenty off disk.

Parquet is a columnar binary format: values are stored one column at a time, typed, compressed, with statistics (min/max/null-count) per chunk. That layout is transformational for analytics. A query for SUM(revenue) reads only the revenue column and skips the rest (column projection). The per-chunk min/max lets the reader skip chunks that cannot match a filter (predicate pushdown). And typed columns of similar values compress far better than text. Measured on the same 500,000-row, 7-column event frame:

Format Size on disk vs CSV Notes
JSON lines (text) 65.0 MB 0.4× (bigger!) most verbose — repeats every key on every row
CSV (text, no compression) 24.0 MB 1.0× (baseline) universal, human-readable, bulky
Parquet, uncompressed 13.6 MB 1.8× smaller columnar layout alone beats text
CSV + gzip 7.5 MB 3.2× compresses well but still row-oriented, opaque
Parquet + snappy (default) 8.2 MB 2.9× fast codec — the sensible default
Parquet + gzip 6.1 MB 3.9× smaller, slower to write
Parquet + zstd 5.3 MB 4.5× best ratio; modern default on many platforms

Parquet with zstd is 4.5× smaller than raw CSV and 12× smaller than JSON lines — and the size is the smaller half of the win. The read speed is the bigger half:

read all cols from CSV        :    228.1 ms
read all cols from Parquet    :     10.7 ms   (21.4x faster than CSV)
read 1 col  from Parquet      :      4.2 ms   (columnar projection: skip 6 of 7 cols)

Reading the whole frame from Parquet is 21× faster than from CSV — no text parsing, no type inference, just typed bytes into typed arrays. Reading a single column is faster still, because columnar storage lets the reader physically skip the other six columns’ bytes. (These millisecond figures are machine- and cache-dependent; the magnitudes — one to two orders of magnitude — are the durable fact.) Compression codecs trade write-time CPU for size:

Codec Ratio (this data) Write speed Splittable Use when
snappy 2.9× fast yes the safe default — balanced speed and size
zstd 4.5× fast-ish (tunable level) yes best all-round; the modern default on many platforms
gzip 3.9× slow yes cold/archival data where read is rare
lz4 ~2.9× fastest yes write-heavy hot paths
none 1.8× fastest yes rarely worth it — you give up size for little gain

snappy is the safe default; zstd gives the best ratio at modest cost and is what most modern platforms now standardise on; gzip is smaller but slow; uncompressed is rarely worth it.

Partitioning: the query-cost lever

Columnar format makes each file cheap to read. Partitioning makes it so you read fewer files at all. To partition a dataset is to split it into subdirectories by the value of a column — almost always a date — so that warehouse/events/ becomes:

warehouse/events/
├── event_date=2026-03-01/part-0.parquet
├── event_date=2026-03-02/part-0.parquet
├── event_date=2026-03-03/part-0.parquet
└── event_date=2026-03-04/part-0.parquet

That directory layout (column=value, called Hive partitioning) is not cosmetic — it is a physical index the query engine reads from the path. A query filtered to one day opens one directory and ignores the rest. That is partition pruning, and it is the biggest single cost lever in analytics because cloud warehouses and lake engines bill by bytes scanned. Partition by the column you filter on and a one-day query scans one day’s bytes; forget to, and it scans all of history for every query. Measured on a 10-day, 500,000-row partitioned dataset:

read ALL 10 partitions        :     20.2 ms   (500,000 rows)
read 1 partition (pruned)     :      3.4 ms   ( 49,967 rows, ~1/10th the data)

The pruned read touches roughly a tenth of the data and returns in roughly a tenth of the time — and on a real warehouse where “all partitions” is years of terabytes, that ratio is the difference between a ₹0.05 query and a ₹50 one.

Partition choice Effect When
By date (event_date=…) one directory per day; prune to a date range almost always — most queries filter by time
By date + low-card key (region, tenant) prune on two dimensions large multi-tenant/multi-region data
Too fine (by hour, by user_id) millions of tiny files — the “small-files problem” never partition by a high-cardinality column
Too coarse (by year) prune helps little; each partition huge only for genuinely small or rarely-filtered data
Unpartitioned every query is a full scan tiny tables, or a staging area before partitioning

The two ways to get partitioning wrong are opposite and both painful. Partition on a high-cardinality column (a user ID, a UUID) and you get the small-files problem: millions of tiny Parquet files, each with per-file overhead, and reads slow to a crawl because opening a file costs more than reading it. Partition too coarsely and pruning buys you nothing. The reliable default is: partition by date, at day granularity, and make sure each partition holds files of a sensible size (hundreds of MB, not kilobytes). Everything else is tuning.


What separates a real pipeline from a script

Here is the heart of the lesson. A script that extracts, transforms and writes Parquet is maybe forty lines. Turning it into a pipeline means giving it a set of properties that only matter the second time it runs, or the day the source misbehaves. Every one is demonstrated on executed output.

Idempotency: the re-run-safe property

Idempotency means running the pipeline twice produces the same result as running it once. It is the single most important property, because in production a pipeline will be re-run: a task retries after a transient failure, an engineer re-triggers yesterday after a fix, a backfill replays three months. If a re-run duplicates data, every one of those events silently corrupts the warehouse.

The classic bug is append on re-run. The naive load writes the batch’s rows into the partition — and pandas/pyarrow, told to partition, writes a new file each call. Run it twice and the partition has two files, and double the rows. Executed, on the same day’s input, run twice:

# THE BUG — naive partitioned write appends a new part-file every run
df.to_parquet(warehouse, partition_cols=["event_date"], engine="pyarrow")
after run #1: total rows = 5
after run #2 (same input): total rows = 10   <-- DOUBLED
part-files in event_date=2026-03-01/ : 2
    ab5a8d63f3834b9f808a2d5832a46d29-0.parquet
    dff70eaf8a534ecfa52b26ec8bfbbeb3-0.parquet

Nothing raised. The second run dropped a second randomly-named part-file into the same partition directory (your hex names will differ — they are UUIDs), and a reader of that partition now sees every row twice. In a real pipeline this is a retried Airflow task, and your daily revenue is now 2×.

The fix is overwrite-partition: before writing a partition, delete what is already there, so the partition ends each run holding exactly this run’s output. pyarrow’s dataset writer does it with one argument, existing_data_behavior="delete_matching", which deletes the partitions this batch touches and rewrites them:

# THE FIX — overwrite exactly the partitions this batch produces
import pyarrow as pa, pyarrow.dataset as ds
table = pa.Table.from_pandas(df, preserve_index=False)
part = ds.partitioning(pa.schema([("event_date", pa.string())]), flavor="hive")
ds.write_dataset(table, warehouse, format="parquet", partitioning=part,
                 existing_data_behavior="delete_matching",   # <- the whole trick
                 basename_template="part-{i}.parquet")
after run #1: total rows = 5
after run #2 (same input): total rows = 5   <-- STABLE
part-files in event_date=2026-03-01/ : 1 ['part-0.parquet']

Now the re-run converges: five rows in, five rows out, one clean part-0.parquet, no matter how many times it runs. That is idempotency, and it is not a nicety — it is the property that makes retries, backfills, and on-call re-triggers safe instead of corrupting. The deterministic basename_template matters too: a fixed filename means re-running truly overwrites rather than accumulating uniquely-named files. (A lakehouse table format — Delta, Iceberg — gives you this as an atomic transaction; here you build it by hand, which is the point.)

Technique How it stays idempotent Best for Watch out
Overwrite-partition delete the partition, then rewrite it date-partitioned facts (this lesson) must partition on the batch’s unit of work
Upsert / merge by key update matched keys, insert the rest mutable rows, CDC, dimensions needs a reliable primary key
Truncate-and-reload replace the whole target each run small tables, full-refresh dims wasteful and destroys history at scale
Insert + dedup on read append freely, dedupe when querying append-only staging pushes cost to every reader
Deterministic keys + INSERT … ON CONFLICT DB rejects/updates duplicates row-at-a-time loads to a RDBMS per-row overhead
Blind append it does not immutable logs with proven exactly-once duplicates on any re-run — the bug above

Incremental processing and backfill

Incremental processing is the extract-side twin of idempotency: process only new data, keyed to a watermark or a partition date. Backfill is its complement: the ability to re-process a specific past window — because you found a bug, added a column, or the source corrected historical data. The two properties depend on each other. A pipeline whose unit of work is “one day’s partition” can naturally do both: to process incrementally, run today’s partition; to backfill, run the past partitions you need. And because each partition write is idempotent (overwrite-partition), backfilling March 3rd overwrites only March 3rd and leaves every other day untouched. Executed — the warehouse starts with three days (14 rows), and an incremental run adds a fourth:

before: 14 rows, 3 partitions
loaded new day: {'source': 'events_2026-03-04.csv', 'raw_rows': 3, 'loaded_rows': 3, 'deduped': 0}
after:  17 rows, 4 partitions
new partitions added: ['event_date=2026-03-04']
existing partitions untouched? True

The new day added exactly one partition and three rows; the three existing partitions were not read, not rewritten, not touched. That is what “incremental” should mean — and note it is only safe because the load is idempotent. Incrementality without idempotency is a duplication bug waiting for its first retry.

Data quality, schema evolution, and observability

Three more properties turn a pipeline from “runs” into “trustworthy.”

Data quality you have met: a validation gate that fails the run rather than loading bad data. The rule is fail loud, fail early, fail at the source. A null that slips through the transform becomes a NaN-poisoned average three dashboards downstream, and the cost of finding it grows every step it travels.

Schema evolution is what happens when the source’s shape changes under you — a new column appears, a type widens, a field is renamed. Handled well it is routine; handled badly it either breaks the load or, worse, silently drops data. Here is the trap, executed: write one day’s partition without a channel column, a later day with it, then read the dataset back the default way:

files on disk:
   event_date=2026-03-01/…-0.parquet -> cols ['user_id', 'revenue']
   event_date=2026-03-02/…-0.parquet -> cols ['user_id', 'revenue', 'channel']

[default read] ds.dataset(...).to_table() columns: ['user_id', 'revenue', 'event_date']

The channel column silently vanished. pyarrow infers the dataset’s schema from the first file it discovers, and that file predates channel, so the column is dropped on read with no error — a schema change that quietly loses data. The fix is to unify schemas explicitly rather than trust first-file inference:

import pyarrow as pa, pyarrow.dataset as ds
schemas = [pa.parquet.read_schema(p) for p in warehouse.rglob("*.parquet")]
unified = pa.unify_schemas(schemas)          # union of all fields across files
tbl = ds.dataset(warehouse, partitioning="hive",
                 schema=unified.append(pa.field("event_date", pa.string()))).to_table()
columns: ['user_id', 'revenue', 'channel', 'event_date']
 user_id  revenue channel event_date
       1    100.0     NaN 2026-03-01
       2    200.0     app 2026-03-02

Now channel survives, null-filled for the day that predated it — which is the correct semantics. This hand-work is precisely what lakehouse formats (Delta, Iceberg) automate: they track schema as metadata and evolve it transactionally, so an added column is a recorded, safe operation rather than a first-file lottery.

Schema change Safe by default? What breaks / happens Handle it by
Add a column risky silently dropped on read (first-file inference) unify_schemas; declare an explicit schema; Delta/Iceberg
Drop a column mostly old files still have it; new reads null-fill unify schemas; keep the column, stop populating it
Rename a column no reads as two columns, each half-null treat as add + drop; map old→new in transform
Widen a type (int→float) usually pyarrow may up-cast; some engines error cast to the target type in the transform
Narrow a type (float→int) no data loss or a load error never auto-narrow; version the table
Reorder columns yes Parquet is name-based, not positional nothing needed

Observability is knowing your pipeline is healthy without waiting for someone downstream to complain. The three signals that matter most:

Signal What it answers How you get it
Row counts did the expected volume arrive? log len(df) in and out; alert on a sudden drop/spike
Freshness how old is the newest data? max(event_date) vs now; alert if a partition is missing
Lineage where did this number come from? track which sources/runs produced which tables (OpenLineage, dbt docs)
Null/dupe rates is quality drifting? log the DQ metrics each run; trend them
Run duration is it slowing down (un-incremental)? time each run; a growing trend means a full-scan crept in

A pipeline that logs its row counts, checks its freshness, and asserts its quality is one you can trust while you sleep. One that does none of those is a script you hope is working — and hope is not an operational strategy.


Orchestration with Airflow

You have a working, idempotent, incremental batch pipeline. Now you need it to run every day, in the right order, retrying transient failures, backfilling on demand, alerting on real ones, and showing you what happened. You could wire that with cron — and the moment you have more than one job with a dependency between them, you have invented “cron-soup”: a tangle of crontab lines where job B assumes job A finished, with no enforcement, no retry, no visibility, and a 2am failure that cascades silently because B ran on A’s stale output. Orchestration is the discipline that replaces cron-soup with a declared, monitored graph of work.

Apache Airflow is the most widely used orchestrator, and its core abstraction is the DAG — a Directed Acyclic Graph of tasks. Directed: edges point from a task to the task that depends on it. Acyclic: no cycles, so there is always a valid execution order. Graph: tasks are nodes, dependencies are edges. You declare the tasks and the edges; Airflow works out the order, runs independent tasks in parallel, retries failures, and gives you a UI and logs.

Airflow concept What it is
DAG the whole workflow — a graph of tasks with dependencies, plus a schedule
Task one unit of work (a node in the graph)
Operator the kind of task: PythonOperator, BashOperator, SQLExecuteQueryOperator, sensors, …
Dependency (a >> b) an edge: b runs only after a succeeds
Schedule the cadence — @daily, a cron string, or a dataset trigger
Logical date (ds) the date the run represents (the data interval), not the wall-clock run time
XCom “cross-communication” — small values passed between tasks (a path, an ID)
Retry automatic re-run of a failed task, with a delay
Backfill running past intervals to fill history (catchup=True)

The operator is the kind of task, and a handful cover most pipelines:

Operator Runs Typical use
PythonOperator / @task a Python callable the E, T, or L when it is Python (this lesson)
BashOperator a shell command invoke a CLI, a dbt run, a script
SQLExecuteQueryOperator SQL against a connection in-warehouse transforms, MERGE, rollups
Sensors (FileSensor, ExternalTaskSensor) wait for a condition block until a file lands or an upstream DAG finishes
Transfer operators (S3ToSnowflake, …) move data between systems the L of ELT — load raw into the warehouse
KubernetesPodOperator a container isolated/heavy jobs with their own deps

The single most misunderstood concept is the logical date (historically execution_date, now logical_date, templated as ds). It is not the time the task runs — it is the date the run represents. The run for event_date=2026-03-01 fires after March 1st ends (when the day’s data is complete) but its logical date is March 1st. Your tasks must key off the logical date, not datetime.now(), or backfills break: a task that reads “today’s” file with date.today() will, when backfilling March, wrongly read today’s data for every historical run. Bind the partition to ds and each run — scheduled or back-filled — processes exactly its own day. This is the orchestration-level restatement of idempotency: the logical date makes every run reproducible.

Here is the batch pipeline as an Airflow DAG — extract → transform → load, with retries, catchup=True for backfill, and each task keyed to the logical date:

from __future__ import annotations
import pendulum
from datetime import timedelta
from airflow import DAG
from airflow.operators.python import PythonOperator

default_args = {
    "owner": "data-eng",
    "retries": 2,                          # transient failures retry, don't page a human
    "retry_delay": timedelta(minutes=5),
    "depends_on_past": False,
}

def extract(ds: str, **_):
    """Read the raw file for THIS logical day. `ds` is the run's date (YYYY-MM-DD)."""
    src = f"/data/raw/events_{ds}.csv"
    print(f"[extract] logical date={ds} reading {src}")
    return src                              # return value is pushed to XCom

def transform(ds: str, ti, **_):
    src = ti.xcom_pull(task_ids="extract")  # pull the upstream XCom
    staged = f"/data/staging/events_{ds}.parquet"
    return staged

def load(ds: str, ti, **_):
    """Overwrite-partition load: delete THIS day's partition, then write it.
    Idempotent by construction, so a retry never double-writes."""
    staged = ti.xcom_pull(task_ids="transform")
    partition = f"/data/warehouse/events/event_date={ds}"
    print(f"[load] overwriting {partition} from {staged}")

with DAG(
    dag_id="events_daily_etl",
    default_args=default_args,
    description="Daily grocery-events batch ETL to partitioned Parquet",
    schedule="@daily",                      # one run per day (Airflow 2.x arg name)
    start_date=pendulum.datetime(2026, 3, 1, tz="UTC"),
    catchup=True,                           # let Airflow backfill missed days
    max_active_runs=1,                      # days run in order, one at a time
    tags=["etl", "batch", "parquet"],
) as dag:
    t_extract = PythonOperator(task_id="extract", python_callable=extract)
    t_transform = PythonOperator(task_id="transform", python_callable=transform)
    t_load = PythonOperator(task_id="load", python_callable=load)

    t_extract >> t_transform >> t_load      # the dependency edges = the DAG

This DAG was parse-validated, not task-executed. It was loaded into a real Airflow 2.10.5 DagBag — the same parser the scheduler uses — and reported zero import errors. Its structure introspects exactly as intended:

import errors: {} (none)
dags found: ['events_daily_etl']
schedule: @daily | catchup: True | max_active_runs: 1
tasks: ['extract', 'load', 'transform']   retries (per task): 2 each
  extract    upstream=[]            downstream=['transform']
  transform  upstream=['extract']   downstream=['load']
  load       upstream=['transform'] downstream=[]
roots: ['extract'] | leaves: ['load']

The task bodies (which read from /data/...) were not run against a live scheduler or warehouse — there is no Airflow metadata database or file tree here. What is proven is that the DAG is syntactically and structurally valid Airflow: it parses, its three tasks exist, its dependency chain is extract → transform → load, and its retry/schedule/backfill settings are as written.

Notice how the DAG encodes the pipeline properties. retries=2 with a delay means a transient failure (a source API blip) self-heals without a human. catchup=True means Airflow will backfill every day from start_date — safe only because the load is idempotent. max_active_runs=1 serialises the days so backfill runs in order. The >> operator declares the edges; Airflow derives the order. And every task keys off ds, so run and backfill are reproducible. The settings that control all of this:

Parameter Controls Sane default for a daily ETL
schedule cadence — cron, @daily, or a dataset trigger @daily
start_date first interval the DAG represents a fixed past date (never now())
catchup backfill every interval since start_date True only if tasks are idempotent
max_active_runs how many DAG runs run at once 1 for ordered backfill
retries / retry_delay auto re-run of a failed task 2 / 5 min
depends_on_past require the prior run to succeed first False unless runs are truly sequential
sla alert if a task overruns set to your freshness target
execution_timeout kill a task that hangs bound it so a stuck run doesn’t block others

Modern Airflow also offers the TaskFlow API (@task-decorated functions whose return values become XComs automatically), which reads more like plain Python; the classic PythonOperator shown here makes the tasks, operators, XComs and edges explicit, which is what you want when learning the model.

One non-negotiable: Airflow tasks must themselves be idempotent. Airflow will retry a task, and if the task’s load appends instead of overwrites, retry #2 double-writes. The orchestrator guarantees at-least-once execution of each task, so each task must be safe to run more than once — which is exactly the overwrite-partition property you already built. Orchestration does not grant idempotency; it requires it.

Airflow is not the only orchestrator. Prefect and Dagster are the modern Python-native alternatives — Dagster in particular centres on data assets (the tables you produce) rather than tasks, which fits the “materialise this partition” mental model well. The concepts transfer wholesale: DAGs, retries, backfills, logical dates.


Streaming: batch vs stream, Kafka, and the unbounded world

Everything so far is batch: bounded chunks of data, processed on a cadence, where “yesterday’s orders” is a complete, finite set you can re-read and re-process. A large fraction of the world is not like that. Events happen continuously and you often need to react in milliseconds, not hours — a fraud check on a card swipe, a real-time inventory decrement, a live dashboard, an alert when a sensor crosses a threshold. That is streaming: an unbounded flow of events processed continuously as they arrive.

The distinction is bounded vs unbounded, and it changes everything downstream. A batch job knows where its data ends, so it can sort, join, aggregate over the whole set, and re-run deterministically. A stream never ends, so “the total” is never final — you compute over windows, you cope with events that arrive out of order or late, and “re-run” means “replay from an offset.” Reach for streaming when latency genuinely must be sub-second and the value of an event decays fast; stay with batch — which is simpler, cheaper, and easier to make correct — for everything else. Most “we need real-time” requirements are satisfied by a batch job running every few minutes, and you should confirm you truly need streaming before paying its complexity.

Dimension Batch Streaming
Data boundary bounded — a finite chunk (a day, a file) unbounded — a never-ending flow
Latency minutes to hours milliseconds to seconds
Processing run, complete, exit run continuously, never “done”
Aggregation over the whole dataset over windows (tumbling/sliding/session)
Ordering sort freely; whole set available out-of-order and late events are the norm
Re-run re-read the input, reprocess replay from a stored offset
Correctness easy — deterministic over a fixed set hard — windows, watermarks, exactly-once
Cost & complexity lower — start here higher — justify the need
Typical tools pandas, Spark, dbt, Airflow Kafka, Flink, Spark Structured Streaming, Kafka Streams
Use when reports, rollups, ML training data, nightly loads fraud, alerting, live dashboards, CDC, event-driven apps

Kafka: the log at the centre

Apache Kafka is the backbone of most streaming systems, and its mental model is one word: the log. Not “a queue you pop from” — an append-only, replayable log that many consumers can read independently at their own positions. Producers append events to the end; consumers read forward from wherever they are; the events persist (for a retention window) so a new consumer can replay history and a crashed one can resume.

Kafka concept What it is
Topic a named stream of events (“orders”, “clicks”) — the logical log
Partition a topic is split into partitions for parallelism; each is an ordered, append-only log
Offset a message’s position within a partition — a monotonically increasing integer
Producer writes (appends) events to a topic; a key decides the partition
Consumer reads events forward from a partition, tracking its offset
Consumer group a set of consumers that share a topic’s partitions — the unit of horizontal scaling
Broker a Kafka server holding partitions; a cluster is many brokers
Retention how long events persist (time or size) before deletion — enables replay

Three facts unlock Kafka. Ordering is per-partition, not per-topic — Kafka guarantees order within a partition but not across partitions, so if you need all of one user’s events in order, you must key them to land in the same partition. Consumer groups are how you scale — within a group, each partition is consumed by exactly one member, so you scale throughput by adding consumers up to the partition count (beyond that, extra consumers sit idle). Offsets are the replay mechanism — a consumer commits its offset to mark progress; commit after processing and a crash re-reads the last batch (at-least-once); commit before and a crash loses it (at-most-once). The offset is the streaming analogue of the batch watermark.

Here is an accurate kafka-python producer and consumer. This code was not run against a broker — there is no Kafka cluster in this environment. Every parameter and method, however, was verified against kafka-python 3.0.8 by introspecting the library, so the API is real, not invented:

# PRODUCER — append events to a topic (CONCEPTUAL: not run against a broker)
import json
from kafka import KafkaProducer

producer = KafkaProducer(
    bootstrap_servers="localhost:9092",
    value_serializer=lambda v: json.dumps(v).encode("utf-8"),
    key_serializer=lambda k: k.encode("utf-8"),
    acks="all",                 # wait for all in-sync replicas — durability
    enable_idempotence=True,    # broker de-dupes producer retries (exactly-once append)
    retries=5,
    linger_ms=10,               # batch briefly for throughput
    compression_type="zstd",
)

for event in stream_of_events():                     # your event source
    # key by user_id so one user's events share a partition -> ordered
    producer.send("events", key=event["user_id"], value=event)

producer.flush()   # block until buffered records are actually sent
# CONSUMER — read forward, process, commit offset (CONCEPTUAL: not run)
import json
from kafka import KafkaConsumer

consumer = KafkaConsumer(
    "events",
    bootstrap_servers="localhost:9092",
    group_id="etl-loader",          # consumer group -> shares partitions, scales out
    auto_offset_reset="earliest",   # a brand-new group starts from the log's start
    enable_auto_commit=False,       # WE commit, after the work is durably done
    value_deserializer=lambda m: json.loads(m.decode("utf-8")),
    key_deserializer=lambda k: k.decode("utf-8"),
    max_poll_records=500,
)

for msg in consumer:
    # msg exposes: msg.topic, msg.partition, msg.offset, msg.key, msg.value, msg.timestamp
    handle(msg.value)               # process (idempotently!) — e.g. upsert to a sink
    consumer.commit()               # commit AFTER handling -> at-least-once delivery

The parameters that matter most (all verified against kafka-python 3.0.8):

Producer setting Does Correctness note
acks="all" wait for all in-sync replicas before ack durability — "1"/"0" are faster but can lose data
enable_idempotence=True broker de-dupes producer retries exactly-once append within Kafka
retries resend on transient failure needs idempotence on, or retries can reorder/dupe
key_serializer / value_serializer bytes ← your objects key decides the partition → ordering
linger_ms / compression_type batch + compress before sending throughput knobs
Consumer setting Does Correctness note
group_id join a consumer group the unit of scaling; one member per partition
enable_auto_commit=False you commit offsets commit after processing → at-least-once
auto_offset_reset where a new group starts "earliest" replays; "latest" skips backlog
max_poll_records batch size per poll tune against processing time
value_deserializer your objects ← bytes mirror the producer’s serializer

The enable_auto_commit=False plus commit-after-processing is the crux: it gives at-least-once delivery (a crash mid-batch re-reads uncommitted messages), and since re-delivery is possible, handle() must be idempotent — the same event processed twice must not double-count. That is the same idempotency discipline from the batch half, now load-bearing for streaming correctness.

Windowing, delivery semantics, and late data

Two hard problems define stream processing, and they are why streaming is harder than batch.

Windowing. You cannot sum an unbounded stream — there is no end. So aggregations run over windows: bounded slices of the stream. Three kinds cover most needs:

Window Definition Example use
Tumbling fixed-size, non-overlapping “orders per 1-minute bucket”
Sliding fixed-size, overlapping, step < size “5-min moving average, updated every minute”
Session dynamic, closed by a gap of inactivity “group a user’s clicks until 30 min idle”

Late and out-of-order data. Events do not arrive in the order they happened — a mobile phone goes offline and flushes an hour later, a partition lags, clocks skew. So there are two clocks: event time (when it happened) and processing time (when you saw it). Correct aggregations use event time, which means a window for 10:00–10:01 might receive a 10:00 event at 10:05. The system needs a watermark — a bound on lateness (“assume nothing older than 2 minutes will still arrive”) — after which a window is considered complete and emitted; events later than the watermark are dropped or sent to a side output. This is the streaming twin of the batch watermark, and getting it wrong means either waiting forever or emitting wrong (incomplete) aggregates.

Delivery semantics describe how many times an event is processed under failure:

Semantic Guarantee Cost How
At-most-once 0 or 1 — may lose data cheapest commit offset before processing
At-least-once 1 or more — may duplicate moderate commit after processing; requires idempotent handling
Exactly-once exactly 1 effect most expensive idempotent producer + transactional writes, or idempotent sink

True exactly-once is the hardest guarantee in distributed systems, and the practical route to it is almost always “at-least-once delivery + an idempotent sink” — process each event one-or-more times, but design the write so duplicates have no effect (upsert by key, dedupe on an event ID). Kafka’s enable_idempotence and transactions provide exactly-once within Kafka; end-to-end exactly-once into an external system still leans on the sink being idempotent. Once again: idempotency is the property that makes distributed data correct.

One clarification that trips up newcomers: Kafka is the log, not the processor. It transports and stores events; it does not, by itself, compute windowed aggregates or manage watermarks. That work lives in a stream-processing framework layered on top. Kafka Streams (a JVM library) and ksqlDB process Kafka topics with windowing and joins; Apache Flink is the heavyweight for true event-time streaming with sophisticated watermarking and exactly-once state; Spark Structured Streaming brings the batch DataFrame API to micro-batched streams, which is often the pragmatic middle ground. Python’s reach into this layer is real but thinner than in batch — PyFlink and Faust exist, and kafka-python handles produce/consume — but the heavy stateful processing is still JVM territory, and a common architecture is Python producing to and consuming from Kafka with Flink or Spark doing the stateful windowing in between. When you evaluate “do we need streaming,” you are really committing to operating one of these frameworks, and that operational weight is a large part of why batch-every-few-minutes so often wins.


The modern data stack: where Python fits

No one builds all of this from raw parts anymore. The modern stack is a set of specialised tools, and the useful thing to know is where Python sits in each.

Tool Category What it does Where Python fits
Apache Spark distributed compute batch + stream processing across a cluster; scales pandas-style logic to TBs PySpark — the DataFrame API is Python; the T of heavy ETL
dbt transformation (ELT) the “T”: versioned SQL SELECTs with tests, docs, lineage, in-warehouse config + macros; Python models on some warehouses
Snowflake / BigQuery cloud warehouse elastic SQL storage + compute; the ELT destination Python connectors, Snowpark (Python UDFs/DataFrames)
Apache Flink stream processing true low-latency streaming with event-time windows, exactly-once PyFlink — Python API over the Flink runtime
Apache Kafka event streaming the durable log at the centre of streaming kafka-python, confluent-kafka, Faust
Airflow / Dagster / Prefect orchestration schedule, retry, backfill, monitor the whole graph the DAGs are Python — this is Python’s home turf
Delta Lake / Iceberg / Hudi lakehouse table format ACID + schema evolution + time-travel on Parquet delta-rs, PyIceberg, Spark/PySpark
Great Expectations / Pandera data quality declarative validation suites with reports pure Python — the DQ gate, industrialised
Polars / DuckDB fast local engines columnar, multi-core DataFrame/SQL on one machine Python-first; a pandas-scale-up without a cluster

The strategic read: Python owns orchestration, ingestion, non-SQL transforms, ML feature pipelines, and data quality; SQL (via dbt) owns the in-warehouse modelling; and a distributed engine (Spark/Flink) takes over when one machine is not enough. For a huge share of real workloads you never need the cluster — pandas, or its faster cousins Polars and DuckDB, handle gigabytes on one box, and the pipeline you built in this lesson is the honest core of it. Start with the simplest thing that is correct — a scheduled, idempotent, partitioned Python batch job — and reach for Spark, Flink and the rest only when scale or latency forces you to. The properties transfer; the tools are implementation detail.


Hands-on lab

You will build a complete, idempotent batch ETL: generate raw daily event files (CSV and JSON), extract → transform (clean, dedupe, derive, validate, aggregate) → load to date-partitioned Parquet, then prove the three properties that make it a pipeline — idempotency, partition pruning, and incremental loading. Every number below is real, executed on Python 3.12.3 / pandas 3.0.3 / pyarrow 25.0.0.

Parquet needs a columnar engine, so work in a virtual environment:

python3.12 -m venv .venv && source .venv/bin/activate    # Windows: .venv\Scripts\activate
pip install pandas pyarrow
python -c "import pandas, pyarrow; print(pandas.__version__, pyarrow.__version__)"   # 3.0.3 25.0.0

Step 1 — Generate dirty raw daily sources (two CSV days, one JSON day). Real sources are messy: a retried webhook duplicates an event, categories arrive with stray whitespace and mixed case, a price is missing, an event lands just before midnight. Create make_raw.py:

import json
from pathlib import Path

RAW = Path("de_lab/raw"); RAW.mkdir(parents=True, exist_ok=True)

(RAW / "events_2026-03-01.csv").write_text(
"""event_id,ts,user_id,sku,category,qty,unit_price
e1001,2026-03-01T08:15:00,u1,MILK1,Dairy,2,62.0
e1002,2026-03-01T09:40:00,u2,RICE5, grain ,1,360.0
e1003,2026-03-01T12:05:00,u1,CURD1,dairy,3,55.0
e1004,2026-03-01T18:22:00,u3,OIL1,Oil,1,
e1002,2026-03-01T09:40:00,u2,RICE5, grain ,1,360.0
e1005,2026-03-01T23:50:00,u4,GHEE1,Oil,2,540.0
""")
(RAW / "events_2026-03-02.csv").write_text(
"""event_id,ts,user_id,sku,category,qty,unit_price
e2001,2026-03-02T07:05:00,u2,MILK1,Dairy,1,62.0
e2002,2026-03-02T10:30:00,u5,ATTA2,Grain,4,42.0
e2003,2026-03-02T14:12:00,u1,CURD1, DAIRY ,2,55.0
e2004,2026-03-02T16:45:00,u3,OIL1,oil,1,165.0
e2005,2026-03-02T21:15:00,u6,RICE5,Grain,2,360.0
""")
# Day 3 arrives as a JSON payload (an API/webhook dump), same logical schema.
day3 = [
    {"event_id": "e3001", "ts": "2026-03-03T08:00:00", "user_id": "u1", "sku": "MILK1", "category": "Dairy", "qty": 3, "unit_price": 62.0},
    {"event_id": "e3002", "ts": "2026-03-03T11:20:00", "user_id": "u7", "sku": "OIL1", "category": "Oil", "qty": 2, "unit_price": 165.0},
    {"event_id": "e3003", "ts": "2026-03-03T13:05:00", "user_id": "u2", "sku": "ATTA2", "category": "grain", "qty": 5, "unit_price": 42.0},
    {"event_id": "e3004", "ts": "2026-03-03T19:30:00", "user_id": "u3", "sku": "CURD1", "category": "Dairy", "qty": 1, "unit_price": 55.0},
]
(RAW / "events_2026-03-03.json").write_text(json.dumps(day3, indent=2))
(RAW / "events_2026-03-04.csv").write_text(   # the "new day" for the incremental run
"""event_id,ts,user_id,sku,category,qty,unit_price
e4001,2026-03-04T09:10:00,u5,MILK1,Dairy,2,62.0
e4002,2026-03-04T12:00:00,u1,RICE5,Grain,1,360.0
e4003,2026-03-04T15:40:00,u8,GHEE1,Oil,1,540.0
""")
print("raw sources written to", RAW)

What just happened: four days of raw events on disk — three to load now, one to add incrementally. Day 1 hides a duplicate (e1002 twice) and dirty categories; day 3 arrives as JSON to exercise a second extract path.

Step 2 — Write the ETL: extract, transform, two loaders. Create etl.py. The extract dispatches on file type; transform cleans, dedupes, derives and validates; there are two loaders — the naive buggy one and the idempotent fix — so you can see the difference:

import json, shutil
from pathlib import Path
import pandas as pd
import pyarrow as pa
import pyarrow.dataset as ds

ROOT = Path("de_lab"); WH = ROOT / "warehouse" / "events"
VALID_CATEGORIES = {"Dairy", "Grain", "Oil"}

def extract(path: Path) -> pd.DataFrame:
    if path.suffix == ".csv":
        return pd.read_csv(path)
    if path.suffix == ".json":
        return pd.read_json(path)
    raise ValueError(f"unsupported source format: {path.suffix}")

def transform(df: pd.DataFrame):
    df = df.copy()
    df["category"] = df["category"].str.strip().str.title()          # clean
    df["ts"] = pd.to_datetime(df["ts"])
    df["event_date"] = df["ts"].dt.date.astype("string")             # derive partition key
    before = len(df)
    df = df.drop_duplicates(subset=["event_id"], keep="first")       # dedupe
    dropped = before - len(df)
    med = df.groupby("category")["unit_price"].transform("median")   # fill from group median
    df["unit_price"] = df["unit_price"].fillna(med)
    df["revenue"] = (df["qty"] * df["unit_price"]).round(2)          # derive revenue
    problems = []                                                    # validate (DQ gate)
    if df["event_id"].duplicated().any(): problems.append("dup event_id")
    if not set(df["category"]).issubset(VALID_CATEGORIES):
        problems.append(f"bad category {set(df['category']) - VALID_CATEGORIES}")
    if (df["qty"] <= 0).any(): problems.append("non-positive qty")
    if df["revenue"].isna().any(): problems.append("null revenue")
    if problems:
        raise ValueError("data-quality check failed: " + "; ".join(problems))
    cols = ["event_id","ts","event_date","user_id","sku","category","qty","unit_price","revenue"]
    return df[cols].reset_index(drop=True), dropped

def load_naive(df, warehouse):        # THE BUG: appends a new part-file each run
    df.to_parquet(warehouse, partition_cols=["event_date"], engine="pyarrow")

def load_idempotent(df, warehouse):   # THE FIX: overwrite exactly this batch's partitions
    table = pa.Table.from_pandas(df, preserve_index=False)
    part = ds.partitioning(pa.schema([("event_date", pa.string())]), flavor="hive")
    ds.write_dataset(table, warehouse, format="parquet", partitioning=part,
                     existing_data_behavior="delete_matching",
                     basename_template="part-{i}.parquet")

def total_rows(warehouse):
    return ds.dataset(warehouse, partitioning="hive").count_rows() if Path(warehouse).exists() else 0

def run_etl(raw_path, warehouse, loader):
    raw = extract(raw_path)
    clean, dropped = transform(raw)
    loader(clean, warehouse)
    return {"source": raw_path.name, "raw_rows": len(raw), "loaded_rows": len(clean), "deduped": dropped}

Step 3 — Extract and transform one day; inspect the cleaned frame.

raw1 = extract(Path("de_lab/raw/events_2026-03-01.csv"))
print("raw day-1 rows:", len(raw1), "| raw categories:", raw1["category"].tolist())
clean1, dropped1 = transform(raw1)
print("cleaned rows:", len(clean1), "| duplicates dropped:", dropped1)
print(clean1[["event_id","event_date","category","qty","unit_price","revenue"]].to_string(index=False))
raw day-1 rows: 6 | raw categories: ['Dairy', ' grain ', 'dairy', 'Oil', ' grain ', 'Oil']
cleaned rows: 5 | duplicates dropped: 1
event_id event_date category  qty  unit_price  revenue
   e1001 2026-03-01    Dairy    2        62.0    124.0
   e1002 2026-03-01    Grain    1       360.0    360.0
   e1003 2026-03-01    Dairy    3        55.0    165.0
   e1004 2026-03-01      Oil    1       540.0    540.0
   e1005 2026-03-01      Oil    2       540.0   1080.0

What just happened: six raw rows became five clean ones. The duplicate e1002 was dropped; ' grain ' and 'dairy' were normalised to Grain/Dairy (so grouping works); the missing Oil price on e1004 was filled from the median Oil price (540, from e1005); and revenue = qty × unit_price was derived. One tidy, typed, validated frame — the T of ETL.

Step 4 — The idempotency bug: naive append, same day twice.

wh_bug = Path("de_lab/warehouse_bug/events")
if wh_bug.exists(): shutil.rmtree(wh_bug)
run_etl(Path("de_lab/raw/events_2026-03-01.csv"), wh_bug, load_naive)
print("after run #1:", total_rows(wh_bug), "rows")
run_etl(Path("de_lab/raw/events_2026-03-01.csv"), wh_bug, load_naive)   # RE-RUN, same input
print("after run #2 (same input):", total_rows(wh_bug), "rows  <-- DOUBLED")
print("part-files:", [p.name for p in (wh_bug/"event_date=2026-03-01").glob("*.parquet")])
after run #1: 5 rows
after run #2 (same input): 10 rows  <-- DOUBLED
part-files: ['ab5a8d63f3834b9f808a2d5832a46d29-0.parquet', 'dff70eaf8a534ecfa52b26ec8bfbbeb3-0.parquet']

What just happened: re-running the identical input doubled the rows, with no error. The naive writer dropped a second, randomly-named part-file into the same partition (your hex names will differ). This is the bug that survives code review and corrupts production on the first retry.

Step 5 — The fix: overwrite-partition, same day twice.

if WH.exists(): shutil.rmtree(WH)
run_etl(Path("de_lab/raw/events_2026-03-01.csv"), WH, load_idempotent)
print("after run #1:", total_rows(WH), "rows")
run_etl(Path("de_lab/raw/events_2026-03-01.csv"), WH, load_idempotent)  # RE-RUN, same input
print("after run #2 (same input):", total_rows(WH), "rows  <-- STABLE")
print("part-files:", [p.name for p in (WH/"event_date=2026-03-01").glob("*.parquet")])
after run #1: 5 rows
after run #2 (same input): 5 rows  <-- STABLE
part-files: ['part-0.parquet']

What just happened: the idempotent loader converges — five rows no matter how many times you run it, one clean part-0.parquet. existing_data_behavior="delete_matching" deleted the day’s partition before rewriting it. The pipeline is now re-run-safe.

Step 6 — Backfill days 2 (CSV) and 3 (JSON).

for src in ["events_2026-03-02.csv", "events_2026-03-03.json"]:
    print(run_etl(Path("de_lab/raw")/src, WH, load_idempotent))
print("warehouse total rows:", total_rows(WH))
print("partitions:", sorted(p.name for p in WH.glob("event_date=*")))
{'source': 'events_2026-03-02.csv', 'raw_rows': 5, 'loaded_rows': 5, 'deduped': 0}
{'source': 'events_2026-03-03.json', 'raw_rows': 4, 'loaded_rows': 4, 'deduped': 0}
warehouse total rows: 14
partitions: ['event_date=2026-03-01', 'event_date=2026-03-02', 'event_date=2026-03-03']

What just happened: the same ETL ingested a CSV day and a JSON day into the same partitioned warehouse — 14 rows across three date partitions. The extract dispatch made the source format invisible to the rest of the pipeline.

Step 7 — Partition pruning: read ONE day, prove only one file is scanned.

dataset = ds.dataset(WH, partitioning="hive")
flt = ds.field("event_date") == "2026-03-02"
print("total fragments:", len(list(dataset.get_fragments())),
      "| selected for 2026-03-02:", len(list(dataset.get_fragments(filter=flt))))
one = pd.read_parquet(WH, filters=[("event_date", "==", "2026-03-02")])
print("rows read:", len(one), "| dates in result:", sorted(one["event_date"].unique()))
total fragments: 3 | selected for 2026-03-02: 1
rows read: 5 | dates in result: ['2026-03-02']

What just happened: the dataset has three files (one per day); filtering to 2026-03-02 selected exactly one fragment to scan — the other two days’ files were never opened. That is partition pruning: the filter became a directory skip, not a full scan. On a warehouse billing by bytes scanned, this is the difference between a cheap query and an expensive one.

Step 8 — Incremental run: add only the new day.

before = total_rows(WH); before_parts = sorted(p.name for p in WH.glob("event_date=*"))
print(run_etl(Path("de_lab/raw/events_2026-03-04.csv"), WH, load_idempotent))
after_parts = sorted(p.name for p in WH.glob("event_date=*"))
print(f"before: {before} rows, {len(before_parts)} parts -> after: {total_rows(WH)} rows, {len(after_parts)} parts")
print("added:", sorted(set(after_parts) - set(before_parts)), "| existing untouched:", before_parts == [p for p in after_parts if p in before_parts])
{'source': 'events_2026-03-04.csv', 'raw_rows': 3, 'loaded_rows': 3, 'deduped': 0}
before: 14 rows, 3 parts -> after: 17 rows, 4 parts
added: ['event_date=2026-03-04'] | existing untouched: True

What just happened: the incremental run processed only the new day — three rows, one new partition — and left the three existing partitions untouched. Incrementality (only new data) and idempotency (overwrite just this partition) working together.

Step 9 — Read the whole warehouse back and aggregate.

full = ds.dataset(WH, partitioning="hive").to_table().to_pandas()
daily = full.groupby("event_date").agg(
    events=("event_id", "size"), revenue=("revenue", "sum"), users=("user_id", "nunique")
).reset_index()
print(daily.to_string(index=False))
event_date  events  revenue  users
2026-03-01       5   2269.0      4
2026-03-02       5   1225.0      5
2026-03-03       4    781.0      4
2026-03-04       3   1024.0      3

What just happened: the round trip is complete — you wrote partitioned Parquet, read it all back, and answered “revenue and unique users per day.” You have built, end to end, an idempotent, incremental, partitioned, validated batch ETL: the honest core of data engineering, every number real.

⚠️ The lab writes under de_lab/ in the current directory. Remove it when done (rm -rf de_lab) — it is throwaway.


Common mistakes and troubleshooting

Symptom Cause Fix
Re-running a day doubled all its rows; no error Naive append — to_parquet(partition_cols=...) writes a new part-file each run Overwrite-partition: write_dataset(existing_data_behavior="delete_matching") (or delete the partition dir, then write)
Every run gets slower; reprocesses all history No incremental/watermark — a full load masquerading as a pipeline Track a high-water mark (max(updated_at) / partition date); read only rows above it
A one-day query scans years of data; huge bill Data is CSV, or Parquet but unpartitioned — no pruning possible Write partitioned Parquet (event_date=…); read with a partition filter
Queries slow despite Parquet Partitioned by a high-cardinality column → millions of tiny files Repartition by date; compact small files into ~hundreds-of-MB parts
A new source column silently disappeared on read pyarrow infers dataset schema from the first file, which predates the column pa.unify_schemas([...]) and pass an explicit schema; or use Delta/Iceberg
Load failed: schema mismatch on a column type Source widened a type (int→float, string→int) between runs Cast in the transform to a declared schema; validate dtypes in the DQ gate
Bad rows (nulls, dupes, junk) reached dashboards No data-quality gate — the transform trusted its input Add assertions (schema/null/range/dupe) that raise before load; adopt Great Expectations/Pandera
Airflow task retried and double-wrote Task is not idempotent — retry re-ran a blind append Make the task overwrite-partition/upsert; Airflow guarantees at-least-once, so tasks must be re-run-safe
Backfill read today’s data for every historical run Task used date.today() instead of the logical date Key every task off ds / logical_date; never datetime.now() for the data window
Events filed under the wrong day near midnight Timezone bug — partition key derived from a naive/mixed-tz timestamp Normalise to one tz before deriving the date key (see below)
Kafka consumer lag climbs; or a reset re-reads everything Too few consumers for the partitions, or offsets not committed / auto_offset_reset fired Scale consumers ≤ partition count; commit offsets after processing; set auto_offset_reset deliberately
Streaming aggregate is wrong — a straggler landed late Late/out-of-order event arrived after its event-time window closed Use event-time windows + a watermark/allowed-lateness; route later events to a side output
MemoryError loading a huge CSV Read the whole file into RAM at once Stream it: pd.read_csv(path, chunksize=100_000) and aggregate per chunk (shown below)

Three of these deserve their own paragraphs.

The idempotency trap is the one that will actually bite you. It is invisible in development, because you run once and see the right answer. It appears the first time a task retries in production — a network blip, an OOM, an on-call re-trigger — and now a partition has two copies of every row and your revenue is doubled with no error anywhere. The discipline is absolute: every load and every orchestrated task must be idempotent. Overwrite-partition for date-partitioned facts, upsert-by-key for mutable rows, and never a blind append unless you can prove exactly-once delivery upstream. Treat an append in a scheduled task the way you would treat a bare except: pass — a smell that needs justification.

The timezone partition bug is subtle and common. Derive the partition date from a timestamp without pinning a timezone, and the same instant files under different days depending on the reader’s zone. Executed — an event at 23:50 UTC:

ev = pd.to_datetime(["2026-03-01T23:50:00Z"])
print("UTC date:", ev.date[0], "| IST date:", ev.tz_convert("Asia/Kolkata").date[0])
UTC date: 2026-03-01 | IST date: 2026-03-02

The identical event belongs to March 1st in UTC and March 2nd in IST — two different partitions for one instant. Pick one timezone for your partition key (UTC is the sane default), convert every timestamp to it before deriving the date, and document it. Mixing zones across runs scatters a single day’s events across two partitions and quietly breaks every daily aggregate.

The huge-file memory blow-up has a one-line fix. Loading a multi-gigabyte CSV with a plain read_csv can exhaust RAM and raise MemoryError. Stream it in chunks — read_csv returns an iterator of frames — and reduce as you go:

total, rows = 0.0, 0
for chunk in pd.read_csv("big.csv", chunksize=100_000):   # an iterator of 100k-row frames
    total += chunk["revenue"].sum(); rows += len(chunk)
print(f"streamed {rows:,} rows; total revenue = {total:,.2f}")
streamed 500,000 rows; total revenue = 775,203,717.61

Executed, that chunked sum matches the whole-file read exactly, at a fraction of the memory. The same idea scales up: process partition-by-partition, use a chunked reader, or move to an out-of-core engine (Dask, Polars streaming, DuckDB) — but never assume the file fits in RAM just because it did in the demo.


Cheat-sheet

Task Code
Read CSV / JSON pd.read_csv(p) · pd.read_json(p)
Write partitioned Parquet (idempotent) ds.write_dataset(tbl, wh, partitioning=part, existing_data_behavior="delete_matching", basename_template="part-{i}.parquet")
Write partitioned Parquet (pandas, appends!) df.to_parquet(wh, partition_cols=["event_date"]) — not idempotent
Read a partitioned dataset pd.read_parquet(wh) · ds.dataset(wh, partitioning="hive").to_table()
Partition pruning (read one day) pd.read_parquet(wh, filters=[("event_date","==","2026-03-02")])
Column projection (read some cols) pd.read_parquet(p, columns=["revenue"])
Count rows without loading ds.dataset(wh, partitioning="hive").count_rows()
Which files a filter scans ds.dataset(wh).get_fragments(filter=ds.field("event_date")=="…")
Derive a UTC partition key df["ts"].dt.tz_convert("UTC").dt.date.astype("string")
Dedupe on a key df.drop_duplicates(subset=["event_id"], keep="first")
Chunked read (big file) for c in pd.read_csv(p, chunksize=100_000): ...
Unify schemas across files pa.unify_schemas([pa.parquet.read_schema(p) for p in files])
Compression choice to_parquet(..., compression="zstd") (best) · "snappy" (fast default)
Airflow task dependency t_extract >> t_transform >> t_load
Airflow logical date in a task task callable arg ds (string YYYY-MM-DD) — not datetime.now()
Airflow backfill catchup=True + start_date (needs idempotent tasks)
Validate a DAG parses load it in DagBag(...); assert .import_errors == {}
Kafka producer send producer.send("topic", key=k, value=v) then producer.flush()
Kafka consume + commit for m in consumer: handle(m.value); consumer.commit() (after processing)
ETL rule of thumb idempotent + incremental + partitioned + validated + observable

Interview and exam questions

Q: What is the difference between ETL and ELT, and why did ELT rise? A: Both extract, transform, and load; the difference is the order of the last two. ETL transforms data before loading it, historically on a separate engine, because warehouse compute was scarce. ELT loads raw data into the warehouse first and transforms it there, in SQL. ELT rose because cloud warehouses (Snowflake, BigQuery, Databricks) made in-warehouse compute cheap and elastic, so the reason to transform-before-load vanished — and loading raw first lets you keep the source, re-transform on new requirements without re-extracting, and express transforms as version-controlled SQL (dbt). ETL survives wherever the transform is not naturally SQL (parsing, ML, nested payloads) — which is where Python lives.

Q: What does idempotency mean for a pipeline, and how do you achieve it for a date-partitioned load? A: Idempotent means running twice yields the same result as running once — essential because pipelines get retried, re-triggered, and back-filled. The classic non-idempotent bug is append on re-run, which duplicates rows (a naive partitioned write drops a new file each run, doubling the partition). You achieve it with overwrite-partition: before writing a partition, delete what is there, so the partition ends holding exactly this run’s output (existing_data_behavior="delete_matching", or delete the directory then write). For mutable rows, use upsert/merge by key. A deterministic filename matters too, so a re-run overwrites rather than accumulates.

Q: Why is Parquet so much better than CSV for analytics, and what two things does its columnar layout enable? A: Parquet is a typed, compressed, columnar binary format; CSV is untyped row-oriented text. Two wins follow from columnar storage: column projection (a query reads only the columns it needs, skipping the rest on disk) and predicate pushdown (per-chunk min/max statistics let the reader skip chunks that cannot match a filter). Plus typed columns compress far better than text. Measured: Parquet+zstd is ~4.5× smaller than CSV, and reading Parquet is ~21× faster than CSV because there is no text parsing — just typed bytes into typed arrays.

Q: What is partition pruning and why is it the biggest cost lever in analytics? A: Partitioning splits a dataset into subdirectories by a column’s value (event_date=2026-03-01/…), which the query engine reads as a physical index from the path. Pruning is skipping the partitions a query’s filter excludes — a one-day query opens one directory and ignores the rest. It is the biggest cost lever because cloud warehouses bill by bytes scanned: partition by the column you filter on and a one-day query scans one day’s bytes instead of all history — often a 10-to-100× difference in money and time. The caveat is cardinality: partition by date (low cardinality), never by a user ID (high cardinality → the small-files problem).

Q: What is a watermark, and where does the same idea appear in streaming? A: In batch extraction, a watermark is a stored high-water mark (max(updated_at), a max ID, a partition date) marking “everything up to here is processed”; the next run reads only rows above it, enabling incremental loads. In streaming, a watermark bounds lateness — “assume no event older than 2 minutes will still arrive” — after which an event-time window is considered complete and emitted. Both answer “what is safe to consider done”; the batch version drives incrementality, the streaming version drives correct windowed aggregation over out-of-order data.

Q: Explain the Airflow DAG, and why the logical date matters. A: A DAG is a Directed Acyclic Graph of tasks: nodes are tasks (built from operators), edges (a >> b) are dependencies, and it is acyclic so a valid run order always exists. Airflow schedules it, runs independent tasks in parallel, retries failures, and backfills. The logical date (ds/logical_date, formerly execution_date) is the date a run represents — not wall-clock run time. It matters because tasks must key off it, not datetime.now(): a task that reads “today’s” file with date.today() will, during a backfill of March, wrongly read today’s data for every historical run. Binding work to the logical date makes each run and each backfill reproducible.

Q: Why must Airflow tasks be idempotent? A: Airflow guarantees at-least-once execution — it retries failed tasks and can re-run intervals during a backfill. So each task will sometimes run more than once, and if a task is a blind append, retry #2 double-writes. The orchestrator does not grant idempotency; it requires it. Tasks must overwrite-partition or upsert so that re-execution converges to the same state.

Q: When do you actually need streaming instead of batch? A: When latency genuinely must be sub-second and an event’s value decays fast — fraud detection on a swipe, real-time inventory, live alerting, CDC feeding a warehouse, event-driven apps. Batch is bounded, simpler, cheaper, and easier to make correct, and it covers most work; many “real-time” needs are met by a batch job every few minutes. Streaming adds real cost: windowing (you cannot sum an unbounded stream), out-of-order/late data, watermarks, and hard delivery semantics. Confirm you truly need sub-second latency before paying that complexity.

Q: Explain Kafka’s core model — topic, partition, offset, consumer group. A: Kafka is an append-only, replayable log. A topic is a named stream; it is split into partitions, each an ordered append-only log, for parallelism. A message’s position in a partition is its offset (a monotonically increasing integer). Producers append (a key decides the partition); consumers read forward, tracking their offset. A consumer group shares a topic’s partitions — each partition is consumed by exactly one member — so you scale throughput by adding consumers up to the partition count. Ordering is guaranteed per partition, not per topic; offsets are the replay/resume mechanism, the streaming analogue of a batch watermark.

Q: What are the three delivery semantics, and how do you get exactly-once in practice? A: At-most-once (0 or 1, may lose data — commit offset before processing), at-least-once (1+, may duplicate — commit after processing), exactly-once (exactly one effect). True exactly-once is the hardest distributed guarantee; in practice you achieve its effect with “at-least-once delivery + an idempotent sink” — process each event one-or-more times but make the write have no effect on duplicates (upsert by key, dedupe on event ID). Kafka’s idempotent producer and transactions give exactly-once within Kafka; end-to-end into an external system still relies on the sink being idempotent.

Q (practical): Your daily revenue table shows exactly double yesterday’s number after an on-call engineer re-ran the job. Diagnose and fix. A: The load is not idempotent — it appended instead of overwriting, so re-running the day wrote a second copy of every row into that partition. Diagnose by listing the partition’s files (you will see two part-files) and checking the row count is 2× expected. Fix: switch the load to overwrite-partition (existing_data_behavior="delete_matching" with a deterministic basename_template), delete the duplicated partition and re-run once to converge, and add a row-count observability check that alerts on a sudden doubling. Then audit every scheduled task for the same append-on-retry bug.

Q (practical): A new channel column was added to the source; downstream it is missing entirely, with no error. Why? A: When reading a partitioned Parquet dataset, pyarrow infers the schema from the first file it discovers. If that file predates channel, the column is dropped on read — a silent schema-evolution data loss. Fix by unifying schemas explicitly: pa.unify_schemas([read_schema(f) for f in files]) and pass the result as the dataset schema (old rows null-fill the new column), or adopt a table format (Delta/Iceberg) that tracks and evolves schema transactionally. Add a schema check to the DQ gate so an unexpected column is noticed at load time, not weeks later.


Key takeaways


This lesson is the bridge from analysing data to engineering it. The pandas you learned to group, merge, and clean is the T in the middle; reading and writing files and JSON is the E and the L; and SQL databases are both a common source and a common destination. Where this pipeline ends — clean, partitioned, trustworthy tables — is exactly where building reports and dashboards begins: the plumbing done right is what lets the presentation layer simply ask and get a fast, correct answer.

pythondata-engineeringetleltpandasparquetpartitioningidempotencyairflowkafkastreamingbatch-processingdata-pipelineorchestration
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments