graincheck
Catch metric logic errors before they reach production.
graincheck is a deterministic analytical-SQL integrity layer. It maps a project's grain, joins, aggregations and declared dbt metadata to metric-level risk — then names which reported numbers each risk affects, what evidence the claim rests on, and the one query that settles it.
It never executes anything, and it does not prove correctness. A finding is a claim to verify, not a verdict. Every one of them carries three ratings that are never merged:
| Impact | how much it would matter if this is real |
| Evidence | what kind of thing establishes it — structural, metadata, heuristic, intent, runtime |
| Confidence | how far it should be trusted before anyone acts |
Two things graincheck refuses to do: report a model it could not read as clean, and print one
accuracy number across rules that behave nothing alike. diagnostics/rule_precision.py publishes
precision per rule; docs/FAILURE_MODES.md publishes each rule's known false positives.
pip install . # or: pip install sqlglot, to run from this directory
# scan a project
python -m graincheck scan ./target/compiled --manifest target/manifest.json \
--dialect snowflake --report review.html
# walk through one model
python -m graincheck explain fct_customer_revenue --path ./target/compiled
The problem it addresses
A model that has been wrong since the day it was written has a stable row count, a unique primary key, passing tests, a smooth history with no anomaly, and no diff because nobody is changing it. It runs clean and returns a plausible number.
graincheck reads one query at a time, with no history and no baseline, and asks whether the SQL computes what its name claims.
Where it sits alongside existing tooling
Most data-quality and observability systems operate on data behavior, expected properties, or change detection. graincheck focuses on static analytical SQL risk before execution. These are complementary, not competing.
| Tool class | Operates on | Examples |
|---|---|---|
| Observability | data behavior over time | Monte Carlo, Anomalo, Soda, Elementary |
| Diff / impact analysis | change between versions | Datafold, Recce, SQLMesh, audit-helper |
| CI linting and static checks | SQL text and structure | SQLFluff, Altimate, dbt-project-evaluator |
| Declarative tests | properties someone anticipated | dbt tests, Great Expectations |
| graincheck | analytical semantics of a single query | — |
Some of these overlap with graincheck. Altimate's dbt-tools ships static checks in the same territory, including a fan-out check; SQLFluff covers style and structure. graincheck's specific contribution is a small set of checks for aggregation semantics — which aggregates a given join actually multiplies, CTE grain resolution, additivity, and the severity/evidence separation described below. It is a focused addition to this category, not a replacement for it.
On
dbt-project-evaluator's "Model Fanout": that rule means a parent with 3+ leaf children in the DAG. It is not join fan-out and does not read model SQL. Different phenomenon, same word.
Three ratings, never collapsed into one
Conflating these is how static analysers lose their audience.
| Question it answers | Values | |
|---|---|---|
| Impact | How much would it matter if this is real? | high · medium · low |
| Evidence | What kind of thing establishes it? | structural · metadata · heuristic · intent · runtime |
| Confidence | How far should it be trusted before anyone acts? | verified · high · medium · low |
- structural — the syntax tree alone establishes the pattern. No assumption about your data or naming.
- metadata — established by the SQL plus your declared dbt uniqueness tests, CTE grain, or a resolved surrogate key. Exactly as strong as that metadata is.
- heuristic — inferred from column naming. A badly named additive column will be flagged; a well-named non-additive one will be missed.
- intent — whether this is wrong depends on what the metric is supposed to mean. Only a human can settle it.
- runtime — measured against your data by the verification query. The only level that is not an inference.
HIGH / HEURISTIC / LOW is a normal and useful combination: it would matter a lot if real, it rests
on a column name, and nobody should act on it without reading the model. Confidence is capped per
rule — JOIN_FANOUT can reach high, NON_ADDITIVE_SUM cannot — and a fan-out where the relation
simply has no test declared is reported at medium, because absence of evidence is not evidence
of a defect.
Checks
Impact is how much it would matter; the confidence ceiling is how far a finding from this rule can
ever be trusted. Measured precision per rule is in docs/FAILURE_MODES.md, with each rule's known
false positives written out.
| Rule | Impact | Evidence | Conf. ceiling | Finds |
|---|---|---|---|---|
JOIN_FANOUT |
high | metadata | high | An aggregate across a join whose key is not tested unique on the joined side |
FANOUT_INSIDE_CTE |
high | metadata | high | An aggregate over a CTE whose rows were already multiplied by a join inside it, one or two steps earlier, where there was no aggregate to flag |
JOIN_KEY_CASE_MISMATCH |
high | structural | medium | A join key UPPER()/LOWER()-cased on one side and read as-is on the other, traced across every model the column passes through |
OUTER_JOIN_FILTER_IN_WHERE |
high | structural | high | A LEFT JOIN silently converted to INNER by a WHERE predicate |
AVG_OF_RATIOS |
high | structural | medium | AVG(a/b) where the business means SUM(a)/SUM(b) |
IMPLICIT_CROSS_JOIN |
high | structural | high | A comma join with no condition. An explicit CROSS JOIN is noted at low/intent; UNNEST / LATERAL is not a cross join at all and is ignored |
NON_ADDITIVE_SUM |
high | heuristic | low | SUM() of a rate, ratio, percentage or margin |
COUNT_STAR_ACROSS_JOIN |
medium | structural | medium | COUNT(*) counting result rows rather than entities |
SEMI_ADDITIVE_SUM |
medium | heuristic | low | SUM() of a balance or level across time |
BETWEEN_ON_TIMESTAMP |
medium | heuristic | low | Inclusive upper bound dropping the final day |
UNGUARDED_DIVISION |
low | structural | low | Division with no NULLIF guard |
DISTINCT_MASKS_FANOUT |
low | intent | medium | SELECT DISTINCT papering over a grain problem |
Twelve rules, deliberately. The roadmap is to make these trustworthy, not to reach a hundred —
and five of them (JOIN_FANOUT, OUTER_JOIN_FILTER_IN_WHERE, COUNT_STAR_ACROSS_JOIN,
AVG_OF_RATIOS, NON_ADDITIVE_SUM) are the ones being made excellent first.
diagnostics/rule_precision.py grades every rule against five kinds of case — obvious positives,
borderline positives, obvious negatives, near-miss negatives, and real-world examples — and
prints precision and recall per rule. It deliberately prints no total: a rule with 100% precision
over four cases and one with 90% over forty are not the same claim, and one average hides both. A
rule with no near-miss negatives is marked UNTESTED however good its score looks. Building it found
three real false positives that reading the code had not: SUM(rate_card_amount),
SUM(balance_change) and SELECT DISTINCT <one column> over a join.
It reads the whole project, not one file at a time
Three of the four defects found by hand in Fivetran's dbt_shopify were invisible to a checker that
looks at one file in isolation, and finding them by hand is what produced graincheck/project.py:
- Grain across
select * from ref(...). Every dbt model opens withorders as (select * from {{ ref('shopify_gql__orders') }}). That CTE has noGROUP BY, so a single-file view can only say "grain unknown" and report every join to it. The grain is knowable: the staging model three hops upstream tests a surrogate key, the hash's inputs are(order_id, source_relation), and every model in between passes the rows through. graincheck now follows that chain — through renames (id as order_id), throughGROUP BY, through arow_number() = 1orQUALIFYde-duplication, through a pass-through macro model — and reports the provenance in the finding ("inherited: tested unique instg_shopify_gql__order"). Fifteen medium findings ondbt_shopifywere this, and every one of them was noise. - Multiplication introduced in a CTE that does not aggregate (
FANOUT_INSIDE_CTE). The join that breaks the grain is frequently not in the sameSELECTas the aggregate it corrupts. Every join in the aggregating step can be correct while a plain CTE two steps earlier already repeated the rows. - A join key normalised on one side only (
JOIN_KEY_CASE_MISMATCH).upper(code)in three staging models, plaincodein a fourth, and a join between them five models later. Nothing about a single file reveals it; the column has to be traced back to where its case is decided.
Everything it cannot establish stays unknown and silent: an unreadable macro model, an ambiguous
select * over several relations, a UNION whose branches disagree, a PIVOT. That is the whole
design — a lineage guess that turns an unproven join into a proven one would delete a true finding,
which is worse than saying nothing.
Reviewed exceptions, not ignores
The first real project will contain a SUM(customer_score) that is additive on purpose. If the only
available answer is "turn the rule off", the knowledge of why it is fine dies with the
conversation.
# graincheck.yml
graincheck:
exceptions:
- rule: NON_ADDITIVE_SUM
model: fct_customer_scores
reason: score is a points total, not a rate — additive by design
owner: analytics-platform
expires: 2027-01-01 # optional
A suppressed finding is moved, not deleted: it reappears under Reviewed exceptions with the
reason and the owner. An exception with no reason is refused. One that matches nothing this run is
reported as STALE, and one past its expires date stops suppressing. That turns an ignore list into
a small piece of governance history.
Six measured case studies
examples/case_studies.py executes both the flagged query and the corrected one for each pattern and
measures the difference. Nothing below is asserted — it is all computed at run time.
| # | Pattern | Question asked | Reported | Actual | Error |
|---|---|---|---|---|---|
| 1 | Join fan-out inflates a count | How many orders per customer? | 113 | 99 | +14.1% |
| 2 | WHERE converts LEFT JOIN to INNER | How many customers, excluding returns? | 60 | 100 | −40.0% |
| 3 | Average of ratios | What is our average order value? | 1,646.08 | 1,688.89 | −2.5% |
| 4 | Summing a stored percentage | What was our margin this year? | 1,092 | 39.81 | +2643.3% |
| 5 | BETWEEN on a timestamp | What did we take in March? | 23,348.32 | 24,127.93 | −3.2% |
| 6 | Summing a balance across time | What total balance do we hold? | 8,164,500 | 692,750 | +1078.6% |
Cases 1–3 run on dbt Labs' published jaffle_shop seed CSVs. Cases 4–6 run on generated data, because no
public seed set contains a stored margin percentage, a timestamp column or a monthly balance snapshot; each
case prints its construction in full.
How to read the detection rate. These six cases were chosen to illustrate the six patterns graincheck checks for, so detecting all six is close to tautological and is not a measure of recall on unseen code. What the file establishes is the part that is not obvious: that each flagged shape corresponds to a real, measurable error in an executed query, and how large that error is.
Case 1 in full — the one worth reading
The query an analyst writes when asked "revenue and order count per customer":
SELECT c.id AS customer_id,
SUM(p.amount) AS total_revenue,
COUNT(*) AS order_count
FROM raw_customers c
JOIN raw_orders o ON o.user_id = c.id
JOIN raw_payments p ON p.order_id = o.id
GROUP BY 1
It runs without error and returns entirely normal-looking numbers. 13 of the 99 orders carry more than one payment row, up to 3.
graincheck, before anything is executed:
[HIGH /metadata ] JOIN_FANOUT COUNT computed across a join to raw_payments on ['order_id']
[MEDIUM/structural] COUNT_STAR_ACROSS_JOIN
Executed:
total revenue flagged 167,200.00 corrected 167,200.00 identical
order count flagged 113 corrected 99 OVERSTATED 14.1%
The precision is the point. SUM(payments.amount) is correct despite the fan-out, because each
payment row still appears exactly once. COUNT(*) counts result rows and is wrong by 14%. graincheck names
only the COUNT — a tool that flagged both equally would be crying wolf on half its findings.
That distinction did not exist until the script was run against real data. It lives in
_agg_is_multiplied: after a fan-out, every result row corresponds to one row of the fanning table, so
an aggregate over that table's columns sees each row once, while an aggregate over any other table has
its rows repeated. The first version of the rule used join order instead — "multiplied if it entered
before the fanning table" — which was an artifact of this example, where every other table happens to
precede it. Reading Fivetran's dbt_shopify found the other shape and corrected it.
Run against real production dbt code
Five Fivetran dbt packages — real analytics models deployed at thousands of companies:
| Package | Models | Examined | Findings | High | Skipped: no SQL in the file | Skipped: parse error |
|---|---|---|---|---|---|---|
dbt_shopify |
264 | 147 | 29 | 6 | 105 | 12 |
dbt_netsuite |
108 | 71 | 1 | 0 | 27 | 10 |
dbt_salesforce |
28 | 21 | 10 | 0 | 0 | 7 |
dbt_hubspot |
205 | 83 | 4 | 0 | 70 | 52 |
dbt_stripe |
69 | 21 | 12 | 0 | 25 | 23 |
Read the "Examined" column before the "Findings" column. A file that was not examined is
reported, never silently passed, and graincheck tells you to run dbt compile and scan
target/compiled/ for a complete review. On the compiled output of jaffle_shop, 25 of 25 models
are examined.
The "no SQL in the file" column is the larger one, and it is there because of a defect found while
verifying something else. A Fivetran staging model is often nothing but
{{ fivetran_utils.union_data(...) }} — the SQL lives in the macro, and de-rendering leaves an empty
string. Those files used to return no findings, no error: counted as parsed, filed as clean, with
not one line of their SQL read. Across a 1,320-model production corpus that was 402 models (30%).
They are now reported as unexamined with the reason, which is why these counts are lower than earlier
versions of this README claimed. Lower and true beats higher and wrong.
De-rendering used to be the binding constraint on everything this tool could say. Across a 1,193-model
corpus of thirteen public dbt packages the parse rate was 10.8% — 1,064 files unexamined — because
every {{ ... }} expression was replaced with the scalar 1, without regard for where it sat. That
turned cast(x as {{ dbt.type_string() }}) into cast(x as 1), which is invalid in every dialect, and
partition by email {{ partition_by_source_relation() }} into two expressions with no comma between
them. Substitution is now position-aware — type macros become a type, column-list macros become *,
clause-continuation macros become nothing, and {{ dbt_utils.group_by(n=3) }} becomes a real
GROUP BY 1, 2, 3 (which the CTE-grain logic then reads). A ref() buried inside a larger expression
— from {{ ref('a') if metafields_enabled else ref('b') }} — now yields the relation name rather than
a bare 1 in a FROM clause, and an unresolvable macro in a relation position becomes an identifier
instead of a guaranteed ParseError.
That change is what surfaced three further instances of the same fan-out pattern in dbt_shopify,
including two in the REST model family that had never been parsed at all.
sqlglot is permissive in the same direction: !!! not sql parses happily into a chain of NOT
expressions, and a de-rendered macro file often parses into something that is not a query. Those files
used to come back as parsed, zero findings too. The scanner now checks that a parsed file actually
contains a query node and reports it as unexamined when it does not.
Running on real code also produced the scanner's most important fix. The first version treated every CTE as
an untested table and produced 56 fan-out findings on one package, nearly all noise. It now resolves CTE
grain: a CTE built with GROUP BY customer_id is one row per customer by construction, and joining on that
key is silent. Findings on dbt_shopify fell from 67 to 31, and the survivors are specific — for example a
join to a CTE grouped by [kind, order_id, source_relation] on only [order_id, source_relation], where an
order with several transaction kinds multiplies the aggregate.
That finding is a question for a maintainer, not a proven bug. Treat every finding that way.
The false positive that was caught before it was sent
Hand-checking the high-severity findings against the actual Fivetran source found one that was simply
wrong. In int_shopify_gql__discounts_abandoned_checkouts.sql:
LEFT JOIN discount_application
ON abandoned_checkout_discount_code.code = discount_application.code
AND abandoned_checkout_discount_code.source_relation = discount_application.source_relation
WHERE COALESCE(discount_application.value_type, '') != ''
graincheck flagged it as a LEFT JOIN neutralised by a WHERE clause, with the reason "unmatched rows
have NULL there and fail the comparison." That reason is false. COALESCE turns the NULL into
'', the comparison evaluates to FALSE rather than NULL, and the row is dropped because the author
decided it should be. The COALESCE is the author explicitly saying I know these are NULL and I have
handled it.
The outcome resembles an INNER JOIN, but reporting it would have been worse than reporting nothing: a
maintainer who catches a wrong reason stops trusting every other finding in the document. The rule now
stays silent when the outer-joined column is wrapped in COALESCE, IFNULL, NVL, IIF or a CASE.
Checking the same rule from the other side exposed the opposite bug: exp.In is not an exp.Binary
node in sqlglot, so LEFT JOIN o ... WHERE o.status IN ('a','b') — the same defect, written the way
analysts most often write it — was never detected at all. Both directions now have tests.
This is what the "make ten rules trustworthy rather than reach a hundred" roadmap actually looks like in practice: one afternoon of reading real SQL, one false positive removed, one whole class of true positives gained.
coverage — how much of this can you actually defend
$ python -m graincheck coverage ./models
PROJECT INTEGRITY
Models found 264
fully examined 147
partially examined 0 (a join in these could not be resolved)
not examined at all 117 (unparsed, or all SQL inside macros)
Joins onto a relation 85
eligible (have a key) 83
verified 17
not established 12 (a declared key exists and the join does not use it)
unknown 54 (no uniqueness test declared at all)
no condition at all 2 (cross products)
unresolved 0 (onto a subquery)
JOIN SAFETY COVERAGE 20.5%
METRIC PATH COVERAGE 64.8% (212 of 327 aggregate output columns)
Join keys carrying the most traffic without an established grain:
5 join(s) [UNKNOWN] stg_shopify__order on ['order_id', 'source_relation']
...
On dbt_shopify that is 17 of 83 join keys (20.5%) proven unique by a declared test, and 212 of
327 reported numbers whose join path is fully established.
The three-way split matters more than the percentage. unknown means no uniqueness test is declared at all — absence of evidence, and the key may well be unique. not established means the project does declare a key and the join uses a different one, which is the project contradicting itself. Collapsing those two into "bad" overstates what is known, and a reviewer who spots that stops trusting the rest of the report.
Both formulas are printed underneath the numbers, every run, because a coverage figure without its denominator can be made to mean anything.
A finding answers is this model wrong. This answers the question a lead asks before funding any of it: how much of what we join on has a test behind it, and how much is on trust?
The gap it measures is specific, and it is not an accident of any one project. dbt convention is to test uniqueness on a surrogate key in the output layer:
{{ dbt_utils.generate_surrogate_key(['id', 'source_relation']) }} as unique_key -- tested unique
while every downstream model joins on the natural key — (order_id, source_relation) — usually through
an intermediate model that carries no test at all. graincheck resolves the surrogate back through the
column alias (id as order_id) so a test on unique_key correctly proves that join safe. What is
left after that resolution is the real gap.
Across 20 published dbt packages (Fivetran, Velir, dbt-labs and others), 48 of 356 join keys — 13.5% — are proven unique by a declared test. The other 308 are assumptions nobody has checked.
(It was 48 of 334 until a de-rendering bug found on Windows was fixed: normalising line endings and correcting one substitution rule made four more models parse across the corpus, which added 22 join edges to the denominator without adding any proven keys. The measured gap got slightly worse because more of the code became visible, which is the direction this number should be expected to move.)
That number was produced three times before it was written here: once from the syntax tree plus a YAML parser, once from pure text regexes sharing no code with the first, and once by graincheck itself. The first two agreed on 259 of 263 joins they both found. Reconciling the four disagreements is what turned up three defects — a composite uniqueness test being flattened into a bag of column names, one malformed schema file silently deleting every test declared in it, and surrogate keys not being resolved at all. Before those fixes the same measurement read 9.7%, and an earlier flat-set version of it read zero. The two-implementation comparison is the only reason any of that was caught.
False comfort — the answer to "we already run dbt-project-evaluator"
dbt-project-evaluator asks does this model have a primary key test? graincheck asks is the key we join on proven unique? Those are different questions, and it answers its own correctly. The gap between them is measurable:
FALSE COMFORT
12 join(s) land on a model that PASSES the project's primary-key test requirement while
joining it on a key the project never proves unique.
3 join(s) shopify_gql__orders
badge earned on : ['unique_key']
joined on : ['order_id', 'source_relation']
in : int_shopify_gql__daily_orders, int_shopify_gql__discounts_order_aggregates, …
The incumbent's rule is reimplemented from its own int_model_test_summary.sql — some column with
both unique and not_null, or a combination test — so the comparison is against what it would
actually say rather than a paraphrase.
--emit-tests — the patch, not just the report
Every competitor reports. None hands back the fix. The fix is completely determined by what was already measured:
# 5 model(s) join `stg_shopify__order` on ['order_id', 'source_relation'] and nothing proves it unique.
# Relied on by: int_shopify__inventory_level__aggregates, shopify__line_item_enhanced, …
- name: stg_shopify__order
tests:
- dbt_utils.unique_combination_of_columns:
combination_of_columns:
- order_id
- source_relation
Only ever a test, never a rewrite of a model. A test is safe whichever way the answer turns out: if the key is unique it passes forever and a guarantee the project was already relying on becomes explicit; if it is not, it fails on the next run and a silent fan-out becomes a loud one. Nobody's numbers change on the strength of a static tool's opinion.
--history — coverage as a trend
COVERAGE OVER TIME
when commit join safety metric path eligible verified
2026-09-10T09:14:02 a91f3c0 63.0% 70.0% 100 63
2026-09-17T16:15:27 03e91d7 61.0% 68.0% 110 67
Since the previous run:
join safety coverage 61.0% (-2.0 ↓)
join edges +10 of which unproven: +6
Test coverage became a number teams manage on the day it became a line on a chart. One appended JSON Lines row per run, append-only — a coverage number that can be edited after the fact is not one anyone should trust.
explain — one model, read out loud
$ python -m graincheck explain fct_customer_revenue --path ./models
MODEL GRAIN
one row per customer_id, name
JOIN PATH
FROM stg_orders AS o
LEFT JOIN stg_customers AS c ON customer_id
-> safe. ['customer_id'] is tested unique on this table.
LEFT JOIN stg_order_items AS oi ON order_id
-> CAN FAN OUT. This table is tested unique on ['order_item_id'], not the join key.
AGGREGATES PRODUCED
revenue = SUM(o.order_total) margin = SUM(o.gross_margin_pct)
avg_margin = AVG(o.profit / o.order_total) order_count = COUNT(*)
RISK
1. [HIGH / STRUCTURAL] AVG_OF_RATIOS
2. [HIGH / METADATA] JOIN_FANOUT
3. [HIGH / HEURISTIC] NON_ADDITIVE_SUM
...
LIKELY EFFECT / VERIFY / RECOMMENDED REMEDIATION follow, per finding.
Tested beyond the packages it was built on
The rules were developed against Fivetran's dbt packages, which is a real risk: a checker tuned on one
publisher's house style can be quietly overfitted to it. So it is also run against eight unrelated
projects — Velir's dbt-ga4, dbt Labs' snowplow and jaffle-shop-classic, Elementary's
dbt-data-reliability, dbt-date, dbt-external-tables, dbt_metrics, and a community Spotify
project.
That immediately found the worst false positive in the tool's history. dbt-ga4 flagged nine
high-severity cartesian products, all of them this line:
FROM events, UNNEST(items)
UNNEST expands an array within each row. It is the most common idiom in BigQuery and it multiplies
nothing. Nine alarms on a package's most ordinary code would have ended any engagement in a minute.
UNNEST and LATERAL FLATTEN are now recognised as row-correlated expansions and ignored.
The same scan exposed a second bug directly beneath it: BigQuery's parser normalises FROM a, b to
kind='CROSS', so the "an explicit CROSS JOIN means the author meant it" downgrade was excusing
genuine comma-join mistakes — on the one dialect where nested data makes that mistake easiest to make.
The check now reads the source text instead of trusting the parse tree's kind.
Across those eight projects the tool now reports 8 findings in total, none high-severity. That is the right shape for well-maintained code, and it is the number to watch: if a rule change makes it jump, the rule got noisier rather than smarter.
Stated limits
- These are checks, not proofs. A clean result means none of the checked patterns are present. It is not a statement that the model is correct.
- It does not execute anything. It can tell you a shape is risky; it cannot size the error without your
data. That is what the
verifyline on each finding is for. NON_ADDITIVE_SUM,SEMI_ADDITIVE_SUMandBETWEEN_ON_TIMESTAMPread column names. They are markedheuristicevidence for exactly that reason.- Jinja is de-rendered, not compiled.
ref()andsource()become table names; other expressions become a stand-in chosen from their position. On macro-driven projects most models cannot be examined from source — measured at 147 of 264 ondbt_shopify, where 105 models contain no SQL at all outside their macros. Scantarget/compiled/for a real assessment; on compiledjaffle_shopit is 25 of 25. - A file that was not examined is reported, never silently passed. That includes the file that de-renders to nothing because its query lives in a macro — the case that used to be filed as clean.
- Join-key coverage is a statement about declared tests, not about your data. A key with no test may
well be unique. The point is that nothing in the project says so, and a green
dbt testrun is not evidence either way. - It cannot know intent. Whether a filter was requested is a question for a human. Those findings are
marked
intentevidence. - An aggregate over the joined table's own columns is assumed safe. After a fan-out, each of that
table's rows normally appears once, which is why
SUM(payments.amount)is correctly left alone in the jaffle_shop example above. The assumption breaks when the join key is a non-key on both sides —customers c LEFT JOIN t ON t.region = c.regionrepeats everytrow once per customer in that region, and graincheck stays silent. That case is structurally identical to the safe one; the only difference is whether the left side is unique on the join key, which is a property of the data rather than of the query. Flagging it would fire on every correct detail-to-parent join in a project, so the rule stays quiet and the gap is stated here instead. It is pinned by a named test. - Cross-model lineage is textual, not compiled. The project index parses each model's de-rendered SQL, so a model whose query lives inside a macro is opaque to it, and a chain that passes through one stops there. Opaque is treated as unknown: no finding is produced from it either way.
- Functional dependencies are invisible. A CTE grouped by
(date, feed_key, feed_name, org_name)is one row per(date, feed_key)if the names are attributes of the feed — but nothing in the SQL says so, so a join on(date, feed_key)is still reported. graincheck only reduces such a grain when the pre-aggregation rows are provably unique on a subset of the grouping columns. - No moat is claimed. This is ~1,000 lines built on open-source sqlglot, and the taxonomy it uses is published. The value is in the precision of the checks and the quality of the review around them, not in anything that cannot be rebuilt.
Tests and the health check
python -m pytest tests -q # 375 unit tests
python diagnostics/healthcheck.py # the full adversarial pass
python diagnostics/healthcheck.py --quick # skip the corpus regression
python diagnostics/healthcheck.py --corpus ~/dbt-repos # regress against real projects
Every rule is tested twice: once on SQL that must trigger it, once on realistic SQL that must not. The
negative cases matter more than the positive ones — one of them is what caught the SUM/COUNT distinction
above.
diagnostics/healthcheck.py is the gate before handing a report to anyone. It exits non-zero unless all
sixteen checks pass, and it is adversarial rather than confirmatory — it tries to break the tool:
| Check | What it does | |
|---|---|---|
| A | Imports | Byte-compiles every file, imports every module, confirms the documented public API exists |
| B | Lint | ruff (correctness, bugbear, comprehension, return, perf) across the whole repo |
| C | Unit tests | The suite plus line coverage, failing below 90% |
| D | Fuzz | ~50 hostile inputs plus 600 seeded random mutations; nothing may raise |
| E | Dialects | 11 dialects × 48 fixtures = 528 combinations |
| F | False positives | 18 known-correct queries that must produce zero findings |
| G | True positives | Every rule must still fire on the defect it exists to catch |
| H | Determinism | 5 runs × 4 output formats, byte-identical |
| I | Report integrity | HTML well-formedness, and 6 injection payloads across 5 surfaces |
| J | CLI | 26 invocations: every flag, all three subcommands, exit codes, failure paths |
| K | Performance | Throughput, and a ceiling on worst-case single-file time |
| L | Corpus regression | 35 real public dbt projects diffed against a locked baseline |
| M | Per-rule precision | Every rule graded on its own case set; any false positive or false negative fails the build, and a rule with no near-miss negatives fails as untested |
| N | Install | pip install into a fresh venv, then run the console script from outside the repo |
| O | Console encoding | Every subcommand under cp437, cp1252, ascii, latin-1 and cp850, strict |
| P | Unseen repositories | The whole CLI end-to-end on real third-party dbt projects: no traceback, no hang, no empty output |
Check D is there because SQL in the wild is hostile: files saved as Latin-1, macro output that de-renders into nothing, a 200,000-character string literal. Check F is the one that decides whether anyone keeps using the tool — a scanner that flags correct models gets ignored within a day. Check L holds a content fingerprint of every finding on 35 real projects, not just a count, so a change that moves findings around while keeping the total the same still fails.
Why N, O and P exist, and what that says about the twelve above them
For a long time this health check reported HEALTHY on every run while real defects kept being found by
hand. That was structural, not bad luck. Checks A–M all run python -m graincheck from inside this
directory, on Linux, in a UTF-8 locale, against SQL written here. So they can only ever answer one
question: did I break something I already knew about? They are good at that and they are blind to
everything else.
A stress pass outside those assumptions, plus the first run on Windows, found SEVEN defects, none of which any check above could see:
| What was wrong | What a user would have seen | |
|---|---|---|
| packaging | pyproject.toml declared no packages, so setuptools' flat-layout discovery found both graincheck and diagnostics and refused to guess |
pip install failed on every Python version. It had never once worked |
| dependency | PyYAML is imported by three modules, was never declared, and each ImportError was swallowed with return {} |
A clean install read no schema.yml and said nothing. On a four-line project it turned 0 findings into 1 high-impact JOIN_FANOUT — a confident wrong answer |
| encoding | cmd.exe's default US codepage is cp437, which has no em dash, and every message here contains one |
UnicodeEncodeError and exit 1 on a correct project, before a single finding printed |
| dialect | --dialect postgress does not raise; every model then fails to parse |
"Found 0 issues", exit 0 — a green CI build on a project that was never read |
| manifest | a --manifest that is not JSON |
JSONDecodeError traceback, losing a finished scan |
| interrupt | no KeyboardInterrupt handler |
a twenty-line traceback when you press Ctrl-C on a ninety-second scan |
| line endings | a fixed-width lookback window in _jinja_substitute, where a CRLF costs one character of context |
The examined/unexamined split depended on whether git converted the line endings. 18 models parsed on Linux, 20 on Windows, same commit |
The dialect one is the worst thing this tool has ever done. graincheck's whole argument is that a silent pass is more dangerous than a loud error, and a one-letter typo made graincheck itself produce the most convincing silent pass available: zero findings, exit zero. It now refuses to start.
All seven are fixed, each has a test, and N/O/P are ship gates so the class cannot come back. A new
test walks the package's own imports and fails if any is missing from pyproject.toml, and another
asserts that LF, CRLF and CR inputs produce identical findings.
Windows: verified on 19 September 2026, Python 3.12.4, Windows AMD64 — and it found a seventh defect that no amount of Linux testing would have produced.
dbt_stripe parsed 20 of 69 models on a Windows checkout where the same commit on Linux parsed
18. Same sqlglot version, same files. The cause: _jinja_substitute picks a stand-in for each Jinja
block by reading a fixed-width window of the characters before it, and a CRLF spends two characters
where LF spends one. When the character pushed off the end of that window was the comma ending the
previous SELECT column, the substitution flipped from 1 to nothing — and 1 immediately followed by
an identifier is a ParseError that costs the whole model.
Findings were identical, so nothing looked wrong. But "unparsed is unexamined, not clean" is this
tool's central claim, and it had been quietly depending on whether git converted the line endings.
derender now normalises them first, so every platform reads the same bytes. Fixing the substitution
rule underneath it made both platforms parse those files: 21 of 69, better than either got before.
The other Windows failure was a test of mine, not the tool: it shelled out to head, which does not
exist there. Rewritten to use Python.
Run it yourself with docs/WINDOWS_VERIFY.md or Verify-Graincheck.ps1.
What the project-wide pass changed (23 September 2026)
The whole-project lineage described above was not planned. It came from four defects found by hand in
Fivetran's dbt_shopify after graincheck had already scanned it and said nothing about three of them.
What it now finds that it could not before — each one verified against data, on DuckDB, using the package's own integration-test seeds:
| Model | Defect | Measured |
|---|---|---|
int_shopify_gql__discounts_abandoned_checkouts |
An un-aggregated CTE joins checkout codes to discount_application on code alone, so each checkout repeats once per ORDER that used the code |
one checkout's 0.50 discount reported as 1.50; item total 15.50 as 46.50 |
shopify_gql__discounts, shopify__discounts |
upper(code) on the order and checkout sides, plain code on the redeem-code side, joined on code |
the same code stored lower-case: 0 orders and 0.00; stored upper-case: 1 order and 10.00 |
int_shopify_gql__inventory_level_aggregates |
Order LINES joined to FULFILMENTS on order_id, so an order shipped in two parts repeats every line |
quantity_sold 2 -> 4, subtotal_sold 17.99 -> 35.98 for one order with two fulfilments |
What it stopped saying. The same pass removed more than it added, because most of what it learned was that a join it had complained about was provably safe:
| Corpus | Findings before | After |
|---|---|---|
| 33 public dbt packages | 115 | 55 |
| TEAMSchools/teamster (2,006 models) | 86 | 70 |
| cal-itp/data-infra (625 models) | 87 | 55 |
Every single removal was adjudicated by hand before it was accepted — 60 of them across the corpus —
and the strong-finding count on the two unseen repos did not drop: teamster stayed at 3, cal-itp went
from 7 to 7 (two pivot-related false positives out, two newly-proven fan-outs in). Six separate
classes of noise went with them: a row identifier (id, dcid, unique_key) treated as a business
key; a PIVOT read as though it kept its source's grain; COUNT(*) flagged because of a join in an
unrelated CTE; a SUM over a CASE condition's column name; the same finding printed eleven times
for one date spine; and a column named in a GROUP BY that the input was already unique on.
Head-to-head against the other tools (21 September 2026)
Three other free tools shipped this same idea between July and September 2026: dblect, sqlsure
and dbt-assay. A 24-model dbt project was built with real dbt-core + dbt-duckdb, with a known
answer for every model, and run through graincheck, dblect 0.1.0 and dbt-assay 0.23.0.
(sqlsure checks one SQL string against a hand-written semantic model, so it could not be run on a
project the same way. Altimate's PR reviewer was not tested.)
| real fan-outs caught | false alarms on correct SQL | outer-join bugs caught | false alarms | |
|---|---|---|---|---|
| graincheck, before this run | 4 / 6 | 1 / 4 | 2 / 2 | 0 / 3 |
| graincheck, after fixing what this run found | 6 / 6 | 0 / 4 | 2 / 2 | 0 / 3 |
| dblect 0.1.0 | 2 / 6 | 0 / 4 | 2 / 2 | 0 / 3 |
dbt-assay 0.23.0 (assay tests) |
4 / 6 | 2 / 4 | — | — |
Read the second row with suspicion. graincheck was fixed against these exact cases, so 6/6 is a
regression guarantee, not a measurement. The first row is the fair comparison. The set is 19 cases
written by the author of this tool, and 7 of them are modelled on cases graincheck was already tuned
for. The competitors' strongest features were not measured: dbt-assay's LLM tier and its --verify
mode, which counts keys in the real data, and dblect's declared contracts.
What the run found in graincheck, all now fixed:
- Scanning a built dbt project counted
target/too. 24 models were reported as 88, and every finding appeared up to 3 times. Every earlier test ran on repos cloned from GitHub, wheretarget/is ignored. JOIN ... USING (key)was never checked. None of the 4 tools caught this case.- A join onto
(SELECT * FROM x)was never checked. - A rank column pinned inside a
GROUP BYwas treated as an upstream de-duplication, which was the teamsterstaff_renewal_feedfalse positive, previously only downgraded. - The coverage metric had its own copy of the join-key logic that never got the cal-itp
COALESCEfix. The package contains no POSIX-only assumption I can find — every path goes throughpathlib, every file read is explicit about encoding, the one subprocess call (git, for--history) is optional and swallows a missing binary — and the failure modes a Windows console produces are now covered by check O and by the Windows-codepage tests. But no part of this has ever executed on Windows. Treat it as untested there until someone runs it.
Before all of this, the health check had already paid for itself: on its first run it found four real
defects, two serious — scan_path raised UnicodeDecodeError on a single Latin-1 file and aborted the
whole scan, and files that parsed into non-queries were reported as examined and clean.
Licence
graincheck is released under the Business Source License 1.1, not an open-source licence.
You may, free of charge: run it on your own SQL, in development, in CI and in production pipelines inside your own organisation, and publish and act on its output for any purpose, including commercially.
You may not, without a separate commercial licence: offer it to third parties as a hosted or managed service, embed it in a product or service you sell, or build a commercial SQL-review service whose delivery depends on it.
On 28 September 2030 the licence converts automatically to Apache 2.0.
For a commercial licence, or if you are unsure which side of the line your use falls on, open an issue and ask.
Metadata
Release files for graincheck 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| graincheck-0.1.1.tar.gz | 266.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| graincheck-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 438.5 kB
Release files / graincheck-0.1.1.tar.gz
| Download URL | graincheck-0.1.1.tar.gz |
|---|---|
| Size | 266.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2853d6730c5000e37c4d310cee0bbd6e55c6f52c20166fea141aefdef8fc9f98
|
|
BLAKE2b-256 checksum How to use checksums |
d8346135e35eed4e3115ca60d6d5dd74126cfa1960c842f61a74cb1c129c5f19
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.4
|
Release files / graincheck-0.1.1-py3-none-any.whl
| Download URL | graincheck-0.1.1-py3-none-any.whl |
|---|---|
| Size | 172.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
57d99c90361cd56eaa23ac601bba9e392d40bdcc92ecb48f652d215286dd340d
|
|
BLAKE2b-256 checksum How to use checksums |
905c1fb0787a32e9be96ea9ee03b8036d736e33e694d042780f00d9eee54136e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.4
|