Data Engineering · lesson 16 of 23
Beginner
8- 1 Data Engineering Fundamentals
- 2 File Formats: CSV, JSON, Parquet
- 3 Idempotency and Safe Retries
- 4 Batch Pipelines and Time Windows
- 5 Partitioning and Physical Layout
- 6 Batch vs. Streaming: The Real Trade-off
- 7 Data Lake vs. Data Warehouse vs. Lakehouse
- 8 Data Modeling Basics: Star Schemas and Denormalization
Intermediate
4Advanced
11- 13 Streaming: Exactly-Once Semantics
- 14 Schema Evolution and Data Contracts
- 15 Streaming Architecture: Lambda and Kappa
- 16 Data Lineage: Tracing a Column Back to Its Source
- 17 Data Governance: Catalogs, Classification, and Access Policy
- 18 Data Security and PII: Hashing, Masking, and Tokenization
- 19 Pipeline Observability: Tracing, Anomaly Detection, and Alerting
- 20 Cost Optimization: Measuring What Partition and Column Pruning Actually Save
- 21 CI/CD for Data: Gating Promotion with Real Tests
- 22 Disaster Recovery: RPO, RTO, and What a Backup Actually Loses
- 23 Data Platform Architecture: Wiring the Pieces Together
Also part of Data Engineering
9- + Your First Airflow DAG from Airflow
- + Kafka Topics, Partitions, and Offsets from Kafka
- + Spark Architecture: Driver, Executors, and Cluster Managers from Apache Spark
- + Models, ref, and the DAG from dbt
- + Delta Lake: The Transaction Log from Databricks
- + The Medallion Architecture from Databricks
- + File I/O, pathlib, and JSON in Python from Python
- + Error Handling in Python from Python
- + Terraform Fundamentals (IaC) from Terraform
Data Lineage: Tracing a Column Back to Its Source
Build a small lineage graph by hand, trace one output column back to the CSV it came from, and run impact analysis on a source change before it ships.
By SkillByExample · Published
Build a small lineage graph for a four-stage pipeline, then use it to trace one output column all the way back to its source file, and to answer “what breaks if this source changes?” before it actually does.
A tracker: a dependency graph plus a column map
class LineageTracker:
def __init__(self):
self.edges = {} # output_name -> {"op": str, "column_map": {out_col: [(source_table, source_col), ...]}}
def record(self, output, op, column_map):
self.edges[output] = {"op": op, "column_map": column_map}
def trace_column(self, table, column, depth=0):
pad = " " * depth
if table not in self.edges:
print(f"{pad}{table}.{column} <- source (no further lineage)")
return
edge = self.edges[table]
sources = edge["column_map"].get(column, [])
src_desc = ", ".join(f"{t}.{c}" for t, c in sources)
print(f"{pad}{table}.{column} <- {edge['op']} <- {src_desc}")
for src_table, src_col in sources:
self.trace_column(src_table, src_col, depth + 1)
def inputs_of(self, output):
return sorted({t for cols in self.edges[output]["column_map"].values() for t, _ in cols})
Recording lineage for a four-stage pipeline
lineage = LineageTracker()
lineage.record(output="bronze.orders", op="ingest (as-is)", column_map={
"order_id": [("landing/orders.csv", "order_id")],
"amount": [("landing/orders.csv", "amount")],
"customer_id": [("landing/orders.csv", "customer_id")],
})
lineage.record(output="bronze.customers", op="ingest (as-is)", column_map={
"country_code": [("landing/customers.csv", "country_code")],
})
lineage.record(output="silver.orders", op="filter status != pending", column_map={
"order_id": [("bronze.orders", "order_id")],
"amount": [("bronze.orders", "amount")],
"customer_id": [("bronze.orders", "customer_id")],
})
lineage.record(output="gold.daily_revenue", op="join + group by country, sum(amount)", column_map={
"revenue": [("silver.orders", "amount")],
"country_code": [("bronze.customers", "country_code")],
})
Tracing one output column back to its source
print("== tracing gold.daily_revenue.revenue back to its source ==")
lineage.trace_column("gold.daily_revenue", "revenue")
$ python lineage.py
== tracing gold.daily_revenue.revenue back to its source ==
gold.daily_revenue.revenue <- join + group by country, sum(amount) <- silver.orders.amount
silver.orders.amount <- filter status != pending <- bronze.orders.amount
bronze.orders.amount <- ingest (as-is) <- landing/orders.csv.amount
landing/orders.csv.amount <- source (no further lineage)
Four hops, and the chain is unambiguous: revenue in the gold table came from exactly one
column, amount, in exactly one source file. Column-level lineage is what makes that
“exactly” possible — table-level lineage would only be able to say gold.daily_revenue
depends on silver.orders and bronze.customers, without saying which one actually
produced revenue.
print("== tracing gold.daily_revenue.country_code back to its source ==")
lineage.trace_column("gold.daily_revenue", "country_code")
== tracing gold.daily_revenue.country_code back to its source ==
gold.daily_revenue.country_code <- join + group by country, sum(amount) <- bronze.customers.country_code
bronze.customers.country_code <- ingest (as-is) <- landing/customers.csv.country_code
landing/customers.csv.country_code <- source (no further lineage)
Same output table, different column, completely different source chain — revenue traces
through bronze.orders, country_code traces through bronze.customers entirely. A naive
tracker that just recorded “gold.daily_revenue depends on silver.orders and bronze.customers”
without per-column detail would get this indistinguishable, and a debugging session chasing a
wrong country_code value would have to check both upstream tables instead of just one.
Impact analysis: what breaks before it breaks
print("== impact analysis: what breaks if landing/customers.csv changes shape ==")
affected = [out for out in lineage.edges if "landing/customers.csv" in lineage.inputs_of(out)]
downstream = list(affected)
frontier = affected
while frontier:
next_frontier = [out for out in lineage.edges
if any(f in lineage.inputs_of(out) for f in frontier) and out not in downstream]
downstream.extend(next_frontier)
frontier = next_frontier
print(f"landing/customers.csv changing affects: {downstream}")
== impact analysis: what breaks if landing/customers.csv changes shape ==
landing/customers.csv changing affects: ['bronze.customers', 'gold.daily_revenue']
Walking the graph forward from a source instead of backward from an output answers the other
half of the lineage question: before renaming a column in landing/customers.csv, this says
exactly which two downstream tables need updating, and — because it’s column-level — that
bronze.orders and silver.orders are entirely unaffected, with no need to check them by hand.
Practice
1. Add a fifth table, `gold.order_count`, that also depends on `silver.orders.order_id`, and trace it.
gold.order_count.count <- count(*) <- silver.orders.order_id
silver.orders.order_id <- filter status != pending <- bronze.orders.order_id
bronze.orders.order_id <- ingest (as-is) <- landing/orders.csv.order_id
landing/orders.csv.order_id <- source (no further lineage)
A separate chain through the same intermediate tables, cleanly distinguished by column.
2. Run impact analysis for `landing/orders.csv` instead of `landing/customers.csv`.
landing/orders.csv changing affects: ['bronze.orders', 'silver.orders', 'gold.daily_revenue']
Three tables instead of two — landing/orders.csv feeds two intermediate layers before
reaching gold.daily_revenue, while landing/customers.csv only feeds one.
3. What does `trace_column` print for a column that was never recorded, e.g. `gold.daily_revenue.margin`?
gold.daily_revenue.margin <- join + group by country, sum(amount) <-
An empty source list — the op name prints, but nothing follows it and the recursion never
descends. This is the sharp edge of hand-maintained lineage: a column added to the pipeline
without a matching record() call produces silently incomplete lineage instead of an error.
4. Why does `inputs_of` deduplicate with a set instead of just listing every `(table, col)` pair's table?
gold.daily_revenue’s column map has two different columns coming from two different tables,
but a table could easily supply several columns to one output (as silver.orders does for
bronze.orders above) — listing it once per column would make inputs_of report
bronze.orders three times instead of once, corrupting the impact-analysis walk with
duplicate frontier entries.
Next: data governance — access control, cataloging, and policy enforcement on top of the tables this lineage graph describes.