Datatrail
Blog / Fundamentals 9 min read

Column-Level Lineage Explained: Why Table-Level Is Not Enough

Last updated June 2026 · Datatrail

Lineage map
Lineage mapped from query history. Read-only connection.
0

Read-only connection. Datatrail never moves or mutates your data.

Column-level lineage traces how an individual column derives from specific source columns, rather than just which tables depend on which; table-level lineage tells you fct_orders reads from stg_stripe_charges, while column-level lineage tells you that orders.revenue specifically comes from stg_stripe_charges.amount. Table-level is not enough because the unit that actually breaks a dashboard is a column, and a single renamed or dropped column can silently break reports that table-level lineage would tell you are perfectly fine.

This is the gap between knowing two tables are connected and knowing what feeds a number. For data engineers reviewing a change, that distinction is the difference between a confident merge and a 9am incident.

What table-level lineage shows, and where it goes blind

Table-level lineage is the dependency graph most tools produce first because it is the easiest to derive. Parse a query's referenced tables and its destination table, and you can draw an edge: stg_stripe_charges feeds fct_orders, which feeds dim_revenue_daily, which feeds the finance dashboard. That graph is genuinely useful for orientation.

But consider what it cannot answer. Suppose stg_stripe_charges has forty columns and you rename one of them, amount, to amount_cents. Table-level lineage says fct_orders still depends on stg_stripe_charges, and it does. The edge is unchanged. Everything looks fine. Meanwhile fct_orders.revenue, which selected amount, is now either erroring or silently null, and the three dashboards built on it are broken. Table-level lineage cannot see this because it never tracked which column fed which. The relationship it models is too coarse to represent the failure.

This is the core problem: failures happen at the column, but table-level lineage only reasons about tables.

What actually feeds a number

When a stakeholder points at a figure on a dashboard and asks "where does this come from," the honest answer is a column-level path. Take a single metric, monthly revenue. Its real lineage might be:

  1. stg_stripe_charges.amount, a raw cents value from the Stripe source.
  2. transformed into int_charges.amount_usd by a divide-by-100 and currency join.
  3. summed into fct_orders.revenue grouped by order.
  4. rolled up into dim_revenue_daily.daily_revenue in the model the dashboard reads.

Every step is a column-to-column transformation. Table-level lineage flattens all of this into "four tables are connected." Column-level lineage preserves the chain, so you can answer not just "which tables" but "this exact source field, through these exact transformations." That is what column-level lineage is for: tracing a number to its origin field by field.

How one renamed column breaks three dashboards

Let us make the failure concrete. A teammate refactors the staging layer and renames stg_stripe_charges.amount to stg_stripe_charges.amount_cents for clarity. They update the immediate model and the tests pass locally because they fixed the one reference they could see. They merge.

What they could not see is that three downstream consumers read the derived revenue column:

  • The executive finance dashboard, reading dim_revenue_daily.daily_revenue.
  • A churn analysis model that joins revenue per customer.
  • A reverse-ETL sync pushing account revenue into the CRM.

With table-level lineage, the reviewer of that pull request sees that stg_stripe_charges has downstream tables, but nothing flags amount specifically, so the rename looks safe. With column-level lineage, the reviewer sees that the renamed column feeds revenue, which feeds those exact three assets. The break is visible before merge, not after. This is precisely where impact analysis turns a coarse dependency graph into a usable blast radius: it lists the downstream columns and dashboards a specific change touches.

Notice how the cost compounds. The reverse-ETL sync does not error loudly; it pushes nulls into the CRM, so a sales team starts seeing zero revenue on accounts and nobody connects it to a staging rename three layers up. The churn model silently drops the broken join key and skews its segments. Hours of triage later, someone finally runs the query history and finds the rename. Column-level lineage collapses that investigation into a single view at review time, because the relationship between amount and every number derived from it was tracked all along rather than reconstructed after the fact.

Why column-level lineage is hard to build by hand

If column-level lineage is so much more useful, why does table-level dominate? Because deriving it requires actually parsing SQL semantics, not just listing referenced tables. To know that fct_orders.revenue comes from stg_stripe_charges.amount, a lineage engine has to:

  • Resolve SELECT * expansions against the live schema of each source.
  • Track aliases and CTEs, so WITH c AS (SELECT amount AS amt FROM ...) maps amt back to amount.
  • Follow columns through joins, unions, window functions, and aggregations.
  • Handle every dialect quirk in Snowflake, BigQuery, Redshift, and Postgres.

Warehouse metadata like Snowflake's OBJECT_DEPENDENCIES or BigQuery's audit logs gives you table edges almost for free, but column resolution is the part that requires a real parser. dbt docs gets you partway within the dbt boundary, but stops at BI tools and anything outside the project. This is why many teams have table-level lineage and assume it is enough until the first silent column break teaches them otherwise. If you are working in one warehouse specifically, the mechanics differ: see how to build Snowflake data lineage from access history, or the same job in BigQuery.

Lineage that resolves to the column and stays current

Datatrail builds column-level lineage automatically by parsing your warehouse query history. It connects read-only, never moving or mutating data, and resolves the column-to-column paths through CTEs, joins, and SELECT * expansions so the lineage matches what your SQL actually does. Because it is rebuilt from live query logs, the graph stays current as models are refactored, instead of going stale the moment someone renames a column.

With that in place, the workflow flips from reactive to preventive. Before a change ships, you can see the exact downstream columns and dashboards it affects; after an incident, you can trace a wrong number to the source field that produced it. You can explore the full lineage map or see column-level lineage across dbt models on a live platform, and decide for yourself whether table-level was ever really enough.

See how your data flows, end to end

Connect your warehouse read-only and map lineage, freshness, and downstream impact before a change breaks a dashboard. Transparent pricing, no card to start.