MetadataENGINE-SPECIFICTOOL-SPECIFICSIMPLIFIED

Column-Level Lineage

orders.amount to revenue to monthly_revenue. Much harder to produce than table-level lineage, and the only granularity that answers the question an incident actually asks.

What actually happensHow to build itCan I trust it?

Who needs this, what one row is, and why the obvious build breaks

Every lesson starts from the consumer, because designing from the source outward is this domain's characteristic mistake.

The question

This one column is wrong. Which upstream columns did it come from, and which of them do I have to check?

Who needs this

The engineer already three hops into an upstream walk, who has narrowed the incident to a table with sixty columns and needs to narrow it to the three that feed the broken measure. Also the privacy reviewer tracing where a single personal-data field ended up (PII in Pipelines).

What one row is

One edge is one column-to-column derivation within one job: this output column was computed from those input columns by that expression. The expression matters as much as the endpoints — revenue = amount * rate and revenue = amount produce identical endpoint pairs and very different investigations.

The obvious build

Rely on table-level lineage and read the SQL when you need column detail. The graph tells you which model to open; a human opens it and follows the expression by eye. For a small platform this is completely reasonable and often faster than any tool.

Why it breaks

The model is four hundred lines of common table expressions and the column you care about is assembled across three of them. Reading it by eye takes twenty minutes, and the incident has five more hops (Subqueries, CTEs, EXISTS, UNION, CASE).

How it breaks with real data
  • The model is four hundred lines of common table expressions and the column you care about is assembled across three of them. Reading it by eye takes twenty minutes, and the incident has five more hops (Subqueries, CTEs, EXISTS, UNION, CASE).
  • The model selects * from a staging model, so the column you are chasing has no visible declaration anywhere in the file — it simply appears in the output because the upstream had it.
  • Table-level lineage says fct_orders depends on dim_customer. It does — for the country dimension, and not for the revenue column at all. You spend an hour checking a dependency that could not possibly have caused the symptom (Data Lineage).
  • A column is dropped upstream. Table-level impact analysis flags every downstream model as affected, which is technically true and operationally useless: forty teams are notified, thirty-eight of them do not use the column, and the next notification is ignored (Impact Analysis).
  • The privacy question — "where does email go" — cannot be answered at table granularity at all, because the answer "everywhere stg_users goes" is not an answer anyone can act on (Data Classification).
SourceIngestionRawTransformationValidationStorage ModelServingConsumerObservability

What is actually happening

  • Column-level lineage is produced by resolving the expression tree of every query a job runs: parse the SQL, bind each output column to the input columns its expression references, and resolve those names through the query's own scopes — aliases, subqueries, common table expressions, joins (Tokens, Parse Tree, AST).
  • That resolution is the reason it is hard. Table-level lineage needs only the FROM and the target; column-level needs full name resolution against the real schemas, which means the parser has to know what every referenced table contains at the moment the query ran (How a Query Executes: Planner and Executor).
  • SELECT * is the canonical defeat. The set of output columns is not in the text; it is whatever the upstream had that day. A generator can resolve it if it knows the upstream schema at that moment and cannot if it does not, which is exactly why star selects are discouraged in models and why the guide bans "SELECT * is fine in analytics" as a claim.
  • Three more classes defeat parsers routinely: dynamically constructed SQL, where the query text does not exist until runtime; user-defined functions and stored procedures, whose bodies are a second parsing problem; and anything computed outside SQL — a Python transformation, a BI-layer calculated field (Two Dashboards, Two Numbers).
  • The payoff is that the graph becomes narrow instead of wide. During an incident a table-level walk widens at every hop — each table has many parents — while a column-level walk narrows, because most of a table's parents contribute nothing to the column in question. Narrowing is what makes a walk finish (Debugging a Data Incident).
  • The second payoff is impact precision. A column-level graph turns "this change affects forty models" into "this change affects two models and one dashboard tile", which is the difference between a notification people read and one they filter (Impact Analysis).

One column, four hops

The chain below is the column-level view of the same pipeline the previous lesson walked at table level. It is deliberately narrow: this is one measure, from the source field it came from to the aggregate an executive reads.

Compare it with the table-level version. At table level, fct_orders has several parents and each is a candidate. At column level, revenue has exactly three, and one of them — the FX rate, joined as of the order date — is where a surprising share of revenue incidents actually live (Slowly Changing Dimensions).

