Skip to content

Lint your schema before deploy: --Validate

Your JSON’s valid. Every file has its required fields, every type spelled right, structural validation green on the pull request. And the deploy still blows up at CREATE TABLE — because a foreign key points at a table you forgot to include, and shape-checking never had a way to see it.

Hey folks. I’m Forge Barrett, master of the Content Forge here at SchemaSmith. Back in Module 2 we walked the pre-flight family — the read-only gates that answer a question before a real deploy does. Today we add the fastest one of all, and it needs no database at all. --Validate reads your package cold and tells you what’s structurally sound but semantically broken — before you ever light the fire.

The pre-flight family has four members, each checking a different layer:

GateNeeds a connection?Needs a target?What it checks
--ValidateNoNoPackage meaning — static analysis of the files on disk
--TestConnectionYesNoServer reachability + MinimumVersion
--PreviewTargetsYesYesThe database/schema roster a real quench would touch
WhatIfONLYYesYesThe actual SQL a real quench would generate

--Validate is the only target-less, connection-less one. No server, no credentials, no waiting for a deploy window — just the files and the SchemaQuench binary. That makes it the cheapest gate you own, so it runs first: on every commit, from any machine, before anything that needs a live engine gets a turn.

Module 3 wired JSON Schema validation into CI — every Product.json, every table file checked against its generated .json-schemas on every pull request. That catches the typo: a missing DataType, a misspelled property, a value outside an enum. It’s real and it’s cheap, and you should keep it.

But it validates each file in isolation. It can’t see across objects. It can’t tell you that OrderItem’s foreign key points at a Supplier table nobody included, that two columns collide on a name, or that a ShouldApplyExpression leans on a token nobody defined. Those are errors of meaning, and they live in the gaps between files. That’s exactly the seam --Validate covers — structure is Module 3’s job; meaning is this one’s.

The lab ships a shop package that’s deliberately broken. Point the linter at it:

schemaquench --Validate --SchemaPackagePath:./sqlserver/Package

It exits 2 and prints exactly three errors, one from each check engine:

ERROR [SS-DUP-001] Template 'Main' / Table '[OrderItem]': Duplicate column name '[Quantity]' at Template 'Main' / Table '[OrderItem]' - 2 entries share this name and at least one is not gated by ShouldApplyExpression.
ERROR [SS-FK-002] Template 'Main' / Table '[OrderItem]' / FK '[FK_OrderItem_Supplier]': RelatedTable '[Supplier]' does not resolve to any known table (resolved schema '[dbo]').
ERROR [SS-TOK-001] .../dbo.Customer.json: references undefined token '{{IncludePiiColumns}}'.
3 error(s), 0 warning(s)

Three real flaws, no database touched:

  • SS-DUP-001OrderItem has two [Quantity] columns, neither gated. A collision that dies at CREATE TABLE.
  • SS-FK-002OrderItem declares [FK_OrderItem_Supplier] pointing at a [Supplier] table that isn’t in the package. You forgot the table.
  • SS-TOK-001Customer’s [Email] column is gated on {{IncludePiiColumns}}, a token nobody defined. It’d resolve to nothing at deploy and quietly do the wrong thing.

Each line is one flaw: severity, code, location, message. Errors first, then a count. That’s the whole board.

Three edits, one per finding:

  1. SS-DUP-001 — in dbo.OrderItem.json, remove the duplicate ungated [Quantity] column. Keep the original.
  2. SS-FK-002 — in the same file, drop the [SupplierId] column and the [FK_OrderItem_Supplier] foreign key. The [Supplier] table was never part of this package.
  3. SS-TOK-001 — in Product.json, add "IncludePiiColumns": "1" to ScriptTokens, declaring the token the [Email] column references.

Re-run:

schemaquench --Validate --SchemaPackagePath:./sqlserver/Package
PASS - no issues found

Exit 0. Clean board, cold metal, still no database in sight.

