perche
git blame for data. Run a SQL query, point at any row of the result, and see the exact source rows that produced it, through every join, filter, GROUP BY, DISTINCT, CTE, UNION, INTERSECT and EXCEPT.
Perché is Italian for “why”.
Try it in your browser → Click any bar of a dashboard over 600k real New York taxi trips and see the rows behind it: trips dated 2002 in a January 2024 file, a 59,282-mile ride, a payment type the data dictionary doesn't define. Nothing to install: DuckDB-Wasm and perche run in the page.
$ perche "SELECT c.country, sum(o.amount) AS revenue
FROM 'orders.csv' o JOIN 'customers.csv' c ON c.id = o.customer_id
WHERE o.status = 'paid' GROUP BY c.country ORDER BY revenue DESC"
# country revenue ← source rows
─ ─────── ─────── ─────────────────────────────
0 IT 2212.78 orders.csv:5 customers.csv:3
1 FR 42 orders.csv:1 customers.csv:1
Italy looks suspiciously high. Why?
$ perche "…same query…" --row 0
row 0:
country revenue
─────── ───────
IT 2212.78
← orders.csv (5 rows)
rowid id customer_id amount status
───── ─── ─────────── ────── ──────
0 101 1 120 paid
1 102 2 80.5 paid
4 105 4 999.99 paid
5 106 4 999.99 paid ← the same order, loaded twice
6 107 2 12.3 paid
← customers.csv (3 rows)
rowid id name country
───── ── ───── ───────
0 1 Ada IT
1 2 Bruno IT
3 4 Dario IT
No server and no setup: perche runs on DuckDB, so the sources can be CSV, Parquet or JSON files, DuckDB tables, or anything DuckDB can read.
Install
pip install perche
Python 3.10+. The only dependencies are duckdb and sqlglot.
Command line
perche QUERY # the result, with how many source rows feed each row
perche QUERY --row N # the source rows behind row N
perche QUERY --row N --json # the same, as JSON
perche QUERY --db shop.duckdb # query tables in a DuckDB database (opened read-only)
perche QUERY --sql # print the rewritten query perche runs
perche - < query.sql # read the query from stdin
Pipelines
Real data rarely comes from one query. Put each step in its own .sql file; steps read files and each other by name:
examples/pipeline/
├── clean_orders.sql SELECT o.id, o.amount, c.country FROM '../orders.csv' o JOIN '../customers.csv' c … WHERE o.status = 'paid'
└── revenue.sql SELECT country, count(*) AS orders, sum(amount) AS revenue FROM clean_orders GROUP BY country
perche run runs the steps in dependency order and follows any row back through every step:
$ perche run examples/pipeline --row 0
revenue row 0:
country orders revenue
─────── ────── ───────
IT 5 2212.78
← step clean_orders (5 rows)
rowid id amount country
───── ─── ────── ───────
0 101 120 IT
1 107 12.3 IT
3 106 999.99 IT
4 102 80.5 IT
5 105 999.99 IT
← ../orders.csv (5 rows)
rowid id customer_id amount status
…
← ../customers.csv (3 rows)
…
Relative paths are resolved from the pipeline directory. With --db shop.duckdb the step tables are written to that database. From Python:
p = perche.Pipeline.from_dir("examples/pipeline").run() # or Pipeline({"step": "SELECT …"}, con)
p["revenue"].rows # each step is a Trace
p.roots("revenue", [0]) # {'../orders.csv': [0, 1, 4, 5, 6], '../customers.csv': [0, 1, 3]}
p.why("revenue", 0) # the full tree, as dicts
Python
import duckdb
import perche
con = duckdb.connect("shop.duckdb")
t = perche.trace("SELECT country, count(*) FROM customers GROUP BY 1", con)
t.rows # [('IT', 3), ('FR', 1)]
t.lineage_of(0) # {'customers': [0, 1, 3]} rowids, per source
t.why(0) # {'customers': [{'rowid': 0, 'id': 1, 'name': 'Ada', ...}, ...]}
t.counts() # [{'customers': 3}, {'customers': 1}]
t.lineage # lineage of every row (costly on big results)
How it works
The rewriting is plain Python on top of sqlglot and needs no database connection (perche.rewrite.rewrite(sql, tables)); that is how the browser demo runs it in Pyodide.
perche parses the query with sqlglot and rewrites it so that every row carries the ids of the source rows it came from:
- each table, file or view read by the query gets an extra column holding
[rowid]; - projections, filters, joins,
ORDER BYandLIMITpass those lists through; GROUP BY, aggregates,DISTINCT,UNIONandINTERSECTconcatenate the lists of the rows they fold together;EXCEPTkeeps only the left side's lists, since the right side's rows are the ones that did not match;- CTEs and subqueries are rewritten recursively; each reference to a CTE gets its own lineage columns, so self-joins work.
The rewritten query (see it with --sql) runs in DuckDB, and the result stays in a temporary table. Python only sees the ids of the rows you ask about.
Files, views and table functions have no stable rowid, so perche copies them once into a temporary table. For a file, the row id is the 0-based position of the data row.
This is lineage in the sense of Cui and Widom: for an aggregate row, every input row of its group; for a join row, one row per joined source.
Is it right?
The tests check perche against ground truth computed independently:
- Exact oracle. 300 random queries over a shop database with NULLs, orphans and duplicates (inner and left joins across up to three tables, AND/OR filters,
GROUP BY, global aggregates,DISTINCT). For each query the generator also writes an oracle query that collects the rowids by hand. Both must produce exactly the same lineage. - Metamorphic. Wrapping each query in a subquery, in a CTE, or in a
UNION ALLwith itself must not change any row's lineage;INTERSECT,UNIONandEXCEPTof a query with itself must match itsDISTINCT. - Pipelines. 150 random two-step pipelines must give each row the same original sources as the same logic written as one query with a CTE.
- Sufficiency. Re-running a query, composed with CTEs,
UNION,INTERSECTandEXCEPT, on only the lineage rows of a result row must produce that row again.
Breaking the engine on purpose shows the tests notice: dropping the lineage of joined tables fails 233 of them, and treating DISTINCT like a projection fails 63.
Cost
Measured on 1M orders joined with 50k customers on a 4-core machine, DuckDB 1.5 (python bench/overhead.py):
| query | plain | traced | |
|---|---|---|---|
| filter | 0.009 s | 0.017 s | 1.9× |
| join + group by (4 groups) | 0.020 s | 0.127 s | 6.2× |
| group by (50k groups) | 0.057 s | 0.230 s | 4.0× |
Lineage for an aggregate holds every input row id of its group, so a query that folds a billion rows into one number carries a billion ids.
Not tracked yet
perche raises UnsupportedQuery instead of guessing for INTERSECT ALL, EXCEPT ALL, recursive CTEs, DISTINCT ON and DISTINCT combined with aggregation.
These are accepted, but not everything they read shows up in the lineage:
- Subqueries in
WHERE,SELECTorHAVING(WHERE x IN (SELECT …), scalar subqueries): their rows are not part of the lineage. - Window functions: a row's lineage is its own input row, not the whole window.
VALUES,UNNESTandLATERALinFROMcontribute no rows.- Whole-row references such as
hash(t)orSELECT t FROM tsee perche's hidden lineage columns as extra fields, so their values change. Name the columns instead.
Prior art
Row-level provenance has been studied for years in database research. perche is a small, practical take on it, not a new idea:
- ProvSQL: PostgreSQL extension for provenance and probabilistic databases, with semiring provenance.
- Perm and GProM: provenance through query rewriting, the same approach perche uses.
- SmokedDuck: a DuckDB fork that captures lineage inside the engine, much faster than rewriting.
perche needs no database server, extension or fork. It works on files, and its goal is to answer one question quickly: where did this number come from?
Roadmap
- Lineage through subqueries in
WHERE(IN,EXISTS).
License
MIT
Metadata
Release files for perche 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| perche-0.1.0.tar.gz | 414.8 kB | With provenance | Details for perche-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| perche-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for perche-0.1.0-py3-none-any.whl |
Total release size: 432.6 kB
Release files / perche-0.1.0.tar.gz
| Download URL | perche-0.1.0.tar.gz |
|---|---|
| Size | 414.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ce3bd8facb2f467fcc38246fdfd6862110016a4467e8dd180598976df43c7099
|
|
BLAKE2b-256 checksum How to use checksums |
7207d53208eeeecbe563f6fccab6832407a7ec5e5ade2c8726c217b4692804d9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 11, 2026.
Transparency logRelease files / perche-0.1.0-py3-none-any.whl
| Download URL | perche-0.1.0-py3-none-any.whl |
|---|---|
| Size | 17.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1e248fecbafd5570d9bac089a748f9b58d8114f4ffd14747399e1c58cea86374
|
|
BLAKE2b-256 checksum How to use checksums |
4415ae8e6754f4943f68f938981f4fd9cc52df4959eff7a1c241872aee5027cd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 11, 2026.
Transparency logSecurity for perche 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for perche-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 11, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.