Data Lineage Tracking: Track Origin End-to-End
How to track data origin and transformations end-to-end. Covers column-level lineage, OpenLineage, Marquez, metadata injection, and impact analysis.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
Data lineage tracks where data comes from, how it’s transformed, and where it ends up. Every pipeline run, every SQL transformation, every column mapping is recorded as metadata. This creates a graph: source table → transformation → intermediate table → final report. When a source column changes, you can trace which downstream tables, dashboards, and ML models depend on it. OpenLineage is the open standard for lineage metadata, and Marquez is its reference implementation. Lineage is essential for data governance, impact analysis, debugging, and compliance (GDPR, CCPA require knowing where personal data flows).
When to Use
- Data governance and compliance (GDPR, CCPA, SOX)
- Impact analysis before schema changes or pipeline modifications
- Debugging data quality issues — tracing bad data back to the source
- Multi-team data platforms where teams need to understand dependencies
- Regulatory audits requiring data flow documentation
When NOT to Use
- Small projects with a single pipeline and no dependencies
- Prototypes and exploratory work
- Systems where all data flows are documented manually and rarely change
Solution
OpenLineage event structure
// openlineage_event.json — OpenLineage run event
{
"eventType": "START",
"eventTime": "2026-07-05T10:00:00.000Z",
"run": {
"runId": "abc-123-def",
"facets": {
"nominalTime": {
"_producer": "https://github.com/OpenLineage/OpenLineage",
"_schemaURL": "https://openlineage.io/schema/facets/1.0/nominalTime.json",
"nominalStartTime": "2026-07-05T10:00:00.000Z",
"nominalEndTime": "2026-07-05T10:05:00.000Z"
}
}
},
"job": {
"namespace": "shop",
"name": "etl.customers_daily",
"facets": {
"sql": {
"query": "SELECT id, email, status FROM source.customers WHERE updated_at >= ?"
}
}
},
"inputs": [
{
"namespace": "postgres://source-db",
"name": "shop.public.customers",
"facets": {
"schema": {
"fields": [
{"name": "id", "type": "long"},
{"name": "email", "type": "string"},
{"name": "status", "type": "string"}
]
}
}
}
],
"outputs": [
{
"namespace": "postgres://warehouse",
"name": "warehouse.customers",
"facets": {
"schema": {
"fields": [
{"name": "id", "type": "long"},
{"name": "email", "type": "string"},
{"name": "status", "type": "string"},
{"name": "is_active", "type": "boolean"}
]
},
"columnLineage": {
"fields": {
"is_active": {
"inputFields": [
{"namespace": "postgres://source-db", "name": "shop.public.customers", "field": "status"}
]
}
}
}
}
}
]
}
Python lineage tracking with OpenLineage
# lineage_tracker.py — emit OpenLineage events from Python pipelines
from openlineage.client import OpenLineageClient
from openlineage.client.run import (
Run, RunEvent, Job, Dataset, OutputDataset, InputDataset
)
import uuid
from datetime import datetime
client = OpenLineageClient(url="http://marquez:5000", api_key=None)
class LineageTracker:
def __init__(self, job_name, job_namespace="shop"):
self.job = Job(namespace=job_namespace, name=job_name)
self.run_id = str(uuid.uuid4())
def start(self, inputs, outputs, sql=None):
run = Run(runId=self.run_id)
job_facets = {}
if sql:
job_facets["sql"] = {"query": sql}
self.job.facets = job_facets
event = RunEvent(
eventType="START",
eventTime=datetime.now().isoformat(),
run=run,
job=self.job,
inputs=[self._to_input_dataset(d) for d in inputs],
outputs=[self._to_output_dataset(d) for d in outputs]
)
client.emit(event)
def complete(self, inputs, outputs):
run = Run(runId=self.run_id)
event = RunEvent(
eventType="COMPLETE",
eventTime=datetime.now().isoformat(),
run=run,
job=self.job,
inputs=[self._to_input_dataset(d) for d in inputs],
outputs=[self._to_output_dataset(d) for d in outputs]
)
client.emit(event)
def fail(self, error_message):
run = Run(runId=self.run_id)
run.facets = {"errorMessage": {"message": error_message}}
event = RunEvent(
eventType="FAIL",
eventTime=datetime.now().isoformat(),
run=run,
job=self.job,
inputs=[],
outputs=[]
)
client.emit(event)
def _to_input_dataset(self, spec):
return InputDataset(
namespace=spec.get("namespace", "default"),
name=spec["name"],
facets=spec.get("facets", {})
)
def _to_output_dataset(self, spec):
return OutputDataset(
namespace=spec.get("namespace", "default"),
name=spec["name"],
facets=spec.get("facets", {}),
outputFacets={}
)
Using lineage tracker in a pipeline
# etl_with_lineage.py — ETL pipeline with lineage tracking
from lineage_tracker import LineageTracker
def run_customer_etl():
tracker = LineageTracker("etl.customers_daily")
inputs = [{
"namespace": "postgres://source-db",
"name": "shop.public.customers",
"facets": {
"schema": {
"fields": [
{"name": "id", "type": "long"},
{"name": "email", "type": "string"},
{"name": "status", "type": "string"}
]
}
}
}]
outputs = [{
"namespace": "postgres://warehouse",
"name": "warehouse.customers",
"facets": {
"schema": {
"fields": [
{"name": "id", "type": "long"},
{"name": "email", "type": "string"},
{"name": "is_active", "type": "boolean"}
]
},
"columnLineage": {
"fields": {
"is_active": {
"inputFields": [
{"namespace": "postgres://source-db",
"name": "shop.public.customers",
"field": "status"}
]
}
}
}
}
}]
sql = "SELECT id, LOWER(email) as email, status = 'active' as is_active FROM source.customers"
try:
tracker.start(inputs, outputs, sql=sql)
# Run the actual ETL
df = extract_from_source()
df = transform(df)
load_to_warehouse(df)
tracker.complete(inputs, outputs)
except Exception as e:
tracker.fail(str(e))
raise
Column-level lineage with SQL parsing
# column_lineage.py — extract column-level lineage from SQL
import sqlglot
from sqlglot import exp
def extract_column_lineage(sql, source_table, target_table):
"""Parse SQL and extract column-level lineage."""
parsed = sqlglot.parse_one(sql)
lineage = {}
for select in parsed.find_all(exp.Select):
for column in select.find_all(exp.Column):
col_name = column.name
source_col = column.name
# Check if it's an alias or expression
parent = column.parent
if isinstance(parent, exp.Alias):
target_col = parent.alias
elif isinstance(parent, exp.EQ):
target_col = parent.left.name
else:
target_col = col_name
if target_col not in lineage:
lineage[target_col] = []
lineage[target_col].append({
"source_table": source_table,
"source_column": source_col
})
return lineage
# Example
sql = """
SELECT
id,
LOWER(email) AS email,
status = 'active' AS is_active,
CONCAT(first_name, ' ', last_name) AS full_name
FROM source.customers
"""
lineage = extract_column_lineage(sql, "source.customers", "warehouse.customers")
# Result:
# {
# "id": [{"source_table": "source.customers", "source_column": "id"}],
# "email": [{"source_table": "source.customers", "source_column": "email"}],
# "is_active": [{"source_table": "source.customers", "source_column": "status"}],
# "full_name": [{"source_table": "source.customers", "source_column": "first_name"},
# {"source_table": "source.customers", "source_column": "last_name"}]
# }
Impact analysis query
-- impact_analysis.sql — find all downstream tables affected by a source change
WITH RECURSIVE lineage_graph AS (
-- Start from the changed table
SELECT
downstream_table,
downstream_column,
upstream_table,
upstream_column,
1 AS depth
FROM lineage_edges
WHERE upstream_table = 'source.customers'
AND upstream_column = 'email'
UNION ALL
-- Recurse through downstream dependencies
SELECT
e.downstream_table,
e.downstream_column,
e.upstream_table,
e.upstream_column,
g.depth + 1
FROM lineage_edges e
JOIN lineage_graph g ON e.upstream_table = g.downstream_table
AND e.upstream_column = g.downstream_column
WHERE g.depth < 10 -- prevent infinite loops
)
SELECT DISTINCT
downstream_table,
downstream_column,
depth,
STRING_AGG(upstream_table || '.' || upstream_column, ' -> ') AS path
FROM lineage_graph
GROUP BY downstream_table, downstream_column, depth
ORDER BY depth, downstream_table;
Airflow integration with OpenLineage
# airflow_lineage.py — Airflow DAG with automatic lineage extraction
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.providers.openlineage.extractors.python import PythonOperatorExtractor
from datetime import datetime, timedelta
default_args = {
'owner': 'data-team',
'depends_on_past': False,
'retries': 1,
'retry_delay': timedelta(minutes=5),
}
dag = DAG(
'etl_with_lineage',
default_args=default_args,
schedule_interval='0 2 * * *',
start_date=datetime(2026, 1, 1),
tags=['etl', 'lineage'],
)
# Airflow automatically emits OpenLineage events when configured
# Add to airflow.cfg:
# [openlineage]
# transport = {"type": "http", "url": "http://marquez:5000"}
# extraction_filter = airflow.providers.openlineage.extractors.python.PythonOperatorExtractor
def extract(**context):
import pandas as pd
# Airflow extractor reads XCom for dataset info
context['ti'].xcom_push(key='inputs', value=[
{"namespace": "postgres://source", "name": "shop.customers"}
])
context['ti'].xcom_push(key='outputs', value=[
{"namespace": "postgres://warehouse", "name": "warehouse.customers"}
])
# Actual ETL logic here
pass
extract_task = PythonOperator(
task_id='extract',
python_callable=extract,
provide_context=True,
dag=dag,
)
Java lineage with custom metadata
// LineageEmitter.java — emit lineage events from Java pipelines
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URI;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.UUID;
import java.time.Instant;
public class LineageEmitter {
private static final String MARQUEZ_URL = "http://marquez:5000";
private final HttpClient client = HttpClient.newHttpClient();
private final ObjectMapper mapper = new ObjectMapper();
public void emitRun(String jobName, String runId, String eventType,
String[] inputTables, String[] outputTables) {
try {
var event = mapper.createObjectNode();
event.put("eventType", eventType);
event.put("eventTime", Instant.now().toString());
var run = mapper.createObjectNode();
run.put("runId", runId);
event.set("run", run);
var job = mapper.createObjectNode();
job.put("namespace", "shop");
job.put("name", jobName);
event.set("job", job);
var inputs = mapper.createArrayNode();
for (String table : inputTables) {
var ds = mapper.createObjectNode();
ds.put("namespace", "postgres://source");
ds.put("name", table);
inputs.add(ds);
}
event.set("inputs", inputs);
var outputs = mapper.createArrayNode();
for (String table : outputTables) {
var ds = mapper.createObjectNode();
ds.put("namespace", "postgres://warehouse");
ds.put("name", table);
outputs.add(ds);
}
event.set("outputs", outputs);
var request = HttpRequest.newBuilder()
.uri(URI.create(MARQUEZ_URL + "/api/v1/lineage"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(event)))
.build();
client.send(request, HttpResponse.BodyHandlers.ofString());
} catch (Exception e) {
throw new RuntimeException("Failed to emit lineage event", e);
}
}
}
Variants
Table-level lineage (simpler)
# table_lineage.py — simple table-level lineage without column detail
class TableLineage:
def __init__(self):
self.edges = [] # list of (source_table, target_table, job_name)
def record(self, source, target, job):
self.edges.append({
"source": source,
"target": target,
"job": job,
"recorded_at": datetime.now().isoformat()
})
def get_downstream(self, table):
"""Find all tables that depend on this table."""
visited = set()
queue = [table]
result = []
while queue:
current = queue.pop(0)
for edge in self.edges:
if edge["source"] == current and edge["target"] not in visited:
visited.add(edge["target"])
result.append(edge["target"])
queue.append(edge["target"])
return result
Lineage from dbt models
# dbt_lineage.yml — dbt automatically generates lineage from model dependencies
version: 2
models:
- name: stg_customers
description: "Staged customer data"
columns:
- name: id
tests: [not_null, unique]
- name: email
tests: [not_null]
- name: dim_customers
description: "Customer dimension with enriched fields"
depends_on:
- ref('stg_customers')
- ref('stg_orders')
columns:
- name: customer_id
tests: [not_null, unique]
- name: lifetime_value
description: "Calculated from stg_orders"
Best Practices
-
For a deeper guide, see Batch-to-Streaming Bridge.
-
Emit lineage events at both START and COMPLETE — so you can see running pipelines and their results
-
Track column-level lineage when possible — table-level is a start, but column-level enables precise impact analysis
-
Use OpenLineage standard — don’t build a custom lineage format; OpenLineage has wide tool support
-
Include SQL in job facets — the SQL query is the most useful lineage metadata for debugging
-
Automate lineage collection — use extractors for Airflow, Spark, dbt instead of manual emission
-
Store lineage in a queryable system — Marquez, DataHub, or Amundsen for querying the lineage graph
-
Run impact analysis before schema changes — “who depends on this column?” before dropping it
-
Keep lineage metadata in sync — if a pipeline is removed, clean up its lineage edges
Common Mistakes
- No lineage at all: when something breaks, you can’t trace the data back to the source. Manual investigation takes hours.
- Only table-level lineage: you know
warehouse.customersdepends onsource.customersbut not which columns. Dropping a column breaks unknown downstream consumers. - Not including SQL: lineage without the actual SQL makes it hard to understand what the transformation does.
- Manual lineage tracking: maintaining lineage in spreadsheets or wikis. It’s always out of date. Automate it.
- No impact analysis: making schema changes without checking what depends on the changed columns.
FAQ
What is data lineage?
A record of where data comes from, how it’s transformed, and where it flows. It’s a graph connecting source tables, transformations, intermediate tables, and final outputs (dashboards, ML models, reports).
What is OpenLineage?
An open standard for lineage metadata. It defines a JSON event format for recording pipeline runs, their inputs, outputs, and transformations. Marquez is the reference implementation. Airflow, Spark, and dbt have OpenLineage integrations.
What is column-level lineage?
Lineage that tracks individual columns, not just tables. You know that warehouse.customers.is_active comes from source.customers.status. This enables precise impact analysis when a source column changes.
How do I implement lineage with dbt?
dbt generates lineage automatically from model dependencies (ref() calls). Run dbt docs generate and view the lineage graph at localhost:8080. For OpenLineage integration, use the dbt-openlineage adapter.
Why is lineage important for compliance?
GDPR and CCPA require knowing where personal data flows. If a user requests data deletion, you need to trace their data across all tables, pipelines, and downstream systems. Lineage provides this map.
Related Resources
ETL Extract-Transform-Load
How to build ETL pipelines with extract, transform, and load stages. Covers staging tables, incremental extraction, idempotent loads, and orchestration.
PatternCDC Pattern: Stream Database Changes to Downstream
How to stream database changes to downstream consumers with CDC. Covers log-based CDC, Debezium, Kafka Connect, outbox pattern, and consumer reconciliation.
PatternSchema Registry Evolution
How to manage schema versions for streaming pipelines with a schema registry. Covers Avro, backward compatibility, forward compatibility, and consumer migration.
PatternIdempotent Load: Re-run Data Loads Safely Without Duplicates
How to re-run data loads safely without duplicates. Covers deduplication keys, MERGE upserts, load IDs, partition overwrite, and transactional loads.