Search Tech Journey

Find topics, journeys and posts

6-month learning plan54 / 130
back to blog
data engineeringintermediate 55m read

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.

🗄️Data EngineeringM05 · Data Engineering· Session 054 of 130 90 min

🎯 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.

You will be able to
  • 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

Governance is a library, cost is a utility bill
🌍 Real world

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.

💻 Code world

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

If your platform can't answer these, you don't have governance
  • ‘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

  1. 2010
    ‘Data governance’ = Excel + email
    A spreadsheet listing tables + owners. Updated quarterly by an overworked steward.
  2. 2015
    Metadata catalogs open-sourced
    LinkedIn's WhereHows, Uber's Databook, later DataHub. First automated crawl of warehouse metadata.
  3. 2018
    GDPR goes live in EU
    ‘Right to be forgotten’ becomes law. Suddenly every US-facing company needs a delete-by-user workflow.
  4. 2020
    OpenLineage spec published
    Common event model for lineage. dbt, Airflow, Spark, Flink emit compatible events.
  5. 2023
    Unity Catalog + Snowflake Horizon
    Warehouse-native governance goes GA. Column tags, row policies, and cost attribution ship as first-class primitives.
  6. 2024
    ‘FinOps for data’ becomes a job title
    As 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

Catalog
The registry: every table, its schema, owner, and description. Sources: DataHub, Unity Catalog, Amundsen, Snowflake Horizon.
who
Lineage
The graph: dataset → dataset (table), and optionally column → column. OpenLineage events, dbt exposures, Snowflake Access History.
where
Classification
Column tags: PII, sensitive, public, financial, PHI. Applied via automated scanners (dlp.google, Snowflake CLASSIFY) + human overrides.
what
Access policies
Row-level (‘analysts see only own region’) and column-level (‘mask email except for support role’) policies driven by tags.
guard
Cost attribution
Query tags → per-team warehouse cost. Snowflake QUERY_HISTORY + WAREHOUSE_METERING, BigQuery INFORMATION_SCHEMA.JOBS, Databricks system.billing.
$$$
Lifecycle & deletion
Retention rules per classification, plus a delete workflow that fans out to all lineage-connected datasets (including backups).
delete

Cost attribution: two-step pattern

11
Tag every query

`ALTER SESSION SET QUERY_TAG = 'team=ml/pipeline=lifetime_value/env=prod'`. Enforce at the driver layer, not by convention.

22
Nightly rollup

SUM(credits * unit_price) GROUP BY team, pipeline. Store in a `cost_by_team_daily` fact table.

33
Chargeback dashboard

One dashboard per team; company-wide leaderboard. Numbers reviewed monthly in FinOps sync.

44
Anomaly alerts

Any team crossing 2× 30-day average → auto Slack to team-owner. Prevents surprise invoices.

55
Actionable budgets

Every team gets a monthly credit budget. Overrun requires a ticket, not a shrug.

Governance modes: catalog-first vs producer-first

Catalog-first (crawl and hope)

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
Producer-first (contracts)

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
Hybrid (most companies land here)

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

Common misconception
✗ What most people think

"Governance is a compliance function. It's the tagging and access-request paperwork the security team makes us do, and it slows engineering down."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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.

From first principles
Start with the question

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.

  1. 1
    In 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
  2. 2
    Each 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
  3. 3
    Each 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
  4. 4
    So 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
  5. 5
    Reversing 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

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.

Mental modelEvery dataset has a landlord

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.
🔔 Fires when you see

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.

The tradeoff

Who controls data access and modelling standards: a central platform team, or the domain teams that produce the data?

Centralised governance
+ you gain consistent definitions, one place to audit, uniform quality standards, and genuinely comparable metrics across the company. Compliance is tractable because there is one implementation to certify.
− you pay the central team becomes the bottleneck for every change and has the least domain knowledge about any given dataset. Queue times grow with company size, and teams route around the process with shadow pipelines that are invisible and ungoverned.
pick when in regulated domains, or while the company is small enough that the central team can hold the whole domain model in its head
Federated / data mesh
+ you gain domain teams own their data products and can move at their own pace with the context to model correctly. Throughput scales with team count instead of being capped by one team's capacity.
− you pay definitions diverge — three teams produce three incompatible "active user" metrics, each locally correct. Governance quality becomes a function of each team's discipline, and the weakest team sets your compliance floor.
pick when when the central team's request queue is measurably blocking delivery and domains are genuinely separable with few cross-domain metrics
Federated execution, central standards
+ you gain domains own and publish their own data products, but against centrally-defined contracts: mandatory ownership and sensitivity tags, required tests, a shared semantic layer for cross-domain metrics. Autonomy in implementation, uniformity in interface.
− you pay requires real platform investment — the standards must be automatically enforced in CI, not written in a wiki. A standard that is documented but unenforced produces the cost of centralisation with the outcomes of federation.
pick when when you have a platform team that can build enforcement tooling rather than review pull requests by hand
What a senior engineer actually does

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.py

