Skip to content
About
Main content

Lineage coverage: static parse against runtime log

Measure your machineNeeds Python with DuckDB, sqlglot

Problem

can be collected by reading code or by watching execution. A static parser sees every SQL string in the source, including branches that never run and dead code, but cannot see SQL assembled at run time from variables. A runtime log sees exactly what ran, including dynamic SQL, but only the branches that ran this time. Each misses what the other sees.

Take 20 SQL jobs (written for this lab: plain SQL, branches that run only on some days, dead code, and SQL built from strings). Extract table names with sqlglot from the SQL strings in the source. Then run the jobs on DuckDB and record the tables each executed statement actually scanned, from the query profile. Compute the edges found by both, by parsing only, and at runtime only, and each method's coverage.

The question. What share of the lineage edges does each method find, and what does each miss?


Predict

What share of all the lineage edges does parsing the code find?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    lineage-coverage-static-parse-against-runtime.py109 linesDownload
    View the code
    lineage-coverage-static-parse-against-runtime.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["duckdb", "sqlglot"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Lineage coverage: static parse against runtime.
    
    Twenty small jobs (written for this lab: plain SQL, branches that run only on
    some days, and SQL built from strings) read and write tables in DuckDB.
    Static lineage parses every SQL string literal in the jobs' source with
    sqlglot. Runtime lineage runs the jobs and reads the tables each executed
    statement actually scanned, from DuckDB's query profile. Compares the edges
    each method finds.
    """
    import argparse, ast, inspect, json, os, re, tempfile, textwrap
    import duckdb, sqlglot
    from sqlglot import exp
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--weekday", type=int, default=2, help="Day the jobs run on (0 = Monday); some branches run only on some days.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    SOURCES = ["orders", "customers", "payments", "refunds", "shipments", "products", "events_2025_03", "events_2025_04", "fx_rates", "regions"]
    
    def j01(run): run("CREATE OR REPLACE TABLE daily_orders AS SELECT * FROM orders")
    def j02(run): run("CREATE OR REPLACE TABLE order_customer AS SELECT o.*, c.region FROM orders o JOIN customers c USING (id)")
    def j03(run): run("CREATE OR REPLACE TABLE net_payments AS SELECT * FROM payments EXCEPT SELECT * FROM refunds")
    def j04(run): run("CREATE OR REPLACE TABLE late_shipments AS SELECT * FROM shipments WHERE x > 5")
    def j05(run, day=None):
        if args.weekday == 0: run("CREATE OR REPLACE TABLE weekly_products AS SELECT * FROM products")    # Mondays only
        else: run("CREATE OR REPLACE TABLE weekly_products AS SELECT * FROM regions")
    def j06(run):
        month = "2025_04"; run(f"CREATE OR REPLACE TABLE recent_events AS SELECT * FROM events_{month}")   # name built at run time
    def j07(run): run("CREATE OR REPLACE TABLE fx_orders AS SELECT * FROM orders JOIN fx_rates USING (id)")
    def j08(run):
        t = "pay" + "ments"; run("CREATE OR REPLACE TABLE pay_copy AS SELECT * FROM " + t)
    def j09(run): run("CREATE OR REPLACE TABLE region_rollup AS SELECT region, count(*) n FROM customers GROUP BY region")
    def j10(run):
        if False: run("CREATE OR REPLACE TABLE old_report AS SELECT * FROM refunds")                       # dead code
        run("CREATE OR REPLACE TABLE new_report AS SELECT * FROM shipments")
    def j11(run): run("CREATE OR REPLACE TABLE cust_orders AS WITH x AS (SELECT * FROM orders) SELECT * FROM x JOIN customers USING (id)")
    def j12(run):
        for m in ("2025_03", "2025_04"): run(f"INSERT INTO all_events SELECT * FROM events_{m}")
    def j13(run): run("CREATE OR REPLACE TABLE prod_regions AS SELECT * FROM products, regions")
    def j14(run):
        src = os.environ.get("J14_SOURCE", "orders"); run(f"CREATE OR REPLACE TABLE env_copy AS SELECT * FROM {src}")
    def j15(run): run("CREATE OR REPLACE TABLE refund_view AS SELECT * FROM refunds WHERE id IN (SELECT id FROM payments)")
    def j16(run):
        if args.weekday >= 5: run("CREATE OR REPLACE TABLE weekend_fx AS SELECT * FROM fx_rates")           # weekends only
    def j17(run): run("CREATE OR REPLACE TABLE ship_customer AS SELECT * FROM shipments JOIN customers USING (id)")
    def j18(run):
        cols = "id, x"; run("CREATE OR REPLACE TABLE slim_orders AS SELECT %s FROM orders" % cols)
    def j19(run): run("CREATE OR REPLACE TABLE product_copy AS SELECT * FROM products")
    def j20(run):
        tables = [t for t in SOURCES if t.startswith("events_")]
        run("CREATE OR REPLACE TABLE event_union AS " + " UNION ALL ".join(f"SELECT * FROM {t}" for t in tables))
    JOBS = [j01, j02, j03, j04, j05, j06, j07, j08, j09, j10, j11, j12, j13, j14, j15, j16, j17, j18, j19, j20]
    
    def tables_in(sql):
        try: tree = sqlglot.parse_one(sql, read="duckdb")
        except Exception: return None
        target = tree.this.find(exp.Table) if isinstance(tree, (exp.Create, exp.Insert)) else None
        ctes = {c.alias for c in tree.find_all(exp.CTE)}
        reads = {t.name for t in tree.find_all(exp.Table) if t is not target and t.name not in ctes}
        return (target.name if target else None), reads
    
    static, unparsed = set(), 0
    for job in JOBS:
        for node in ast.walk(ast.parse(textwrap.dedent(inspect.getsource(job)))):
            if isinstance(node, ast.Constant) and isinstance(node.value, str) and re.match(r"\s*(CREATE|INSERT|SELECT)", node.value):
                got = tables_in(node.value)
                if got is None or not got[0]: unparsed += 1; continue
                for src in got[1]: static.add((job.__name__, src, got[0]))
    
    work = tempfile.mkdtemp(); c = duckdb.connect(os.path.join(work, "lab.duckdb"))
    for t in SOURCES: c.execute(f"CREATE TABLE {t} AS SELECT i AS id, i % 10 AS x, 'r' || (i % 3) AS region FROM range(100) t(i)")
    c.execute("CREATE TABLE all_events AS SELECT * FROM events_2025_03 LIMIT 0")
    prof = os.path.join(work, "profile.json")
    c.execute("PRAGMA enable_profiling = 'json'"); c.execute(f"PRAGMA profiling_output = '{prof}'")
    runtime = set()
    def scanned(node, out):
        info = node.get("extra_info") or {}
        t = info.get("Table") if isinstance(info, dict) else None
        if t: out.add(t.split(".")[-1])                                       # catalog.schema.table -> table
        for ch in node.get("children", []): scanned(ch, out)
    for job in JOBS:
        def run(sql, job=job):
            c.execute(sql); target = tables_in(sql)[0]; reads = set(); scanned(json.load(open(prof)), reads)
            for src in reads - {target}: runtime.add((job.__name__, src, target))
        job(run)
    
    both, only_static, only_runtime = static & runtime, static - runtime, runtime - static
    rows = [{"method": "static parse", "edges": len(static), "coverage_pct": len(static) / len(static | runtime) * 100},
            {"method": "runtime log", "edges": len(runtime), "coverage_pct": len(runtime) / len(static | runtime) * 100}]
    detail = [{"edge": f"{s} → {t}", "job": j, "found_by": "both" if (j, s, t) in both else "parse only" if (j, s, t) in only_static else "runtime only"}
              for j, s, t in sorted(static | runtime)]
    summary = {"union": len(static | runtime), "both": len(both), "only_static": len(only_static), "only_runtime": len(only_runtime),
               "unparsed_strings": unparsed, "weekday": args.weekday}
    print(f"{len(static | runtime)} edges in all: both {len(both)}, parse only {len(only_static)}, runtime only {len(only_runtime)} ({unparsed} SQL strings the parser could not resolve)")
    for r in rows: print(f"  {r['method']:<13} {r['edges']:>3} edges, coverage {r['coverage_pct']:.0f}%")
    for d in detail:
        if d["found_by"] != "both": print(f"  {d['found_by']:<13} {d['job']}: {d['edge']}")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary, "tables": {"methods": rows, "edges": [d for d in detail if d['found_by'] != 'both']}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run lineage-coverage-static-parse-against-runtime.py
    uv run lineage-coverage-static-parse-against-runtime.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--weekday2 Day the jobs run on (0 = Monday); some branches run only on some days.

Our run

Coverage of the combined edges

Coverage of the combined edges
MethodEdgesCoverage
static parse2575.8%
runtime log2884.8%

Edges only one method found

Edges only one method found
Found byJobEdge
parse onlyj05products → weekly_products
parse onlyj06events_ → recent_events
runtime onlyj06events_2025_04 → recent_events
runtime onlyj08payments → pay_copy
parse onlyj10refunds → old_report
parse onlyj12events_ → all_events
runtime onlyj12events_2025_03 → all_events
runtime onlyj12events_2025_04 → all_events
runtime onlyj14orders → env_copy
parse onlyj16fx_rates → weekend_fx
runtime onlyj18orders → slim_orders
runtime onlyj20events_2025_03 → event_union
runtime onlyj20events_2025_04 → event_union
Reference runApple M2 · 8 cores · 8 GB
Output · uv run lineage-coverage-static-parse-against-runtime.py
33 edges in all: both 20, parse only 5, runtime only 8 (4 SQL strings the parser could not resolve)
  static parse   25 edges, coverage 76%
  runtime log    28 edges, coverage 85%
  parse only    j05: products → weekly_products
  parse only    j06: events_ → recent_events
  runtime only  j06: events_2025_04 → recent_events
  runtime only  j08: payments → pay_copy
  parse only    j10: refunds → old_report
  parse only    j12: events_ → all_events
  runtime only  j12: events_2025_03 → all_events
  runtime only  j12: events_2025_04 → all_events
  runtime only  j14: orders → env_copy
  parse only    j16: fx_rates → weekend_fx
  runtime only  j18: orders → slim_orders
  runtime only  j20: events_2025_03 → event_union
  runtime only  j20: events_2025_04 → event_union
macOS 26.6.2 · Python 3.13.3 · duckdb 1.5.6 · sqlglot 30.23.0 · run 2026-10-20

What we found

Of 33 edges in all, both methods found 20. Parsing found 5 that never ran (a Monday-only branch, a weekend-only branch, dead code) or were wrong (events_, a fragment of an f-string read as a table name); the runtime log found 8 that the parser could not see, all built from strings or environment variables. Coverage was 75.8% for parsing and 84.8% for the runtime log.


Read it

Neither method is lineage; each is a sample of it. over-reports (paths that exist in code) and misreads dynamic SQL; runtime capture under-reports (paths that did not run this time) but is exact about what did. Combining both, and flagging edges that only one sees, is the honest version.