Here’s the part that matters. Before you fixed a thing, Product already had two columns named [Discontinued] — and the linter never said a word about them.

{ "Name": "[Discontinued]", "DataType": "BIT", "ShouldApplyExpression": "'{{Edition}}' = 'Legacy'", "VariantName": "Legacy" },
{ "Name": "[Discontinued]", "DataType": "TINYINT", "ShouldApplyExpression": "'{{Edition}}' = 'Modern'", "VariantName": "Modern" }

Both are gated on the defined {{Edition}} token, and each carries a distinct VariantName. That’s a legitimate variant pair — the same logical column, materialized differently per edition, only ever one live at a time. --Validate knows the difference between a variant set and an accident. The two ungated [Quantity] columns were a bug; the two gated [Discontinued] columns are a feature. A dumb name-uniqueness check flags both and cries wolf. A semantic linter flags only the one that’s actually wrong.

Check yourself: The broken package has two columns named [Quantity] AND two columns named [Discontinued]. --Validate flags the [Quantity] pair (SS-DUP-001) but stays silent on the [Discontinued] pair. Why?

Because the [Discontinued] pair is a legitimate conditional variant set and the [Quantity] pair isn’t. Both [Discontinued] columns are gated by a ShouldApplyExpression on the defined {{Edition}} token, and each declares a distinct VariantName (Legacy / Modern) — so only one is ever live on a given target. SS-DUP-001 fires only when same-named entries exist and at least one isn’t gated; the two [Quantity] columns are both ungated, so they’re an accidental collision. --Validate reasons about the gates, not just the names — that’s what makes it semantic rather than a blunt uniqueness check.

That {{Edition}}-gated pair isn’t just tolerated going in — it survives the round trip too. Deploy at Edition=Modern, then re-cast the same package with SchemaTongs, and it doesn’t flatten the pair into one column or leave you guessing which shape won. It evaluates each variant’s ShouldApplyExpression against what it finds on the source — substituting the package’s ScriptTokens first — and refreshes only the variant whose gate is live:

{ "Name": "[Discontinued]", "DataType": "BIT", "ShouldApplyExpression": "'{{Edition}}' = 'Legacy'", "VariantName": "Legacy" },
{ "Name": "[Discontinued]", "DataType": "TINYINT", "ShouldApplyExpression": "'{{Edition}}' = 'Modern'", "VariantName": "Modern" }

Re-cast with Edition=Modern token context and the file still has exactly two entries: Modern refreshed from the live TINYINT, its VariantName and gate intact; Legacy’s BIT untouched, because it was never live on this target. No spurious entry, no churn.

Re-cast under a token context where neither gate matches — say Edition=Beta — while the database still holds the TINYINT shape, and SchemaTongs won’t guess which variant that drift belongs to. It writes a third, ungated [Discontinued] entry alongside the pair and hands the problem straight to the check you just learned:

ERROR [SS-DUP-001] Template 'Main' / Table '[Product]': Duplicate column name '[Discontinued]' at Template 'Main' / Table '[Product]' — 3 entries share this name and at least one is not gated by ShouldApplyExpression.

Same SS-DUP-001, same rule — only now it’s not catching an accident, it’s catching drift SchemaTongs couldn’t place with confidence, and handing the call back to you instead of guessing. A gate that fails to evaluate at all fails the extraction the same way: closed, not a silent guess.

One gate live, the variant refreshes in place. No gate live, the drift surfaces as a duplicate you have to reconcile. A broken gate, and the extraction stops cold rather than pretend it knows.

Every finding on the board so far has been an error — three cross-object flaws, all fatal, all setting exit 2. --Validate carries a second severity tier, and this is where you meet it.

Identity in a SchemaSmith package lives in a table file’s content — its Schema, Name, and, for a variant, its VariantName — never its filename. Rename dbo.OrderItem.json to orderitem-legacy.json on disk and the table still deploys exactly right; the engine never reads the filename. But a drifted name still costs you something — it stops being a reliable pointer to the table inside it, and a variant pair stops sorting together in source control. That’s what SS-FILE-NAME-003 watches for.

