provensql
Sound-by-construction semantic diff for SQL. Given two versions of a query, provensql tells you whether the edit could change the answer — and it is built so that it can never wrongly tell you "no change" when there was one.
$ provensql diff before.sql after.sql
DIFFERENT: counterexample found (instance 'with_null_row'): base returned 4 rows, head returned 5 rows
assuming: no catalog supplied -- witness assumes no NOT NULL/UNIQUE/FK constraints
beyond what the query text itself implies; verify against your actual schema
Why
Every team that ships SQL has had the "we changed the query and something broke" incident. sqlglot and sqlfluff give you a parser and a linter; dbt gives you tests you have to write yourself; an LLM judge will confidently tell you two queries are equivalent when they aren't (see Evaluation — this is not hypothetical). Nothing answers the actual question a reviewer has: will this change the output, for some input the tests didn't cover?
provensql answers that question the way a formal tool should: it returns one of four verdicts, and each one means exactly what it says.
Verdicts
| Verdict | Meaning | Backed by |
|---|---|---|
EQUIVALENT |
Proven identical output for any database instance | Canonical-form equality (Stage 2) or an SMT proof (Stage 3) |
SCHEMA_CHANGE |
Output columns differ in name, order, or type | Static schema comparison |
DIFFERENT |
A concrete database instance exists where the outputs diverge | An actual counterexample, executed and shown to you (Stage 4) |
UNKNOWN |
Outside what provensql can currently decide | Refusal, with a machine-readable reason code |
The one rule that matters more than any feature: provensql must never return EQUIVALENT unless it has actually proven it. This isn't a design goal stated in a doc somewhere — it's enforced in the type system. Verdict.different() raises ValueError if you call it without a witness, and there is no code path anywhere in the pipeline that can construct a false EQUIVALENT verdict. Undercoverage (UNKNOWN) is the correct, honest failure mode; a wrong answer is not.
How it works
parse (Stage 0) → canonicalize (Stage 1) → canonical-form match? → EQUIVALENT
→ schema differs? → SCHEMA_CHANGE
→ SMT proof (Stage 3) → EQUIVALENT
→ counterexample search → DIFFERENT (Stage 4)
→ nothing found → UNKNOWN
- Stage 0 — Parse & fragment check. Parses with
sqlglot(BigQuery dialect) and refuses (with a reason code) anything outside the supported fragment: window functions, recursive CTEs, nested ARRAY/STRUCT types, nondeterministic functions. - Stage 1 — Canonicalize. Identifier qualification plus the renderer's normalization only — both semantics-preserving. (A general constant-fold/boolean simplifier was deliberately removed after a trust-boundary test caught
sqlglot'ssimplify()producing a real falseEQUIVALENT; those equivalences now route through the SMT-validated Stage 3 instead. See docs/evaluation.md.) Qualification is best-effort, so a partial failure fails towardUNKNOWN, never toward a false match. - Stage 2 — Canonical equality. If both queries render identically after Stage 1, they're
EQUIVALENT. - Schema check. Static comparison of the output column list.
- Stage 3 — SMT proof. Requires the relational skeleton (tables/joins/group-by/order/limit) to match exactly, then compiles each
WHERE/HAVING/SELECT-list expression into Z3 terms and proves logical equivalence under SQL's three-valued NULL logic — catching rewrites like aCASE/COALESCEchange or aNOT (a <= 1)→a > 1predicate flip that Stage 2's exact-match can't see. All-or-nothing: if the skeleton doesn't match, or even one expression can't be proven equivalent, it abstains entirely rather than claim a partial result. Aggregates (COUNT,SUM, ...) aren't modeled semantically but are recognized as identical when textually identical, so reordering aHAVINGclause around an aggregate still proves out. - Stage 4 — Counterexample search. Generates small adversarial database instances (NULL rows, duplicate rows, empty tables, disjoint join keys) and executes both queries against them in DuckDB. A divergence is a proof of
DIFFERENT, complete with a replayable witness instance. Tried after Stage 3 since it's a weaker claim (an absence of a counterexample doesn't prove equivalence) and more expensive (actual execution vs. symbolic reasoning). - Runtime backstop. On the catalog-free path, every Stage 3
EQUIVALENTis re-checked by running Stage 4 on the same pair; if it finds a diverging instance, the proof and the search contradict each other, so the verdict fails safe toUNKNOWNrather than ship a falseEQUIVALENT. A bug in the SMT encoder or solver thus degrades coverage, never soundness.
Optional catalog
Without a catalog, provensql infers column types heuristically from how each column is used in the query (a literal comparison, a cast) and refuses to execute any call to a function it doesn't recognize. A --catalog schema.yml overrides this with ground truth:
tables:
orders:
columns:
id: INT64
status: STRING
udfs:
- mozfun.norm.diff_months
Catalog-declared UDFs get a deterministic stand-in registered in DuckDB (see provensql/udf_rewrite.py for why a stub is sound here even though it doesn't reproduce the UDF's real logic). Columns declared ARRAY/STRUCT cause a clean UNKNOWN rather than fabricated flat data.
Install
pip install provensql
provensql diff base.sql head.sql [--catalog schema.yml] [--json]
(Or pip install -e ".[dev]" from a clone to run the test suite.)
Exit codes are CI-friendly: 0 = proven safe, 1 = needs human review, 2 = proven or flagged as a behavior change. --json emits a machine-checkable audit certificate (verdict, reason, assumptions, and — for DIFFERENT — the replayable witness) for archival or PR automation.
Evaluation
Full methodology, results, and honest limitations: docs/evaluation.md.
provensql is evaluated against real commit history, not hand-picked examples. The methodology (see mining/):
- Mined 1,242 real
(before, after)SQL pairs from commits that modified a.sqlfile acrossmozilla/bigquery-etl,GoogleCloudPlatform/bigquery-utils, anddbt-labs/jaffle-shop-classic. - Auto-bucketed each pair by AST diff, then drew a 213-pair stratified sample.
- Hand-labeled all 213 pairs (
EQUIVALENT/DIFFERENT/SCHEMA_CHANGE/UNKNOWN) — independently, without seeing provensql's own verdict.
Corpus finding: 76% of real SQL edits are semantically consequential (DIFFERENT + SCHEMA_CHANGE), and every real WHERE/HAVING touch in the sample was substantive — a touched predicate was never just cosmetic. Full breakdown in mining/.
Tool results on the same 213 pairs (current state, M1+M2+M3):
| Coverage (definitive verdict reached) | 10.8% |
False EQUIVALENT count |
0 |
| Precision on decided cases | 87.0% |
Coverage is low, and that's the honest, expected shape of where this stands: most of the corpus never reaches Stage 3 or 4 at all, rejected at Stage 0 for constructs genuinely out of v0's scope (Jinja templating, BigQuery scripting, window functions). Of what does get through, Stage 3 adds real but modest coverage on top of Stage 2/4 — one additional real production query (a reserved-word backtick-quoting fix) proven equivalent by SMT where exact canonical matching didn't catch it. Small movement, honestly reported. Soundness — the number that actually matters — is clean throughout.
Mutation testing: does Stage 3 actually work?
The natural corpus barely exercises Stage 3 (see below), so its refactor-handling is measured the rigorous way instead — apply transformations whose ground-truth answer is known by construction to 421 real single-SELECT queries, via python mining/mutation_eval.py:
Equivalence-preserving rewrites (recall — want EQUIVALENT):
| Rewrite | Recall | Resolved by |
|---|---|---|
WHERE→ON pushdown (inner joins) |
100% (6/6) | Stage 3 |
Redundant DISTINCT elimination |
88% (43/49) | Stage 3 |
| Reorder inner-join chain | 75% (6/8) | Stage 3 |
Reorder WHERE conjuncts |
3% (2/79) | Stage 3 |
Swap = operands |
13% (46/348) | Stage 3 |
| Double negation | 7% (11/160) | Stage 3 |
The last three rows are deliberately, honestly low. They used to read 97/88/94% when simplify() collapsed the reorderings at Stage 2 — but simplify() was removed for soundness (it caused a live false EQUIVALENT), so they now route through the SMT prover, which abstains the moment an expression contains a function outside its modeled fragment. That drop is the measured price of removing an unsound shortcut; the join/DISTINCT rows — Stage 3's actual target capabilities — are unaffected.
Equivalence-breaking mutations (soundness — must NEVER be EQUIVALENT): across 511 mutations (flip a comparison operator, bump a literal, drop a conjunct, add a deduplicating DISTINCT), zero were wrongly certified equivalent — they land on DIFFERENT (with a witness) or UNKNOWN. Zero of 511 gives a one-sided Clopper–Pearson 95% upper bound of ≤ 0.58% on the true false-EQUIVALENT rate.
This is what an equivalence checker's evaluation should look like: high recall on the rewrite classes it targets, and a hard zero on the adversarial cases. It also caught a real limitation — Stage 3 was abstaining whenever any unchanged expression used a function outside the SMT fragment (TIMESTAMP_DIFF etc.), fixed by a sound "structurally identical expressions are trivially equivalent" fast path.
What the full corpus says about where the ceiling is
Running the same pipeline across all 1,242 mined pairs (not just the labeled 213) is nearly identical: 10.1% coverage, still zero false EQUIVALENT. That near-match is itself a finding — the small sample was representative, so sample size was never the bottleneck. Nor is Stage 3's capability: its join-type-substitution, join-reordering, and WHERE/ON pushdown reasoning fired on 3 of 1,242 pairs, all reserved-word quoting fixes; the join- and predicate-change buckets yielded zero proven-equivalent pairs, because those changes are genuine semantic edits, not the equivalence-preserving refactors Stage 3 proves. The ceiling is corpus composition: bigquery-etl is dominated by Jinja/scripting (Stage-0-rejected) and by real behavior changes. Exercising Stage 3's full range needs a refactor-heavy corpus (a dbt project's history, a SQL-formatter migration) — the reasoning is verified by the test suite regardless; this corpus just doesn't contain many cases that trigger it. Reproduce with python mining/full_corpus_eval.py.
Two DIFFERENT verdicts in this run disagree with the human label of EQUIVALENT. Both are the same pattern: an upstream source column was renamed and re-aliased back to the same output name, and the human labeler knew from external context that the rename preserved the data. provensql, reasoning only from the query text, cannot assume two differently-named columns hold identical data — that's a defensible default, not unsoundness, but it's a real limitation worth knowing: provensql currently has no way to declare "these two column names are known-equivalent under a rename," which would be a reasonable catalog extension.
The comparison that motivated this project
An LLM judge given the same pairs will confidently call classic traps equivalent: LEFT JOIN → JOIN with a nullable key, COUNT(x) → COUNT(*), NOT IN vs NOT EXISTS on a nullable column. Measured on the same 213 pairs, OpenAI gpt-5 reaches 85.9% accuracy and claims equivalence on 30 pairs — but 2 of those are false EQUIVALENTs (vs provensql's 0), the exact error that makes an LLM unsafe as a review gate. provensql either proves the divergence with a witness or honestly says it doesn't know. It never does the first thing. (Reproduce: python eval/baselines.py --openai-model gpt-5.)
Corpus & licensing
The mining/labeling tooling in this repo (mining/*.py) is original code under this project's Apache-2.0 license, with no encumbrance from the repos it targets — safe to point at any codebase. The mined data itself is not published here: mozilla/bigquery-etl is MPL-2.0, and redistributing verbatim source snippets extracted from it sits in a genuine gray area this project isn't going to resolve by assumption. If you want to reproduce the evaluation, mining/repos.txt lists the exact source repos — clone them and re-run the harness; the numbers above are reproducible from public history.
Roadmap
- Subquery unnesting and comma-join normalization — the highest-ROI fragment extensions (a scope study against the Cosette/Calcite academic benchmarks showed these, not predicate reasoning, are what currently block coverage there).
- Precision- and error-aware equivalence: sound reasoning about floating-point/decimal rounding and runtime errors (division-by-zero, overflow, CAST failure), which provensql currently abstains on — and which the entire proving frontier abstracts away.
- Column-rename lineage hints in the catalog (see the two-false-
DIFFERENTfinding above). - Additional dialects beyond BigQuery (the fragment is largely dialect-agnostic via
sqlglot; BigQuery was chosen for v0 because it has a free, credential-less sandbox that makes the eval corpus reproducible by anyone).
Contributing
Issues and PRs welcome. If you're extending Stage 0's supported fragment or Stage 1's canonicalization rules, the one hard requirement is in provensql/verdict.py: no change should make it possible to emit EQUIVALENT without an accompanying proof. tests/test_compare.py and tests/test_catalog.py have the current soundness invariants under test — add to them, don't relax them.
License
Apache 2.0 — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file provensql-0.1.1.tar.gz.
File metadata
- Download URL: provensql-0.1.1.tar.gz
- Upload date:
- Size: 55.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c4c154add9e1b63c75493932514c1215fdb4b59818db4ed73a140b14c7bf401d
|
|
| MD5 |
f859a76769576e453de81228f1265e7a
|
|
| BLAKE2b-256 |
692d6b3a1187d7d7b40bc43d31822c2fb654897c891943453faf1e478629ca6e
|
File details
Details for the file provensql-0.1.1-py3-none-any.whl.
File metadata
- Download URL: provensql-0.1.1-py3-none-any.whl
- Upload date:
- Size: 45.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e4e2167d2baac5dc15846317cbced2141524bcc74cf18b67b10ab6f8a229c764
|
|
| MD5 |
9bbe2e735dc41d3ccb51d3cc92428957
|
|
| BLAKE2b-256 |
7caab1174ce1459d19c4d8a8a140c41f6aa9f588fa81585d0ed402b3b61b0e6a
|