Table lineage answers "what reads this table". Column lineage answers "what reads this field", through every rename and expression on the way. The difference decides the work: dropping one column in this project means checking forty tables with the first and two with the second. The eight use cases below run from dropping a column safely to making a data contract enforceable, and each one replaces a manual search with a graph traversal.
A column graph needs two inputs. Static parsing reads the code before it runs, and warehouse query history reads what already ran. The graph also has to cover data pipelines, the warehouse and the BI tool. A graph that stops at the warehouse edge misses the dashboards where wrong numbers get noticed.
You want to drop legacy_segment_code from dim_customer. First you need to know whether anything still reads that column. Table-level lineage gives you a list of every table downstream of dim_customer, forty tables in this project. The list does not say which of the forty read legacy_segment_code, because table-level lineage records that a table reads a table and nothing finer. So you open all forty models by hand and search each one for the column name.
Column-level lineage records a finer edge, which field in one model feeds which field in the next, through every rename and expression on the way. Ask it the same question and it names the two models that read legacy_segment_code. You check two files instead of forty. Every one of the eight column-level lineage use cases below makes that same trade. Table lineage gives you a list you still have to search. Column lineage gives you the names.
Same project, same question. Table lineage highlights forty tables. Column lineage highlights two.
The model names below follow the standard dbt convention, and the prefix marks each model's layer. A source is a raw table from a system like a CRM. A stg_ (staging) model cleans and renames one source. An int_ (intermediate) model joins staging models together. The fct_ and dim_ models at the end are what people query. A fact table such as fct_orders holds one row per order. A dimension such as dim_customer holds what those orders describe, like customers. A mart_ model is built for one report.
1. Deleting a Column Without Breaking a Dashboard
A column is safe to drop when nothing downstream reads it. Proving that nothing reads it is the hard part, because the proof has to cover every model in the project. Column-level lineage turns that proof into a list of consumer names you can check in minutes.
Take fct_orders, the fact table with one row per order, which carries 58 columns, one per attribute an order can have. Table lineage reports that 41 models read fct_orders. It cannot say which of those 41 models read which of the 58 columns, so a careful review of one column means opening all 41 files. The column graph answers the same question in one traversal, which means following the edges out from one column to every field that reads it. Run that traversal for all 58 columns and nine come back with zero consumers. Seven of those nine are _v1 leftovers from a rename nobody finished.
Without column edges, proving that a column is unused means reading every downstream model by hand. The safe answer to every delete request is then no. Dead columns then accumulate, because nobody can afford the proof. The table gets wider with each one, and every select * an analyst writes reads all of them. Delete the nine dead columns and every downstream scan reads less data at once.
2. Sizing a Pull Request Before Review
The review effort a pull request deserves should track how far the change reaches into the project. Diff size measures how many lines changed, which says nothing about reach. A column graph measures reach directly, by counting the fields downstream of the change.
Say two pull requests land the same morning. The first rewrites 180 lines inside mart_marketing_touch, a reporting mart at the end of its chain. That model is a leaf, so no other model reads it, and one dashboard is its only consumer. The second changes four lines in stg_orders, the staging model that cleans the raw orders feed. One of those four lines changes the data type of order_status, the field that records the state each order is in.
By diff size, the 180-line change looks like the risky one. Count downstream consumers instead and the ranking flips. The field order_status feeds 63 models and the finance revenue report. The 180-line diff feeds one dashboard tile. A reviewer who ranks by diff size spends the hour on the wrong pull request.
Put the downstream count on the pull request itself and the reviewer reads it before opening a file. Somebody can then give the four-line diff an hour and the 180-line diff five minutes. Our write-up of blast radius analysis covers the mechanics.
3. Triaging an Incident in Minutes Instead of Days
Tracing a wrong number on a dashboard back to its cause is a graph traversal when you hold column edges. Without column edges the same trace is a manual search through every model between the dashboard and the source.
Monday's revenue tile reads 12% under Friday's. The column graph shows the value reaching the tile through four hops:
- The tile shows
net_revenuefrom a Tableau extract. - The extract reads
mart_finance_daily.net_revenue. - That reads
fct_orders.net_amount. - That value is
gross_amountminusdiscount_amount.
The last commit that touched discount_amount is the first suspect.
Done by hand, you open the dashboard's query, then the view that query reads, then the models sitting behind that view. That walk crosses three tools, the BI tool, the warehouse console and the dbt repo, and each one needs its own login. Most of the elapsed time in an incident goes on the walk rather than on the fix. A column graph that spans all three tools answers all three steps from one place. Atlan makes the same point about agent-driven triage:
If lineage stops at the warehouse boundary, the agent finds the broken table and stops there.
Altimate's version of the workflow is root cause analysis for a workload spike.
4. Proving Where Personal Data Ends Up
A privacy review has to trace one field through every rename and every expression it passes through. Table-level lineage cannot follow a rename, because it records that a table reads a table and never which field became which. An auditor asks where this customer's email address goes. Table-level lineage can say that the source table sits upstream of thirty tables. It cannot say which of those thirty still carry the address, and the address is what the auditor asked about.
In this project stg_customers renames the raw CRM fields into cleaner names, and dim_customer builds the customer dimension the reports read from those staged fields. The manual version of the trace is a text search, and renames are what break it:
-- models/staging/stg_customers.sql
select
id as customer_id,
email as contact_email,
first_name
from {{ source('crm', 'customers') }}
-- models/marts/dim_customer.sql
select
customer_id,
contact_email as primary_contact,
concat(first_name, ' (', contact_email, ')') as display_label
from {{ ref('stg_customers') }}
Search the project for email and the matches stop at dim_customer. Every model downstream of it reads primary_contact or display_label, and neither name contains the word email. The address is in both. A text search follows a name, so it loses the value at the first rename. The column graph follows the value through the rename into primary_contact and through the concat() call into display_label. The review then ends with a list of every field that holds the address.
One field, five hops, two renames. Table-level lineage shows the five tables and none of the renames.
5. Giving an AI Agent Something to Check Before It Acts
An AI agent that renames or drops a field needs the exact set of downstream consumers before it acts. A column graph returns that set. Table lineage returns every downstream model, which is too much for the agent to check.
Hand an agent the rename of order_status, the order-state field, to status_code with no lineage attached. The agent edits stg_orders, and the project still parses. dbt's ref() resolves a model name to a table and never checks which columns the referencing model selects. A downstream model that still selects order_status therefore raises no error at parse time. The agent reads that silence as an all-clear. The error arrives at run time, in whichever downstream model runs first and asks the warehouse for a column that no longer exists.
Attach the column graph and the agent computes the 63 consumers of order_status before it writes a line. It then edits all 63 models in the same change, or it stops and escalates the list to a human. Either way a human sees the full downstream set before the change merges.
That pre-flight check is what makes an agent safe to run unattended, because the check has one exact answer. An agent without the graph guesses at the consumers, and a stronger model only guesses better. The same argument runs across a wider set of decisions in deterministic tooling beating LLM-only agents.
6. Finding the Column That Costs You Money
Scan cost comes from the fields a query reads. Snowflake and Databricks both store data by column, so a query pays for the columns it names and skips the rest of the row. A cost report that works at the query level blames whole queries. Trace one expensive field backwards through the column graph instead and you arrive at the upstream column that every reader downstream is paying to scan.
The table fct_events, the fact table with one row per tracked event, holds 500 million rows. One of its columns, raw_payload, stores the whole event as a block of JSON text and averages 2 KB per row. Any model that selects raw_payload therefore reads roughly 1 TB. Three hourly aggregations select it. None of those three needs more than two keys out of that JSON.
Promote those two keys to their own columns in the upstream model and the three readers select the keys instead of raw_payload. Nobody flagged the cost before, because a query-level report shows three hourly queries that each look reasonable alone. The column graph shows the same three queries as three readers of one 2 KB column. Reading the execution plan for one of those queries confirms what the scan pulled, and our write-up of profiling a slow dbt query walks through that step.
7. Retiring a Legacy Pipeline With Evidence
Turning off a legacy pipeline is safe once you hold a list of its live consumers, field by field. Column lineage that spans both the old path and its replacement produces that list. Without the list, the decision rests on whoever has been at the company longest.
One question decides whether a legacy path can go: does anything still read it? The code alone cannot answer it, so the fallback is to turn the path off and wait for complaints. That works, and it costs an incident each time a consumer nobody knew about breaks.
Column lineage answers the question with numbers. The legacy model legacy_orders_daily, an older daily orders rollup being retired, exposes 34 columns. Its replacement, fct_orders, the new orders fact table, already serves 31 of those 34 under the same names. The column graph across both paths shows that only three of the 34 fields still have live consumers on the legacy path. All three feed one finance extract. Send that one team a cutover date for those three fields and turn the rest of the legacy path off the same week.
8. Making a Data Contract Mean Something
A data contract is a promise about the shape of a model, the columns it returns and their types. The contract binds only when something detects that a change touched a field it covers. That detection is a lineage query, because the change that breaks a contract usually happens upstream of the model the contract names.
At build time, dbt enforces the shape of a model:
# models/marts/schema.yml
models:
- name: dim_customer
config:
contract:
enforced: true
columns:
- name: primary_contact
data_type: varchar
With enforced: true, dbt checks before the build that dim_customer returns primary_contact, the customer's contact field, as a varchar. That check sees the model's output and nothing else. The upstream rename in stg_customers that produced the field sits outside the check, and so does every model downstream that depends on it. dbt's model contracts documentation states that a contract defines the shape of the returned dataset.
The column graph supplies the other half. When a pull request changes the expression behind primary_contact, the graph reports that the change reaches a field under contract. The reviewer then knows the change needs a version bump. Contracts fail because nobody noticed that a change touched one, and the column graph notices.
Where Column Lineage Comes From
A column graph comes from two sources, static parsing of the SQL and the warehouse's own query history. Each source covers the cases the other misses.
Static parsing reads your SQL and follows the field references. It is fast, it needs no warehouse access, and it works before a model has ever run. Static parsing struggles with dynamic SQL, heavy macro use and SELECT *:
-- models/staging/stg_orders.sql
select * from {{ source('shop', 'orders') }}
-- models/intermediate/int_orders_enriched.sql
select
o.*,
o.gross_amount - o.discount_amount as net_amount
from {{ ref('stg_orders') }} o
A parser reading those two files knows that int_orders_enriched depends on stg_orders. The parser can name exactly one field edge, the one that builds net_amount. Every other column in orders reaches int_orders_enriched through two stars, and a star carries no names until something resolves the source schema.
Warehouse query history is the other source. Snowflake records column-level access in ACCESS_HISTORY, and Databricks captures Unity Catalog lineage down to the column. These edges come from queries that ran, so the warehouse already expanded every star and every macro before it recorded the columns. That covers the dynamic cases a parser cannot read. Query history has no record of a model that has not run yet, or of the branch on your laptop.
| Static SQL parsing | Warehouse query history | |
|---|---|---|
| Reads | model code in the repo | queries the platform already executed |
| A model that never ran | ✓ resolved | ✗ invisible |
SELECT * | ≈ resolved only with the source schema | ✓ expanded, because the warehouse expanded it at run time |
| Dynamic SQL and macro output | ✗ usually missed | ✓ recorded once it runs |
| An ad-hoc query outside dbt | ✗ invisible | ✓ recorded |
| Freshness | current with the working branch | Snowflake documents ACCESS_HISTORY latency of up to 180 minutes |
| History depth | whatever the repo holds | 365 days on ACCESS_HISTORY, a one-year rolling window on the Databricks lineage system tables |
| Documented blind spot | unresolved stars, macro-heavy models | intermediate views between the base table and the direct object on Snowflake, file-path sources and user-defined functions on Databricks |
A tool that reads both sources covers what each one misses. Our column lineage reference covers the editor-side version and names which edges the lineage panel resolves statically and which ones need a warehouse connection.
Freshness decides how much of the graph you can act on. A graph rebuilt nightly describes yesterday's project, and the change you are reviewing belongs to today. An impact count from a nightly graph therefore answers a question about a codebase that no longer exists. Rebuild on every change, and treat a nightly graph as documentation rather than a check.
Check the coverage number before you trust a graph. A tool that parses 90% of your models sounds good until you find that the missing 10% are the macro-heavy models everything depends on. A useful coverage report names the failed models, because a percentage says nothing about which models fell out.
The unparseable models are the macro-heavy ones with the most downstream readers.
Column Lineage Got Cheaper to Build In 2026
Building column-level lineage used to mean writing a SQL parser first, so a vendor was the only practical source. That changed when the dbt engine started parsing SQL.
dbt's Fusion engine, the rewrite behind dbt Core v2.0, reads each model's SQL as SQL, where earlier versions treated it as a text template. An engine that resolves every field reference already knows which column feeds which, so column-level edges fall out as a by-product. A parser added afterwards has to reconstruct those edges.
Today the parser ships in the engine you already run, and what is left to build is the graph and the coverage reporting on top of it. Whichever side of the build-versus-buy line you land on, ask the same three questions. What percentage of models parse cleanly, which models fail, and how many downstream models sit behind those failures?
These Column-Level Lineage Use Cases All Replace a Search With a Traversal
Each of the eight column-level lineage use cases above comes down to one question that column edges answer with a name or a count.
| Question | Table-level answers | Column-level answers |
|---|---|---|
| Can I drop this column? | ✗ check everything downstream | ✓ two consumers |
| How risky is this pull request? | ≈ by diff size | ✓ by downstream column count |
| What broke this dashboard number? | ≈ a candidate list of tables | ✓ a path to the field |
| Where does this email address go? | ≈ tables that might carry it | ✓ the field, through renames |
| What will this agent's change break? | ✗ unknown | ✓ an exact set, before it acts |
| Which column is driving up scan cost? | ✗ blames the whole query | ✓ names the field |
| Is the legacy path still used? | ≈ probably | ✓ a list of live consumers |
| Did a change break a data contract? | ✗ nothing flags it | ✓ flags the version bump |
Column lineage is one of the nine checks in nine signs your data platform needs an agent-first overhaul, which is the broader diagnostic.
Pick the field your team argues about most. Ask your lineage tool for that field's downstream consumers, then open the models and count the consumers by hand. The gap between the two counts is your coverage number.
Frequently Asked Questions
Table-level lineage records that one table reads from another. Column-level lineage records which field flows into which field, including through renames and expressions. Only column-level lineage can answer whether a given column has any live consumers.
Most catalogs give you table-level lineage and a list of each table's columns. Neither one is a column-level edge, because a column list says what a table holds and never who reads each column. Check whether your catalog can answer "what reads this specific column" rather than "what reads this table".
Column-level lineage has to cross tool boundaries for incident triage to run as one traversal. A wrong number is usually noticed on a dashboard, so the trace starts in the BI tool. A graph that stops at the warehouse edge has no record of the dashboard tile, so the trace has to start one hop in, by hand.
A star carries no field names, so a static parser reading select * records the table edge and no column edges under it. Give the parser the source schema so it can expand the star. The other fix is warehouse query history, where the star was already expanded when the query ran.
On a small project where one engineer can name every consumer of every column, the benefit is modest. Every one of the column-level lineage use cases above starts to pay off once nobody on the team can do that without looking.