Rename dbo.OrderItem.json and re-run --Validate:

WARN [SS-FILE-NAME-003] .../orderitem-legacy.json: Table file 'orderitem-legacy.json' does not match its canonical name 'dbo.OrderItem.json' (from Schema/Name/VariantName). Identity is content, so this is a naming lean, not an error - rename to keep the file a reliable pointer to its table.

Canonical shape is <schema>.<table>[.<VariantName>].json — schema and table first, then an optional .VariantName segment so a table’s conditional variants sort together on disk.

Here’s the part that matters: this finding doesn’t gate. --Validate still exits 0 when a naming warning is the only thing on the board — the count line reads something like 0 error(s), 1 warning(s), and a warning alone never flips that exit code. Errors are a wall; this one’s a lean toward tidiness.

The editor .json-schemas that give you red-squiggle validation are generated from the domain model. If the model moves and nobody regenerates them, they lie — and Module 3’s structural gate would be checking your files against a stale contract. --Validate catches that too. Induce it in the lab:

Hand-edit sqlserver/Package/.json-schemas/tables.sqlserver.schema — narrow any "maxLength": 128 down to "maxLength": 1, so it no longer matches fresh generation. Re-run:

ERROR [SS-STALE-001] .../tables.sqlserver.schema: committed .json-schemas are stale - regenerate via --WriteSchemasOnly.

Exit 2. The fix is one command — regenerate from the current model:

cd sqlserver/Package && schematongs --WriteSchemasOnly && cd ../..

Re-run --Validate and the staleness finding’s gone. Green again. Staleness runs first in the linter, before the structural pass — a schema that doesn’t match the model can’t be trusted to judge your files, so --Validate fixes the ruler before it measures.

ci/validate.yml in the lab is a copy-ready GitHub Actions workflow. Drop it in your repo’s .github/workflows/, point it at your package, and every pull request runs the linter. Because --Validate exits 2 on any error, the job fails on its own — no output-parsing, no scripting to interpret the result. No database container, no secrets, no engine matrix. It’s the cheapest step in the whole pipeline, so it goes at the front: lint first, then spend the expensive gates only on packages that already read clean.

The same three-error board reproduces on all four engines. Only the identifier quoting and native type spellings differ — [dbo] and brackets on SQL Server, lowercase public on PostgreSQL, backtick-quoted schema-less names on MySQL. On MySQL, foreign-key resolution is name-only, since there are no schemas within a database, so SS-FK-002 resolves Supplier by bare name.

SS-FILE-NAME-003’s canonical name follows the same schema rule as everything else here: SQL Server keeps the schema segment (dbo.<table>.json, since the table’s content declares "Schema": "[dbo]"), while PostgreSQL and MySQL are schema-less (<table>.json) — PostgreSQL because these packages leave Schema empty and rely on the default public, MySQL because it has no schemas within a database at all.

The finding codes, the switch, the exit behavior, and the pass/fail semantics are identical everywhere — including the fact that a warning like SS-FILE-NAME-003 never flips a pass to a fail, on any engine.


A smith inspects the billet in daylight before he ever lights the forge — a crack you can see cold is a crack you fix for free, and the same flaw found glowing on the anvil costs you the heat, the hammer, and sometimes the whole piece. That’s --Validate: read the package cold, catch the dangling key and the phantom token and the stale ruler while they’re still just text on disk, and carry only sound metal to the fire.

That rounds out the pre-flight family — the target-less lint out front, the connection gates behind it, and nothing broken reaching a live engine by surprise. Got a cross-object flaw the linter caught that would’ve cost you a deploy? Email me at forgebarrett@schemasmith.com — I read every one.

Subscribe and stick around. More’s coming from the forge.

Until then, may every flaw show itself in the cold light, long before your metal ever meets the fire.

— Forge