The narrowing is the point. A table walk widens as it goes upstream, because every table has more parents than the last; a column walk narrows, because most of a table's parents contribute nothing to the column you care about. That is why column-level lineage changes how long an incident takes rather than merely how it is documented.

`orders.amount` → `revenue` → `monthly_revenue`
  1. `orders.amount_minor` (source)

    holds The order value in minor units of the order's own currency, as written by the application.

    could corrupt A currency stored separately from the amount, so the number is meaningless without its partner column — and the partner is easy to lose in a projection (Projection Pushdown).

    ↑ reads from
  2. `stg_orders.amount_minor`

    holds The same value after typing and cleaning.

    could corrupt A cast from text that produces null rather than an error on a malformed value, turning a data error into a silent zero (Nullability & Defaults).

    ↑ reads from
  3. `dim_fx.rate` (joined as of `order_date`)

    holds The conversion rate that applied on the day of the order.

    could corrupt Joining the *current* rate instead of the rate as of the order date — which reconciles perfectly against the source and restates every historical month (SCD Type 2 in Practice).

    ↑ reads from
  4. `fct_orders.revenue`

    holds amount_minor * rate, expressed in the reporting currency, gross of refunds.

    could corrupt Applying the rate twice after a re-run that is not idempotent; a join that fans out and multiplies the measure (Idempotent Data Pipelines).

    ↑ reads from
  5. `revenue_daily.revenue`

    holds Sum of fct_orders.revenue per country-day.

    could corrupt Summing over a period that is still open, so the most recent day is genuinely incomplete and merely looks low (Late-Arriving Data).

    ↑ reads from
  6. `monthly_revenue` (dashboard measure)

    holds Sum over the daily rows, filtered by whatever the BI layer applies.

    could corrupt A BI-layer filter or blend that no model, test or lineage edge can see — the last hop, and the least instrumented one (Two Dashboards, Two Numbers).

Three of these six nodes can produce a wrong monthly_revenue while every table-level check passes and every row count reconciles. That is the population column-level lineage exists to make searchable.

Why it is so much harder to produce

ENGINE-SPECIFICWhich of these five actually bites depends on the engine and dialect: some engines expose resolved column bindings for executed statements, which eliminates the parser-trailing problem entirely, while others expose only the statement text and leave every case above to an external resolver.

Table-level lineage needs the FROM clause and the write target. Column-level lineage needs the resolved binding of every output expression, which means the generator must reimplement a meaningful part of the engine's name resolution — scopes, aliases, common table expressions, natural joins, and whatever the dialect does with unqualified names (Tokens, Parse Tree, AST).

The query below is small and already exercises most of the hard cases. revenue is derived from two columns in different tables through a multiplication. order_month is derived by a function. country passes through a join that contributes rows rather than values. And the last common table expression selects *, which means the output column set is not in the text at all — it is whatever stg_orders happened to contain when the query ran.

A generator that cannot resolve the star has two options and both are bad if unstated: emit nothing for those columns, which reads as independence, or emit an edge from the whole upstream table, which reads as everything depending on everything. Reporting "unresolved" is the only honest third option, and it is the one most tools do not offer.

What defeats a column-lineage generator
TriggerSymptomCauseResponse
A model selects * from its parent.Downstream columns have no resolvable parent; the branch below goes dark.The output column set exists only at runtime, resolved against the parent's schema at that moment.Ban star selects in anything consumers depend on, and resolve the remainder against a schema snapshot taken at run time (Model Layering).
SQL is built as a string at runtime.The job has inputs and outputs but no expression-level edges at all.There is no query text to parse until the query runs, and often no record of it afterwards.Capture the executed statement from the engine's query history and resolve that, rather than the source that generated it (Follow the Query).
A user-defined function computes the column.Edges stop at the function call; the real parents are inside the body.Resolving the body is a second parsing problem, in a possibly different language.Emit the edge at the function boundary and mark it as opaque, so an investigator knows to open the function rather than assuming it is a passthrough.
The engine or dialect is upgraded.Edge counts drop; nothing alerts; the graph looks sparser and more decisive than before.The external parser trails the engine's grammar and now fails on constructs it used to handle.Publish resolution rate as a monitored metric, not edge count, and alert on a drop (Data Observability).
The metric is computed in the BI tool.The graph terminates at the warehouse column, one hop short of the number in the complaint.The BI layer has its own expression language and is not instrumented.Integrate the BI tool's own metadata, or move the calculation into a governed metrics layer where it can be seen (The Metrics Layer).
Every hard case for a column-lineage resolver, in twenty lines
1with base as (
2 -- SELECT *: the output column set is not in this text.
3 select * from analytics.stg_orders
4),
5fx as (
6 select currency, rate_date, rate
7 from analytics.dim_fx
8)
9select
10 b.order_id,
11 b.customer_id,
12 b.amount_minor * f.rate as revenue, -- two parents, one expression
13 date_trunc('month', b.order_date) as order_month, -- one parent, through a function
14 c.country as country, -- one parent, via a join
15 total_with_tax(b.amount_minor) as amount_taxed -- UDF: parents are inside the body
16from base b
17join fx f
18 on f.currency = b.currency
19 and f.rate_date = b.order_date -- influences rows, not values
20left join analytics.dim_customer c
21 on c.customer_id = b.customer_id
22where b.status <> 'cancelled' -- influences rows, not values

