Lesson 13 Schema Evolution & Lineage
Theme:
Lesson 13 · Slide 1

Lesson 13 — Schema Evolution & Lineage

Adding columns without downtime; full lineage end-to-end.

Data Engineering Module 5 Production
Lesson 13 · Slide 2

The problem with schema changes

Every production pipeline accumulates schema changes. Upstream teams add columns, rename fields, and split tables — often without warning. Two deployment patterns emerge:

The wrong way

  1. Change the schema
  2. Deploy the producer
  3. Wait for consumer errors to appear
  4. Scramble to fix

Consumers break in production. On-call at 3 am.

The right way

  1. Make a compatible change
  2. Deploy the producer
  3. Consumers upgrade on their own schedule
  4. Remove the old field after 30+ days

No outage. No scramble. No 3 am pages.

Lesson 13 · Slide 3

Three kinds of compatibility

ModeDefinitionSafe change pattern
Backward New code reads old data without error ADD column with a default value
Forward Old code reads new data without error Never REMOVE or RENAME an existing column
Full Both backward and forward hold simultaneously ADD nullable column (safe in both directions)

The gharchive pipeline targets full compatibility throughout its schema history. The data_contract.yml declares this as a guarantee to all consumers.

Lesson 13 · Slide 4

Safe changes vs unsafe changes

Safe — no consumer break

  • ADD nullable column
  • ADD column with default value
  • Widen a type (int32 → int64)
  • Mark a column deprecated (but keep it)

These can deploy with zero coordination. Consumers see new data; old code ignores the new column.

Unsafe — needs migration

  • RENAME a column
  • REMOVE a column
  • Narrow a type (int64 → int32)
  • Change the semantic meaning of a field

These require a dual-write window, a consumer audit, and a planned removal date.

Lesson 13 · Slide 5

The “add a column” walkthrough

The sample pipeline demonstrates three safe additive schema versions:

VersionColumnsChangeCompatible?
v1 id, actor, repo, created_at Baseline —
v2 v1 + payload_size ADD nullable int64 Full
v3 v2 + event_type ADD string with default "unknown" Full

Both v2 and v3 deploy with zero downstream changes. A v1 consumer reading v3 data simply ignores payload_size and event_type.

Lesson 13 · Slide 6

Data contracts: the YAML deploy gate

A data contract is a YAML the producer publishes. It declares: here is my schema; here is my deprecation policy; here is who owns this. PRs that change the schema must also update the contract — and the contract test must pass before merge.

version: v3
namespace: gharchive-pipeline
dataset: events
fields:
  - { name: actor,      type: string, required: true, added_in: v1 }
  - { name: event_type, type: string, required: true, added_in: v3, default: unknown }
compatibility:
  backward: true
  forward:  true
deprecation_policy:
  removal_notice_minimum_days: 30

The full contract lives at sample-pipeline/schema_evolution/data_contract.yml.

Lesson 13 · Slide 7

Testing for compatibility in CI

def test_v3_reads_v1_consumer_safe():
    """A v1 consumer reading v3 must still find v1 columns (backward compat)."""
    v3 = pq.read_table(FIXTURES / "v3.parquet").select(
        ["id", "actor", "repo", "created_at"])
    assert v3.num_rows == 10
    assert v3.column_names == ["id", "actor", "repo", "created_at"]

def test_v1_reads_v3_producer_safe():
    """v1 columns are a strict subset of v3 (forward compat)."""
    assert _schema("v1.parquet").issubset(_schema("v3.parquet"))

The full suite at schema_evolution/test_compatibility.py runs 7 tests in CI. It catches any schema change that would break a v1 consumer before the PR is merged. Add new tests alongside every new version.

Lesson 13 · Slide 8

Lineage — beyond job-level events

Lesson 12 emitted job-level START and COMPLETE events to Marquez. Lesson 13 makes lineage queryable end-to-end: which dbt model produced this column? Which downstream table reads from it? What changed in the last deploy?

Screenshot placeholder: Marquez column-lineage view — actor traced from raw_events through dim_events to daily_counts.

Lesson 13 · Slide 9

Column-level lineage in dbt

version: 2
models:
  - name: dim_events
    columns:
      - name: event_type
        description: "Top-level GitHub event type (push, pull_request, ...)"
        meta:
          contract:
            added_in: v3
            default: unknown

dbt emits column-level facets to OpenLineage when dbt-openlineage is in the project. Marquez stitches these across operators — you see which raw column flows into which mart column, without writing any custom lineage code. The meta.contract block ties the dbt model column back to the data_contract.yml version.

Lesson 13 · Slide 10

“Who broke prod” via lineage

When a downstream dashboard goes red, the debugging flow becomes a graph traversal:

  1. Find the broken dataset in Marquez.
  2. Query for the most recent run that wrote to it.
  3. Follow that run’s inputs backward until you reach a human-authored change.

Marquez REST API query:

curl "http://localhost:5000/api/v1/lineage?nodeId=dataset:gharchive-pipeline:dim_events"

The response lists every upstream job and run. The most recent run with a schema diff facet is usually the culprit. No log-grepping required — the lineage graph is the audit trail.

Lesson 13 · Slide 11

Streaming: the next frontier

This course stops at batch. Streaming (Kafka, Flink, Spark Structured Streaming) lets you push freshness from minutes to seconds, but it adds exactly-once and watermark concerns we won’t cover here. The OpenLineage and data-contracts patterns from this lesson transfer directly to streaming pipelines — just the engines change. Schema registries (Confluent, Apicurio) play the same role as data_contract.yml in a batch pipeline: they are the deploy gate between producers and consumers.

Lesson 13 · Slide 12

What we’ll do in lab

By the end of lab you have a living v4 contract, a green test suite, and a lineage event that records the evolution.

Lesson 13 · Slide 13

What changed in the sample pipeline

 sample-pipeline/
+  schema_evolution/
+    __init__.py
+    generate_fixtures.py   # run once; NOT committed as fixture output
+    test_compatibility.py  # 7-test pytest suite; runs in CI
+    data_contract.yml      # producer/consumer schema guarantee
+    fixtures/              # v1/v2/v3 Parquet; generated locally
   observability/           # unchanged from L12
   airflow/                 # unchanged from L12
   dbt/                     # unchanged from L12

The actor column and all upstream schema contracts from Modules 1–4 are preserved. No existing tests break. The only additions are the schema_evolution/ tree.

Lesson 13 · Slide 14

Recap & what’s next

Coming up: Lesson 14 — cost attribution, table-level access controls, and the GDPR story.
1 / ?