Skip to main content
Data Engineering · lesson 16 of 23
Open the Data Engineering page →
Data Engineering advanced Lesson 16 of 23

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

data-engineering lineage governance observability

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.

Frequently Asked Questions

What is data lineage?
A record of where every table (and ideally every column) in a pipeline's output actually came from — which sources fed it, and what transformations were applied along the way. It answers 'where did this number come from' and 'what breaks if I change this source' without anyone reverse-engineering the pipeline code.
Column-level vs. table-level lineage — what's the difference in practice?
Table-level lineage says gold.daily_revenue depends on silver.orders and bronze.customers — useful for impact analysis, cheap to capture. Column-level lineage says specifically that the revenue column depends on silver.orders.amount, not on any of silver.orders' other columns — far more precise, and what this lesson builds, but more expensive to instrument and maintain correctly.
Do I have to build lineage tracking by hand like this lesson does?
No — this lesson builds a minimal tracker to show the underlying data structure (a dependency graph plus a column map) that every real lineage tool is built on. In production, tools like OpenLineage, dbt's built-in lineage graph, or a cloud warehouse's native lineage feature capture this automatically from query logs or pipeline metadata instead of manual recording.
Why does column-level lineage matter more than table-level for debugging?
A table can have twenty columns from five different upstream sources. Table-level lineage tells you all five sources might be involved when a number looks wrong; column-level lineage tells you which one actually is, cutting a five-way investigation down to one.