Two distinct kinds of dependency are visible here and a usable graph must separate them. amount_minor and rate contribute to the *value* of revenue. status and rate_date contribute to *which rows exist*. A filter bug and an expression bug produce completely different symptoms, and a graph that renders both as a plain edge sends the investigator to the wrong place.

What it buys you at 03:00

The argument for column-level lineage is not completeness or tidiness. It is that the upstream walk terminates. With table-level lineage the walk fans out — six parents, each with four parents — and an engineer under pressure has to guess which branch to take. With column-level lineage the same walk is a line.

The block below is the practical form: not a graph rendering, but the ancestors of one column, listed, with the expression at each hop. This is what an investigator wants pasted into an incident channel, and it is a text answer to a text question.

The second thing it buys is the reverse direction. When the question is "we are dropping amount_minor, who breaks", the table-level answer names every model that reads stg_orders and the column-level answer names the two that read that column. The first gets ignored; the second gets acted on (Impact Analysis).

Walking upstream at table granularity
The dashboard reads `revenue_daily`. `revenue_daily` reads `fct_orders`. `fct_orders` reads `stg_orders`, `dim_customer`, `dim_fx` and `dim_product`. Each of those reads two or three more. The investigator picks a branch on instinct and reads SQL until something looks wrong.
Walking upstream at column granularity
The broken measure is `monthly_revenue`. Its ancestors are seven columns in five datasets, listed with the expression at each hop, and two of them are marked as row-influencing rather than value-influencing. The investigator checks the FX join first because it is the only hop that involves a date-dependent lookup.

An upstream walk terminates when each hop reduces the candidate set. Table edges do not reduce it — a table's parents are all still candidates — so the walk widens and the engineer substitutes intuition for the graph. Column edges reduce it at every hop, which turns an open-ended search into a finite checklist and is the entire operational value of the extra generation cost.

monthly_revenue                      (BI measure: SUM over filtered days)
└── revenue_daily.revenue            SUM(fct_orders.revenue) GROUP BY country, day
    └── fct_orders.revenue           b.amount_minor * f.rate
        ├── stg_orders.amount_minor  CAST(raw.amount AS NUMERIC)
        │   └── orders.amount        source column, operational database
        └── dim_fx.rate              joined ON currency, rate_date = order_date
            └── raw_fx_feed.rate     source column, external provider

  row-influencing (not value-influencing):
    fct_orders    WHERE status <> 'cancelled'      <- stg_orders.status
    revenue_daily GROUP BY country                 <- dim_customer.country

How to build it

Most important first.

  • Get it from the engine wherever the engine will give it to you. Some query engines expose the resolved column bindings of a statement, and a binding produced by the engine that ran the query is authoritative in a way a separate parser never is (Query Engines).
  • Ban SELECT * in anything a consumer depends on, and enforce it in review. This is usually argued as a stability rule; it is also the single change that most improves lineage coverage (Model Layering).
  • Record the expression, not only the endpoints. "Derived from amount_minor and rate by multiplication" is what lets an investigator decide in seconds whether this hop could produce the observed symptom.
  • Treat unresolved columns as a first-class, visible state. A generator that reports "could not resolve" for a job is telling you something true; one that silently emits fewer edges is teaching false confidence (Data Lineage).
  • Design the interface around narrowing: start at the broken column, show only its parents, and let the user step. A full column-level graph rendered at once is unreadable at any real platform size.
  • Wire classification propagation to it. If users.email is tagged, every column derived from it should inherit the tag by default and require an explicit assertion to lose it — that is the mechanism behind derived-data governance (Data Classification).

