Part 9 · Data Engineering · Module 60

Data Engineering Foundations

Every dashboard, report, and machine-learning model stands on data that someone moved, cleaned, modeled, and stored reliably. Data engineering is that someone's craft. This module gives you the vocabulary and the mental models the rest of Part 9 builds on: lifecycles, workloads, ETL and ELT, batch and streaming, star schemas, file formats, partitioning, schemas, and contracts.

⏱ ~2.5 hoursPrerequisites: M59 · ML in Production, M51 · pandas

What data engineers do

A data engineer builds and runs the systems that move data from where it is produced to where it is useful, in a shape people can trust. Application developers produce data as a side effect (an order row, a click event, a log line). Analysts, data scientists, and ML models consume it. The data engineer owns the path in between:

The job is less about clever algorithms and more about reliability. A pipeline that is correct 99% of the time silently corrupts 1% of decisions. So data engineers obsess over properties you will meet throughout Part 9: idempotency (running twice gives the same result), determinism, incremental processing, and observability.

The data lifecycle

Data moves through a recognizable lifecycle. Naming the stages helps you ask the right question at each one.

flowchart LR
  G[Generate
apps, devices, logs] --> I[Ingest
batch or stream] I --> S[Store
raw / bronze] S --> T[Transform
clean / silver] T --> M[Model
marts / gold] M --> U[Serve
BI, ML, APIs] U -. feedback .-> G

Many teams call the storage layers bronze (raw, exactly as received), silver (cleaned and conformed), and gold (business-level aggregates and models). Keeping the raw layer unchanged is a key professional habit: when you find a bug in a transformation, you can fix the code and replay from raw instead of asking the source to resend months of data.

Remember: data engineering is the reliable movement and shaping of data; keep raw data immutable so you can always replay.

OLTP vs OLAP

Databases serve two very different kinds of work.

OLTP: online transaction processing

The application's database (PostgreSQL, MySQL, SQLite in an app). Many small reads and writes touching a few rows each: "insert this order", "update this user's email". Optimized for low latency, concurrency, and integrity (transactions, constraints). Data is normalized so each fact lives in one place.

OLAP: online analytical processing

The analytics system (Snowflake, BigQuery, Redshift, DuckDB, ClickHouse). Few, large queries that scan millions of rows but only a handful of columns: "revenue by country by month". Optimized for scan throughput, usually with columnar storage, compression, and parallelism. Data is often denormalized into star schemas.

The deep reason they differ is storage layout. A row store keeps each row's values together, so fetching one whole order is one contiguous read. A column store keeps each column's values together, so summing amount over a billion rows reads only that column, and similar values sitting next to each other compress extremely well.

Row store (OLTP) 1 | 19.99 | US2 | 5.00 | US4 | 42.50 | DE one row = one contiguous chunk: great for "get order 2" Column store (OLAP) order_id: 1, 2, 4 amount: 19.99, 5.00, 42.50 country: US, US, DE SUM(amount) reads one column only; repeated values (US, US) compress well
The same three orders stored row-wise and column-wise.

