S054 · Governance & Cost — Lineage, PII, Attribution
The unglamorous but career-defining half of data engineering: knowing where every row came from, who owns it, which team's budget pays for it, and how to prove nothing sensitive leaked. A working playbook for lineage, PII discovery, and cost attribution.
🎯 Instrument a pipeline with column-level lineage, tag a PII column, and produce a cost-per-team report that survives an actual finance review.
Why this session exists
The first time a lawyer asks "where does this customer's email actually flow?" and you can't answer, your career changes. The second time your CFO asks "why is our Snowflake bill 40 % higher than last quarter and who caused it?" — same. Data governance and cost attribution are the boring skills that separate a data engineer from a data platform engineer. They are also what let a company pass SOC 2, GDPR, HIPAA, and quarterly finance reviews without a fire drill.
- Define lineage (table-level and column-level) and produce a real lineage graph from a dbt project.
- Classify columns as PII / sensitive / public with policy tags, and enforce access via row/column policies.
- Attribute warehouse spend to teams using query tags + a nightly rollup — with a dashboard your CFO would sign.
- Explain the three retention layers (hot / warm / cold) and pick storage tier by access pattern, not vibe.
- Draft a data-lifecycle policy: creation → classification → retention → deletion, tied to a real request queue.
Prerequisites
- S052 · Lakehouse — you know what a snapshot and a catalog are.
- S053 · Data quality — DQ result rows are lineage-adjacent.
- S055 · HTTP fundamentals (skim ahead) — governance APIs are all REST.
(a) Intuition · 5 min
A city library has to know: which shelf every book lives on, who checked it out, when it's due back, and which books contain restricted material (rare, fragile, adult). None of that is glamorous — but a library without it is a hoarder's basement, and a public library without it is a lawsuit.
The utility bill is separate: someone has to know how much electricity each floor uses, so you can spot the freezer nobody unplugged and stop paying $400/month for it.
Data governance is the library: know where every column lives (catalog), who touched it (lineage), which columns are sensitive (classification), and how long you legally must keep or delete them (retention).
Cost attribution is the utility bill: know which team's queries burn the most warehouse credits, tag every query at ingestion, and produce a monthly rollup so nobody can say "not us".
The four things governance actually delivers
- ‘Where did this number come from?’ — lineage from a BI dashboard back to source rows.
- ‘Where does this PII flow?’ — classification + column-level lineage together.
- ‘Delete everything about customer X’ — GDPR erasure workflow across the lake, warehouse, backups.
- ‘Which team is spending our $2M/yr warehouse budget?’ — query tagging + attributed spend rollup.
How the industry got here
- 2010‘Data governance’ = Excel + emailA spreadsheet listing tables + owners. Updated quarterly by an overworked steward.
- 2015Metadata catalogs open-sourcedLinkedIn's WhereHows, Uber's Databook, later DataHub. First automated crawl of warehouse metadata.
- 2018GDPR goes live in EU‘Right to be forgotten’ becomes law. Suddenly every US-facing company needs a delete-by-user workflow.
- 2020OpenLineage spec publishedCommon event model for lineage. dbt, Airflow, Spark, Flink emit compatible events.
- 2023Unity Catalog + Snowflake HorizonWarehouse-native governance goes GA. Column tags, row policies, and cost attribution ship as first-class primitives.
- 2024‘FinOps for data’ becomes a job titleAs warehouse bills cross $1M/yr routinely, dedicated headcount owns attribution + optimisation.
(b) Visual walkthrough · 15 min
Lineage as a graph
Red nodes contain PII (the email column). Column-level lineage lets you highlight exactly this traversal: "email flows all the way to fct_customer_orders". A GDPR delete for one customer must touch every red node.
The governance stack
Layers you're assembling
Cost attribution: two-step pattern
`ALTER SESSION SET QUERY_TAG = 'team=ml/pipeline=lifetime_value/env=prod'`. Enforce at the driver layer, not by convention.
SUM(credits * unit_price) GROUP BY team, pipeline. Store in a `cost_by_team_daily` fact table.
One dashboard per team; company-wide leaderboard. Numbers reviewed monthly in FinOps sync.
Any team crossing 2× 30-day average → auto Slack to team-owner. Prevents surprise invoices.
Every team gets a monthly credit budget. Overrun requires a ticket, not a shrug.
Governance modes: catalog-first vs producer-first
Discover metadata after the fact
- Zero producer burden — just scan the warehouse
- Great for large legacy estates
- Classification is heuristic and lagging
- Owner attribution is often wrong
Publisher declares schema + tags at write time
- Column tags travel with the data from ingest
- Enforceable — bad producers can't ship
- Requires cultural buy-in and tooling
- Best for greenfield or platform rebuilds
Crawl + contracts for critical pipelines
- Auto-catalog everything on day one
- Enforce contracts on top-N revenue tables
- Steward reviews classification periodically
- Realistic path for a 500-person data org
"Governance is a compliance function. It's the tagging and access-request paperwork the security team makes us do, and it slows engineering down."
Governance is the metadata layer that makes cost attribution, impact analysis and deprecation possible at all. Without ownership and lineage, you cannot answer the two questions that dominate a mature platform's engineering time: "who pays for this?" and "can I delete this?" Both are governance queries wearing an engineering hat.
The myth is sticky because governance starts as compliance — the first time anyone asks for a data catalog, it's usually a GDPR or SOX request, so the association is formed correctly for that instance. It stays wrong because the cost of missing governance is invisible: it shows up as an untouchable table nobody dares drop, a pipeline that runs for a dashboard that closed two years ago, and a warehouse bill nobody can decompose.
Try to answer this on your own platform right now:
-- 1. What did each team spend last month?
select tags['team'], sum(cost)
from billing group by 1;
-- 2. Which tables were not read in 90 days?
select table_name, max(last_accessed)
from access_history
group by 1
having max(last_accessed) < current_date - 90;If query 1 returns mostly nulls or an "untagged" bucket, you have no cost attribution. If you cannot act on query 2 because you don't know who owns the results, you have no deprecation path — and your storage bill only ever goes up.
Why does cloud data platform cost grow superlinearly with team size, even when data volume grows linearly? This feels like bad discipline — it's structural.
- 1In a decoupled warehouse, any team can create a dataset and a schedule without approval from anyone who pays the bill.forced by · self-service is the entire reason the platform was adopted; requiring approval reintroduces the central bottleneck it replaced
- 2Each team builds derived tables from other teams' tables, so the number of derived datasets grows with the number of pairs of teams and use cases, not with the number of teams.forced by · derivation is combinatorial: every consumer creates its own reshaping of every producer it depends on
- 3Each derived dataset carries a recurring compute schedule, and schedules are essentially never removed when their consumer disappears.forced by · the cost of a running job is borne by a shared budget, while the risk of deleting it is borne personally by whoever deletes it
- 4So recurring spend accumulates monotonically: it is easy to add and individually irrational to remove.forced by · the incentive is asymmetric — no one is thanked for a job they turned off, and everyone remembers who broke a dashboard
- 5Reversing the asymmetry requires making cost visible per owner and making the blast radius of a deletion knowable — which is exactly chargeback plus lineage.forced by · you cannot make someone accountable for a number they cannot see, or confident about a deletion whose consumers are unknown
Therefore cost control on a self-service platform is a metadata problem, not an optimisation problem. Query tuning reduces the cost of things you decided to keep; governance is what lets you decide what to keep at all.
And note what this predicts: the single highest-leverage cost intervention is not a better query engine or a cheaper storage tier — it is mandatory ownership tags enforced at creation time, because that is the only point where the tag is free to add. Retrofitting ownership across thousands of existing objects is archaeology, and most organisations never finish it. That is why platforms that enforced tagging on day one have decomposable bills and platforms that didn't have a large permanent "unattributed" bucket.
Imagine every table, pipeline and dashboard has a nameplate on the door: who owns it, who pays for it, what's inside (sensitivity), and who is allowed in. An object with no nameplate is a squatter — nobody maintains it, nobody can be billed, and nobody will ever authorise its demolition.
Lineage is the plumbing diagram of the building. Without it, you cannot turn off a pipe without risking a flood somewhere you can't see.
- Ownership is a person or an on-call rotation, never a department. "Data Engineering owns it" resolves to nobody at 2am; a team alias with a rotation resolves to someone.
- Tag at creation, enforce in CI. A tagging policy applied retroactively never reaches completion, because the people who created the objects have moved teams.
- Classify by sensitivity, not by table. A single column of PII makes the join key sensitive downstream — classification must propagate along lineage or it is decorative.
- Cost controls need a hard stop, not just a dashboard: per-warehouse auto-suspend, query timeouts, and result-size limits. A budget alert informs you after the money is gone; a timeout prevents the runaway query from spending it.
Fire this model when you see: a bill line item nobody recognises · a table nobody will let you drop · a "who owns this?" thread with no reply · a pipeline still running for a decommissioned product · an access request that no one can approve because the owner left.
Who controls data access and modelling standards: a central platform team, or the domain teams that produce the data?
Federate ownership, centralise the contract, and enforce the contract in code. The recurring failure mode is a governance policy that lives in a document: it is written once, complied with for a quarter, and then quietly abandoned because nothing breaks when you ignore it.
The concrete test of whether your governance is real: can a new pipeline reach production without an owner tag and a sensitivity classification? If yes, everything else in your governance programme is aspirational, and your cost attribution will keep degrading no matter how many dashboards you build on top of it.
(c) Hands-on · 25 min
We'll simulate a tiny governance system: register tables, emit lineage events, classify columns, and run a GDPR delete that uses the lineage graph to find every affected dataset.
"""
governance_demo.py — Toy governance: catalog + column lineage + PII delete.
Run:
pip install networkx
python governance_demo.py
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterable
import networkx as nx
# ---------- catalog ----------
@dataclass
class Column:
name: str
dtype: str
tags: set[str] = field(default_factory=set)
@dataclass
class Table:
name: str
owner_team: str
columns: dict[str, Column]
storage_bytes: int = 0
def tag_column(self, col: str, *tags: str) -> None:
self.columns[col].tags.update(tags)
CATALOG: dict[str, Table] = {}
def register(tbl: Table) -> Table:
CATALOG[tbl.name] = tbl
return tbl
# ---------- lineage graph ----------
LINEAGE = nx.DiGraph() # nodes: (table, column); edges: source → derived
def emit_lineage(src_table: str, src_col: str, dst_table: str, dst_col: str) -> None:
LINEAGE.add_edge((src_table, src_col), (dst_table, dst_col))
# Propagate tags automatically (this is the ‘PII flows downstream’ magic).
src_tags = CATALOG[src_table].columns[src_col].tags
CATALOG[dst_table].columns[dst_col].tags.update(src_tags)
# ---------- PII discovery + delete ----------
def columns_with_tag(tag: str) -> list[tuple[str, str]]:
return [
(t.name, c.name)
for t in CATALOG.values()
for c in t.columns.values()
if tag in c.tags
]
def gdpr_delete(user_email: str) -> list[str]:
"""Given a user's identifier, walk lineage and return every table needing a delete."""
affected: set[str] = set()
# Start from every table containing an ‘email’-tagged column classified as PII.
for tbl_name, col_name in columns_with_tag("pii.email"):
# Any downstream node from this (table,col) is affected too.
node = (tbl_name, col_name)
affected.add(tbl_name)
for descendant in nx.descendants(LINEAGE, node):
affected.add(descendant[0])
return sorted(affected)
# ---------- cost attribution ----------
@dataclass
class QueryRun:
query_tag: str # "team=ml/pipeline=ltv"
credits: float
cost_per_credit: float = 3.0 # USD
@property
def team(self) -> str:
return dict(kv.split("=") for kv in self.query_tag.split("/"))["team"]
@property
def cost_usd(self) -> float:
return self.credits * self.cost_per_credit
def cost_by_team(runs: Iterable[QueryRun]) -> dict[str, float]:
out: dict[str, float] = {}
for r in runs:
out[r.team] = out.get(r.team, 0.0) + r.cost_usd
return dict(sorted(out.items(), key=lambda kv: -kv[1]))
# ---------- fixture ----------
def build_world() -> None:
src = register(Table(
name="raw.customers",
owner_team="platform",
columns={
"id": Column("id", "int"),
"email": Column("email", "string", tags={"pii.email"}),
"name": Column("name", "string", tags={"pii.name"}),
},
))
stg = register(Table(
name="stg_customers",
owner_team="analytics",
columns={
"customer_id": Column("customer_id", "int"),
"email_lower": Column("email_lower", "string"),
"display_name": Column("display_name", "string"),
},
))
fct = register(Table(
name="fct_customer_orders",
owner_team="analytics",
columns={
"customer_id": Column("customer_id", "int"),
"email_lower": Column("email_lower", "string"),
"order_count": Column("order_count", "int"),
},
))
dash = register(Table(
name="dash.marketing_kpis",
owner_team="marketing",
columns={
"customer_email": Column("customer_email", "string"),
"revenue": Column("revenue", "float"),
},
))
# column-level lineage — this is where governance earns its keep
emit_lineage("raw.customers", "id", "stg_customers", "customer_id")
emit_lineage("raw.customers", "email", "stg_customers", "email_lower")
emit_lineage("raw.customers", "name", "stg_customers", "display_name")
emit_lineage("stg_customers", "customer_id", "fct_customer_orders", "customer_id")
emit_lineage("stg_customers", "email_lower", "fct_customer_orders", "email_lower")
emit_lineage("fct_customer_orders", "email_lower", "dash.marketing_kpis", "customer_email")
def build_query_history() -> list[QueryRun]:
return [
QueryRun("team=ml/pipeline=ltv", credits=1200),
QueryRun("team=ml/pipeline=fraud", credits=450),
QueryRun("team=analytics/pipeline=exec_kpi", credits=280),
QueryRun("team=marketing/pipeline=daily", credits=90),
QueryRun("team=platform/pipeline=metadata", credits=40),
]
def main() -> None:
build_world()
print("=== PII columns ===")
for t, c in columns_with_tag("pii.email"):
print(f" {t}.{c}")
print("\n=== GDPR delete plan for alice@example.com ===")
for t in gdpr_delete("alice@example.com"):
print(f" ⇒ scrub in {t} (owner: {CATALOG[t].owner_team})")
print("\n=== Monthly cost by team (USD) ===")
for team, cost in cost_by_team(build_query_history()).items():
print(f" {team:<10} ${cost:>8,.2f}")
if __name__ == "__main__":
main()Run it:
pip install networkx
python governance_demo.pyYou should see: (a) five PII-tagged columns discovered — including the derived ones that inherited the tag through lineage, (b) four downstream tables needing a scrub for a GDPR delete, (c) a per-team cost table sorted by spend.
Anatomy of the script
What each block teaches
Wire two new concepts in:
from typing import Literal
def can_read(table: str, column: str, requester_region: str, role: str) -> bool:
col = CATALOG[table].columns[column]
if "eu_only" in col.tags and requester_region != "EU" and role != "admin":
return False
if "pii.email" in col.tags and role not in {"support", "admin"}:
return False
return True
# Tag one table's column and try it out.
CATALOG["raw.customers"].tag_column("email", "eu_only")
print(can_read("raw.customers", "email", "US", "analyst")) # False
print(can_read("raw.customers", "email", "US", "admin")) # TrueThat's the seed of a policy engine — Snowflake's row/column policies, Unity Catalog's row filters, and BigQuery's data policies all compile to a function shaped exactly like this.
(d) Production reality · 15 min
The Cambridge Analytica settlement (finalised 2019, subsequent penalties in 2023) hinged largely on Meta being unable to prove where personal data had flowed and to whom. Regulators asked "which apps received this data field?" and internal answers were slow, incomplete, or contradictory.
The technical problem was governance: partial column-level lineage, weak enforcement of contracts on third-party APIs, and no unified deletion pipeline across dozens of internal data stores.
CROSS JOIN in a notebook, on the largest warehouse. It ran for 11 hours before anyone noticed. Because no queries were tagged, finance couldn't tell if the spike was one query or a slow drift. The team spent three days manually correlating Snowflake QUERY_HISTORY to Git commits to identify the offender.DELETE FROM users WHERE id = 42 on the primary warehouse. Done, right? Wrong: the row still exists in the raw ingestion partition (Parquet is immutable), the 7-day time-travel snapshot, the 30-day disaster-recovery backup, the ML feature store, and two BI extracts sitting in someone's Google Drive.A GDPR-compliant delete needs a workflow, not a query:
- Look up every dataset containing the user (via lineage).
- Issue a delete/rewrite in each — including a Delta VACUUM on time-travel history.
- Purge from search indexes and caches.
- Mark backups for expedited expiry (or crypto-shredding if possible).
- Log completion with proof for the audit trail.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
Move on when you can teach these:
- What are the four things governance actually delivers, and what happens if you skip them?
- What is the difference between table-level and column-level lineage, and why does GDPR care?
- How does a single query tag change your ability to answer a CFO question?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.