What this actually promises

Naming the guarantee you do not have is worth more than naming the one you do — everything downstream inherits the weakest promise in the chain.

  • A column edge guarantees that the output column's expression referenced those input columns, for the query that produced it. It does not guarantee the value depended on them meaningfully — a column referenced in a CASE branch that never fires still produces an edge.
  • Coverage is guaranteed only for statements the resolver understood. Unresolved statements produce silence, and silence is indistinguishable from independence, which is the most dangerous property of this whole subject.
  • Nothing guarantees that a column's *meaning* survived the derivation. revenue derived from amount is an edge whether the conversion was correct, wrong, or applied the wrong day's rate (Semantic Changes).
  • Edges through a user-defined function are guaranteed only at the function boundary unless the function body is also resolved: you learn that the output depends on the inputs, not which input drives which part.

Can I trust it?

A green pipeline is evidence that code ran. These four fields are the evidence that the data is right.

The check that would catch this
  • Measure resolution rate: the share of statements the generator fully resolved, per job. It is the honest coverage number, and a platform that reports edge counts without it is reporting a number that can only go up.
  • Reconcile against the columns that actually exist: every output column of a model should have at least one inbound edge or be an explicit literal or constant. A column with no parent and no literal is an unresolved expression wearing a disguise.
  • Both miss the semantic layer entirely. A perfectly resolved edge from amount_minor to revenue says nothing about whether the multiplication used the right rate on the right date, which is the actual cause of most revenue incidents (Two Dashboards, Two Numbers).
Freshness
  • Column lineage must be regenerated whenever a model changes, because a one-line edit to a SELECT list changes the graph. Nightly regeneration means the graph describes yesterday's code during the incident caused by today's deploy.
  • For SELECT * paths the graph is only as correct as the schema snapshot used to resolve it, so schema harvest freshness becomes lineage correctness rather than merely lineage metadata (Metadata: Technical, Operational and Business).
  • Historical column lineage answers "which columns fed this partition when it was written", which is a different graph from today's and the one a backfill investigation actually needs (Validating a Backfill Before You Publish).
When the schema or meaning changes
  • Renaming a column rewrites every edge that touched it. Generated lineage handles this automatically and any hand-maintained mapping does not, which is the same argument as at table level but with far more edges to get wrong (Schema Evolution).
  • Adding a column is the only genuinely safe change at this granularity, and only if nothing selects * downstream — with a star select, adding a column upstream changes a downstream table's schema without touching its code (Backward Compatibility).
  • A change to an expression — the same input columns, a different formula — leaves the graph identical and changes every number. Column lineage will not catch it, and this is exactly the class the semantic-changes lesson exists for (Semantic Changes).
How to re-run this safely
  • The graph is derived and rebuildable from query history and model source, bounded by the retention of both. Rebuilding is cheap enough that a bad generator upgrade is not an incident.
  • During a repair, column lineage tells you the minimal downstream set: recompute only the models whose affected columns actually descend from the corrected one, rather than every model that touches the table (Planning a Backfill).
  • The minimal set is a trap if resolution was incomplete. Combine it with the table-level set and reconcile: recompute the column-level set, then verify against the table-level set that nothing in the difference actually moved (Reconciliation).

What can go wrong

Failure modes
  • The generator degrades quietly after a dialect change or an engine upgrade, emitting fewer edges. Nothing alerts, and the graph becomes optimistically sparse — dependencies look absent that are merely unparsed.
  • A star select in one intermediate model erases column identity for everything downstream of it, so a single unfixed model breaks lineage for a whole branch.
  • Edges are produced for columns referenced in filters and joins as well as in the output expression, which is correct and floods the graph — so the interface must distinguish "contributed a value" from "influenced which rows".
  • The BI layer defines a calculated field on top of the warehouse column and the graph stops one hop short, at the exact hop where the reported number was actually computed (Two Dashboards, Two Numbers).
  • Classification propagation is enabled without a way to assert de-identification, so every derived column inherits the strictest tag and access requests become unmanageable (Data Masking, Tokenisation & Encryption).