Pure Python exaggerates nothing here: real column stores add vectorized execution and compression on top, which is why DuckDB can scan hundreds of millions of rows per second on a laptop (you'll try it in M61).

Remember: OLTP = many tiny row operations; OLAP = few huge column scans. The layout follows the workload.

ETL vs ELT, batch vs streaming

ETL (extract, transform, load) cleans and reshapes data before loading it into the warehouse, usually in a separate processing engine (a Python job, Spark). ELT (extract, load, transform) loads raw data first and transforms it inside the warehouse with SQL (often managed with dbt). ELT became popular because cloud warehouses are powerful and cheap to scale, and keeping raw data in the warehouse makes replays easy.

ETLELT
Where transforms runOutside the warehouse (Python, Spark)Inside the warehouse (SQL, dbt)
Raw data kept?Often not in the warehouseYes, loaded as-is
Good forHeavy parsing, PII scrubbing before storage, non-SQL logicSQL-friendly modeling, fast iteration, analysts owning logic
RiskReplays need the source againWarehouse costs; sensitive raw data lands in the warehouse

Batch vs streaming

Batch processing handles a bounded chunk of data at a time: "every night, process yesterday's files". It is simple, cheap, easy to re-run, and its latency is the batch interval. Streaming processes an unbounded sequence of events continuously, one by one or in tiny micro-batches, so results are seconds old instead of hours. It costs more complexity: ordering, late events, state, and exactly-once guarantees (M63 covers these).

Step through the two visualizers below with the same twelve records. In batch mode each stage finishes the whole batch before the next starts, so the first row reaches the target late. In streaming mode every record moves on as soon as a stage is done with it, so all stages work at once. Compare the tick at which the first row lands. Invalid records are parked in a dead-letter queue instead of crashing the run.

Remember: ETL transforms before loading, ELT after; batch trades latency for simplicity.

Data modeling for analytics: star schemas

An OLTP schema is normalized into many small tables to avoid update anomalies. Analysts would need ten joins to answer "revenue by product category by month". Dimensional modeling (popularized by Ralph Kimball) reorganizes data around business processes into two kinds of tables:

Arrange one fact table in the middle with dimensions around it and you get a star schema. If dimensions are themselves normalized into sub-dimensions (city → region → country), it becomes a snowflake schema; the star is usually preferred for its simpler joins.

erDiagram
  DIM_DATE ||--o{ FACT_RIDE : "date_key"
  DIM_RIDER ||--o{ FACT_RIDE : "rider_key"
  DIM_DRIVER ||--o{ FACT_RIDE : "driver_key"
  DIM_ZONE ||--o{ FACT_RIDE : "pickup_zone_key"
  FACT_RIDE {
    text ride_id PK
    int date_key FK
    int rider_key FK
    int driver_key FK
    int pickup_zone_key FK
    real distance_km
    real fare
    int duration_s
  }
  DIM_DATE {
    int date_key PK
    text date
    int month
    text weekday
    int is_weekend
  }
  DIM_RIDER {
    int rider_key PK
    text rider_id
    text segment
  }
  DIM_DRIVER {
    int driver_key PK
    text driver_id
    text vehicle_type
  }
  DIM_ZONE {
    int zone_key PK
    text zone_name
    text city
  }
    

Grain: the most important decision

The grain of a fact table is exactly what one row represents: "one row per ride", "one row per order line", "one row per store per day". Declare it first, in words, before choosing columns. Mixing grains in one table (some rows per order, some per order line) is the classic cause of double-counted revenue.

Surrogate keys (rider_key) are integers the warehouse assigns; natural keys (rider_id) come from the source system. Facts reference surrogate keys so dimensions can keep history, which brings us to slowly changing dimensions.

Slowly changing dimensions

Dimension attributes change: a customer moves city, a product is recategorized, a station is renamed. How you handle the change determines what historical reports say. The two types you must know:

Type 1: overwrite

Update the row in place. History is lost: last year's sales now appear under the new city. Use it for corrections (a typo in a name) and attributes nobody analyzes historically.

Type 2: add a new version

Close the current row (valid_to = change date, is_current = 0) and insert a new row with a new surrogate key. Facts keep pointing at the version that was true when they happened. Use it whenever "as it was at the time" matters.

There are other types (type 3 keeps a "previous value" column; type 6 combines 1, 2, and 3), but types 1 and 2 cover almost all real work. Remember: declare the grain first; facts hold measures at that grain; type 2 dimensions preserve history with versioned rows.

File formats compared

Data at rest lives in files far more often than you might expect: in object storage, in data lakes, in exports between teams. The format decides how fast you can read it, how small it is, whether types survive the round trip, and whether the schema can evolve.

FormatLayoutSchemaSplittable / compressibleTypical use
CSVText, row-basedNone (all strings; header optional)Splittable if uncompressed; gzip makes it unsplittableInterchange with humans and spreadsheets
JSON LinesText, one JSON object per lineSelf-describing per record, no enforcementLine-splittable; compresses wellLogs, events, API dumps, nested data
AvroBinary, row-basedRequired, stored in the file header; strong evolution rulesBlock-splittable, block compressionKafka messages, write-heavy ingestion
ParquetBinary, columnar (row groups → column chunks → pages)Required, embedded, with types and statisticsSplittable by row group; per-column compression and encodingThe default for analytics and lakes
ORCBinary, columnar (stripes)Required, embeddedSplittable; per-column compressionHive/Hadoop ecosystems
ArrowIn-memory columnar (Feather/IPC on disk)RequiredDesigned for zero-copy sharing, not archivalMoving data between processes and libraries (pandas, Polars, DuckDB)

Parquet wins for analytics because it combines a columnar layout (read only needed columns), per-column encodings (dictionary encoding turns repeated strings into small integers; run-length encoding collapses repeats), compression, and min/max statistics per row group that let readers skip whole chunks ("predicate pushdown"). Arrow is the in-memory twin: Parquet is how columnar data sleeps on disk; Arrow is how it is awake in RAM.

Why?

CSV has no types: every value comes back as a string, and the list becomes the string "['a', 'b']". JSON keeps numbers, booleans, and nested lists. Binary formats such as Parquet and Avro go further and keep exact types (int32 vs int64, timestamps with units, decimals).

Remember: CSV and JSON Lines for interchange and raw landing; Avro for row-oriented streaming records; Parquet (or ORC) for analytics at rest; Arrow in memory.

Compression

Compression trades CPU time for fewer bytes on disk and over the network. In data systems, I/O is usually the bottleneck, so a good codec often makes jobs faster, not just smaller. The codecs you will meet:

CodecRatioSpeedNotes
gzip (DEFLATE)GoodSlow to compress, OK to decompressEverywhere; a gzipped CSV cannot be split for parallel reads
SnappyModestVery fastHistoric Parquet default; favours speed over size
LZ4ModestExtremely fastKafka, in-memory caches
Zstandard (zstd)Very good, tunable (levels 1–22)FastThe modern default for Parquet, Kafka, and archives

Repetitive structured data (field names, a few distinct values) compresses many times over; random bytes do not compress at all. That is why columnar formats, which put similar values next to each other, compress so well.

Remember: zstd is the default choice today; snappy/LZ4 when CPU is the bottleneck; avoid gzipped CSV for large files you need to read in parallel.

Partitioning

A table of a billion rows stored as one file forces every query to read everything. Partitioning splits data into directories by the value of one or more columns, so a query filtering on those columns reads only the matching directories ("partition pruning"). The near-universal convention is Hive-style paths, where each directory level is column=value:

s3://spokes-lake/trips/year=2026/month=03/day=05/part-0000.parquet
s3://spokes-lake/trips/year=2026/month=03/day=06/part-0000.parquet
s3://spokes-lake/trips/year=2026/month=03/day=06/part-0001.parquet

Spark, DuckDB, Polars, pyarrow, Athena, and Trino all read these paths and turn the directory names back into columns. A query with WHERE year = 2026 AND month = 3 AND day = 6 touches one directory.

Remember: partition by what you filter on, keep partitions coarse enough for big files, and use column=value directory names.

Schemas and schema evolution

A schema is the declared structure of data: field names, types, nullability, and sometimes constraints. Schema-on-write (warehouses, Parquet, Avro) checks data when it is written; schema-on-read (raw JSON in a lake) stores anything and interprets it when read, which is flexible but pushes errors downstream.

Sources change over time. Schema evolution is the discipline of changing a schema without breaking the people on either side:

Remember: add fields with defaults, never repurpose a name, and treat a type change as a new field.

Lakes, warehouses, and lakehouses

Data warehouse

A managed analytical database (Snowflake, BigQuery, Redshift). Schema on write, SQL, ACID transactions, strong governance. Storage and compute are bundled by the vendor. Great for structured business data; can be costly and closed.

Data lake

Files (Parquet, JSON, images) in cheap object storage (S3, GCS, Azure Blob). Any data, any engine, very cheap. But plain files have no transactions: two writers can corrupt a table, deletes mean rewriting files, and "what's in this folder?" becomes a swamp without discipline.

Lakehouse

A lake plus an open table format that adds warehouse features on top of files: Delta Lake, Apache Iceberg, or Apache Hudi. A transaction log or metadata tree records which Parquet files make up each table version.

Open table formats give you: ACID commits (readers never see half-written data), time travel (query the table as of version 42 or last Tuesday), schema evolution, efficient MERGE/upsert and deletes (important for GDPR), and hidden partitioning (Iceberg derives partitions from columns so queries don't need to know the layout). Many engines (Spark, Trino, DuckDB, Snowflake, Polars) read them, so storage is decoupled from compute.

flowchart TB
  subgraph Lakehouse
    direction TB
    E1[Spark] --> TF
    E2[DuckDB / Polars] --> TF
    E3[Trino / Snowflake] --> TF
    TF[Table format metadata
Delta log / Iceberg snapshots] --> F[(Parquet files in object storage)] end

Remember: warehouse = managed and governed; lake = cheap files; lakehouse = files plus a table format that adds transactions, time travel, and evolution.

Data contracts

Most pipeline breakages are not bugs in the pipeline: an upstream team renamed a column, started sending amounts in cents, or emptied a field. A data contract is an explicit, versioned agreement between a data producer and its consumers, stating:

Contracts work when they are enforced in code, usually in the producer's CI and at the pipeline's front door.

In M62 you will meet the libraries that do this at scale (pandera, Great Expectations, pydantic). Remember: a contract turns "someone changed something" from a 3 a.m. surprise into a failed check in the producer's pull request.

Practice

Write grain_violations(rows, grain). rows is a list of dicts and grain a tuple of column names that should uniquely identify a row. Return a sorted list of the key tuples that appear more than once. For example, with grain ("order_id", "line"), two rows for ("o1", 1) are a violation.

Hint

Build tuple(row[c] for c in grain) for each row and count them with collections.Counter.

Show solution
from collections import Counter

def grain_violations(rows, grain):
    counts = Counter(tuple(row[c] for c in grain) for row in rows)
    return sorted(key for key, n in counts.items() if n > 1)

Write two functions. partition_path(record, keys) returns a Hive-style relative path string such as "year=2026/month=03" using the record's values for keys in order. parse_partitions(path) does the reverse: given any path string, return a dict of every name=value segment, for example "lake/trips/year=2026/month=03/part-0.parquet" → {"year": "2026", "month": "03"}.

Hint

Join f"{k}={record[k]}" with "/". For parsing, split on "/" and keep segments containing "="; str.partition("=") splits once.

Show solution
def partition_path(record, keys):
    return "/".join(f"{k}={record[k]}" for k in keys)

def parse_partitions(path):
    out = {}
    for segment in path.split("/"):
        if "=" in segment:
            name, _, value = segment.partition("=")
            out[name] = value
    return out

A dimension is a list of dicts with keys key, id, name, valid_from, valid_to, current. Write apply_scd2(dim, snapshot, as_of) where snapshot maps id → name. For each id: if there is no current row, append a new current row; if the current row's name differs, close it (valid_to = as_of, current = False) and append a new current row; otherwise do nothing. New rows get key = max existing key + 1 (1 for an empty dimension), valid_to = None. Process ids in sorted order, mutate dim in place, and return the number of rows added.

Hint

Find the current row with next((r for r in dim if r["id"] == i and r["current"]), None). Compute the next key each time you append.

Show solution
def apply_scd2(dim, snapshot, as_of):
    added = 0
    for sid in sorted(snapshot):
        name = snapshot[sid]
        current = next((r for r in dim if r["id"] == sid and r["current"]), None)
        if current is not None and current["name"] == name:
            continue
        if current is not None:
            current["valid_to"] = as_of
            current["current"] = False
        key = max((r["key"] for r in dim), default=0) + 1
        dim.append({"key": key, "id": sid, "name": name, "valid_from": as_of, "valid_to": None, "current": True})
        added += 1
    return added

Schemas are dicts mapping field name → {"type": str, "default": value} (the "default" key is optional). Write backward_problems(old, new) returning a sorted list of problems that stop a reader using new from reading data written with old:

  • "added_without_default:<field>" for a field in new but not old with no default;
  • "type_changed:<field>" for a field in both whose "type" differs.

Removing a field is fine for backward compatibility (the new reader ignores it). Return [] when compatible.

Hint

Loop over new.items(). A field missing from old needs "default" in spec.

Show solution
def backward_problems(old, new):
    problems = []
    for name, spec in new.items():
        if name not in old:
            if "default" not in spec:
                problems.append(f"added_without_default:{name}")
        elif old[name]["type"] != spec["type"]:
            problems.append(f"type_changed:{name}")
    return sorted(problems)

Write load_star(conn, sales). sales is a list of dicts with keys sale_id, product, category, and amount. Create dim_product(product_key INTEGER PRIMARY KEY, name TEXT UNIQUE, category TEXT) and fact_sale(sale_id TEXT PRIMARY KEY, product_key INTEGER, amount REAL), insert one dimension row per distinct product (keys assigned by SQLite), and one fact row per sale referencing the right product_key. Then write revenue_by_category(conn) returning a list of (category, total) tuples ordered by total descending.

Hint

Insert products with INSERT OR IGNORE, then insert facts with INSERT INTO fact_sale SELECT ?, product_key, ? FROM dim_product WHERE name = ?.

Show solution
import sqlite3

def load_star(conn, sales):
    conn.executescript("""
    CREATE TABLE dim_product (product_key INTEGER PRIMARY KEY, name TEXT UNIQUE, category TEXT);
    CREATE TABLE fact_sale (sale_id TEXT PRIMARY KEY, product_key INTEGER, amount REAL);
    """)
    conn.executemany("INSERT OR IGNORE INTO dim_product (name, category) VALUES (?, ?)",
                     [(s["product"], s["category"]) for s in sales])
    conn.executemany("INSERT INTO fact_sale SELECT ?, product_key, ? FROM dim_product WHERE name = ?",
                     [(s["sale_id"], s["amount"], s["product"]) for s in sales])
    conn.commit()

def revenue_by_category(conn):
    return conn.execute("""
        SELECT p.category, SUM(f.amount) AS total
        FROM fact_sale f JOIN dim_product p ON p.product_key = f.product_key
        GROUP BY p.category ORDER BY total DESC
    """).fetchall()

Mini-project: a ride-sharing star schema

Mini-projectRideCo warehouse from CSVs

RideCo exports three CSVs from its app database: rides.csv (ride_id, rider_id, driver_id, pickup_zone, dropoff_zone, requested_at, distance_km, fare), riders.csv (rider_id, name, segment), and drivers.csv (driver_id, name, vehicle_type, city). Design a star schema and load it into sqlite3.

pixi init rideco && cd rideco
pixi add python pandas pytest
pixi run python make_data.py     # write the three CSVs with a fixed random seed
pixi run python load.py          # build rideco.db
pixi run pytest
  1. Write the grain of fact_ride in one sentence at the top of load.py.
  2. Generate data with random.Random(42): 50 riders, 20 drivers, 2 000 rides over 30 days, 8 zones.
  3. Create dim_date, dim_rider, dim_driver, dim_zone (with integer surrogate keys) and fact_ride (pickup and dropoff zone keys are both role-played by dim_zone).
  4. Load with csv or pandas plus executemany, inside one transaction.
  5. Answer three questions in SQL: revenue by weekday; average fare per km by vehicle type; the top 5 pickup → dropoff zone pairs.
  • The grain is documented and fact_ride has a primary key that enforces it
  • Every dimension has a surrogate key; facts store only keys and measures
  • Running load.py twice produces the same row counts (drop and recreate, or upsert)
  • A pytest test checks SUM(fare) in the warehouse equals the sum in rides.csv
  • The three analytics queries return sensible results

Stretch: make dim_driver SCD type 2 by loading a second drivers.csv where three drivers changed city, and show revenue by driver city as it was at ride time. Then export fact_ride to Parquet partitioned by year=/month= with pixi add pyarrow.

Quiz

Recap

Going further