You 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

Column tags
Governance is a graph problem, not a text problem. Every column carries a set of tags that travel with lineage. This is the smallest useful data model for classification.
tags
LINEAGE = nx.DiGraph()
Column-level lineage is a directed graph — edges from source column to derived column. Every real tool (OpenLineage, dbt, Unity Catalog) reduces to this graph.
graph
emit_lineage() propagates tags
The one line that makes governance actually work: when a source is tagged PII, every derived column inherits the tag. No human curation, no drift.
propagate
gdpr_delete()
Uses `nx.descendants` to walk the graph from every PII column downstream. This is exactly how a real ‘right to erasure’ workflow finds affected tables.
delete
QueryRun.query_tag
Cost attribution starts here — no tag means no team. In Snowflake this is `ALTER SESSION SET QUERY_TAG`; in BigQuery it's labels; in Databricks it's tags on the cluster.
attribute
cost_by_team()
The rollup that every FinOps dashboard runs. Real version joins to a rate card (credits × $/credit or slot-hours × $/slot-hour).
rollup
Try itAdd row-level policy and a residency tag

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"))    # True

That'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.

💡 Hint · Extend Column to accept `residency: str = 'US'`. Then write a function `can_read(table, column, requester_region)` that returns False when residency='EU' and requester_region='US' unless the requester has role='admin'. This is the shape of every real access policy engine.

(d) Production reality · 15 min

War story Meta· 2023~$725M FTC settlement
🔥 What broke

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.

🧯 The fix
Meta rebuilt its data-lineage and consent enforcement platform (parts published as OSS ‘PurpleSky’ / internal ‘PDL’). Every access to a personal-data column now emits a policy check event with a purpose tag, and lineage covers hundreds of thousands of tables end-to-end.
🎓 Lesson to steal
Governance is not a compliance checkbox — it's the difference between a $50k fine and a nine-figure settlement. Instrument lineage before a regulator asks.
Post-mortem
War story A SaaS startup· 2023$180k unexpected monthly Snowflake bill
🔥 What broke
A new data scientist ran an unbounded 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.
🧯 The fix
They mandated `QUERY_TAG` at the driver layer for every environment (dbt, Airflow, notebooks, ad-hoc), added a daily $ threshold alert per user, and downgraded ad-hoc warehouses to a smaller size by default. Similar spikes now surface in Slack within an hour, not three days.
🎓 Lesson to steal
Every query without a tag is a mystery invoice waiting to happen. Enforce query tagging at the connection layer — never trust that developers will remember.
Post-mortem
War story Common failure mode · GDPR erasure editionevery company that runs backups
🔥 What broke
A user requests deletion. The engineer runs 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.
🧯 The fix

A GDPR-compliant delete needs a workflow, not a query:

  1. Look up every dataset containing the user (via lineage).
  2. Issue a delete/rewrite in each — including a Delta VACUUM on time-travel history.
  3. Purge from search indexes and caches.
  4. Mark backups for expedited expiry (or crypto-shredding if possible).
  5. Log completion with proof for the audit trail.
🎓 Lesson to steal
‘Delete’ in a data platform is a multi-hop distributed workflow, not a SQL statement. Lineage is the map that makes it possible.

Where this shows up in the rest of the plan

Governance is a cross-cutting skill that touches every downstream area
S052 · Lakehouse
Delta/Iceberg commits are the raw lineage events; catalog integrations read from them.
S053 · Data quality
DQ results become governance evidence — pass/fail per snapshot per table.
S059 · Auth & JWT
Column-level policies rely on the identity claims in your JWTs; governance is authZ on data.
S099 · MLOps
ML training on PII data must respect the same lineage and consent rules as analytics.
S102 · Data contracts
The upstream-enforcement pattern; governance shifted left to the producer.
S128 · Data platform capstone
Capstone architectures must produce a lineage + cost attribution deliverable, not just pipelines.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

Move on when you can teach these:

  1. What are the four things governance actually delivers, and what happens if you skip them?
  2. What is the difference between table-level and column-level lineage, and why does GDPR care?
  3. 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.