Misreads
  • "Column-level lineage is table-level lineage with more detail." It is a different generation problem: table edges come from the FROM clause, column edges come from full name resolution against live schemas. Tools that do the first well often cannot do the second at all (Data Lineage).
  • "No edge means no dependency." No edge means no *resolved* edge. Until you know the resolution rate, absence is not evidence.
  • "It tells us which column broke." It tells you which columns could have contributed. The narrowing is enormous and it is still a narrowing, not a diagnosis (Debugging a Data Incident).
  • "We can skip it because our models are simple." The models that are simple today are the ones with SELECT * in them, and a star select is precisely what makes the graph unresolvable later (Model Layering).
Privacy, retention and access
  • This is the mechanism that makes derived-data privacy tractable. Without column lineage, the answer to "where did this personal field end up" is a manual audit; with it, it is a reachability query (PII in Pipelines).
  • Tag propagation needs an escape hatch that is itself governed: someone must be able to assert that an aggregate is no longer personal data, and that assertion must be recorded, attributable and reviewable (Data Minimization).
  • A deletion request is answerable at column granularity in a way it never is at table granularity — you can enumerate exactly which downstream columns carry the subject's data and which merely descend from the same table (Deletion Requests).

Operating it

How you see it in production
  • Resolution rate per job and per dialect, trended. A step change after a deploy is a generator regression, and it is invisible in any edge-count metric.
  • Count of models containing SELECT * in a path a consumer depends on. It is a lineage-coverage metric disguised as a style metric (Model Layering).
  • Fan-in per output column — how many upstream columns feed it. Columns with very high fan-in are the ones whose incidents take longest and are worth simplifying (The Transformation DAG).
  • For governance: the set of columns reachable from each classified source column, recomputed on every graph rebuild (Data Classification).
What changes at 10x and 100x
  • At ten times the model count, whole-graph rendering is already impossible and the walk-one-hop interface is the only usable one.
  • At a hundred times, precomputed reachability per classified column becomes necessary for governance queries, because computing it on demand across the whole graph is a real analytical workload (Directed Graph).
  • Column count scales this faster than table count does. A platform that widens its tables — adding attributes rather than tables — grows its column graph superlinearly relative to its table graph.
What drives cost here
  • Generation cost scales with statement count and statement complexity, not with data volume. A platform with thousands of small models is expensive to resolve and cheap to run; the two costs move in opposite directions (The Transformation DAG).
  • Storage grows roughly with columns times edges rather than tables times edges, which is a large multiplier — a hundred-column table participates in far more edges than its table node suggests.
  • Query cost at read time is the graph traversal, and it is the cost that matters because it happens during incidents. Precomputing ancestors and descendants per column trades storage for a response time an engineer under pressure will accept.
What this approach costs
  • Column-level lineage costs a parser per dialect, an ongoing fight with SELECT * and dynamic SQL, and a permanent coverage gap you have to measure and publish. It buys incident walks that narrow instead of widening, and impact notifications precise enough that people read them.
  • Recording expressions makes the graph far more useful and far larger, and exposes transformation logic to anyone who can read the catalog — which is occasionally a disclosure question in its own right.
  • Propagating classification through the graph is the only scalable way to govern derived data and produces over-tagging by default. Without an explicit de-identification assertion, every aggregate of a sensitive column stays sensitive forever (Data Minimization).

Where this applies

Almost nothing here is universal. These labels say what each claim is specific to, and where a different engine, format, warehouse or scale would differ.

  • ENGINE-SPECIFICResolution depends on the dialect and on whether the engine exposes its own resolved bindings. An engine that publishes the column bindings of an executed statement gives lineage that is authoritative; a separate parser reimplementing the dialect will always trail it, especially on functions and window clauses.
  • TOOL-SPECIFICTransformation frameworks that model columns explicitly can emit column edges directly, while frameworks that only track model dependencies cannot — the same platform can therefore have excellent table lineage and no column lineage at all, which surprises people mid-incident.
  • SIMPLIFIEDThe single chain in this lesson has one parent per hop. Real derivations are many-to-one with filters and joins contributing rows rather than values, and a usable interface has to distinguish those two kinds of contribution or the graph is unreadable.

Where the depth lives

This domain teaches how data moves and how you know it arrived intact. It hands the rest off by name.

Domains that do not exist yet
  • Distributed Systems owns the general shape of this idea — reconstructing a causal chain from per-hop records rather than from a global view — which is the same reasoning that produces request tracing, applied to columns instead of spans.