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 two errors and a warning:

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-TOK-001] .../dbo.Customer.json: References undefined token '{{IncludePiiColumns}}'.
WARN [SS-FK-002] Template 'Main' / Table '[OrderItem]' / FK '[FK_OrderItem_Supplier]': RelatedTable '[Supplier]' does not resolve to any table in the package (resolved schema 'dbo'). The deploy succeeds only if it already exists on the target.
2 error(s), 1 warning(s)

Two real flaws and a lean, no database touched:

  • SS-DUP-001 — OrderItem has two [Quantity] columns, neither gated. A collision that dies at CREATE TABLE.
  • SS-TOK-001 — Customer’s [Email] column is gated on {{IncludePiiColumns}}, a token nobody defined. It’d resolve to nothing at deploy and quietly do the wrong thing.
  • SS-FK-002 (warning) — OrderItem declares [FK_OrderItem_Supplier] pointing at a [Supplier] table that isn’t in the package. That’s a warning rather than an error because the deploy happily creates a foreign key to any table that already exists on the target, declared or not. Here it’s a mistake — nothing creates Supplier — but the linter can’t know that without a database. In a deliberately partial package — a patch, a bootstrap — the same warning is expected, and you read past it.

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

Three edits — both errors, and the mistake behind the warning:

  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.

Your package only tripped three checks, so that’s what you saw. --Validate carries a lot more, and the ones worth knowing before you meet them in anger are the ones that catch a package that looks fine.

Declared twice, two different ways. On PostgreSQL the Enum Types, Sequences and Domain Types folders are additive — declared .json and scripted .sql sit side by side, and using only one of the two is normal. Declaring the same object both ways is the problem, and each finding tells you what the engine actually does about it:

ERROR [SS-ENUM-001] an enum type is declared as JSON and scripted as a .sql file in the same folder
ERROR [SS-DOM-001] ... the same, for a domain type
ERROR [SS-SEQ-001] ... the same, for a sequence

They differ for a real reason. A scripted enum is a guarded CREATE TYPE, so once the type exists the script quietly does nothing and the declared form is what converges. A domain is the same trap, because there’s no CREATE OR REPLACE DOMAIN. For sequences SchemaSmith claims no mechanism at all — only that two authoring paths for one object leave it ambiguous which one is in charge. Delete whichever form you didn’t mean to keep.

A deprecated name that still works. SS-DEP-001 is a Warning: a MySQL or MariaDB template using SchemaIdentificationScript, the old alias for DatabaseIdentificationScript. It deploys, because load migrates it — which is exactly why it needed a finding. The linter that exists to warn you before an alias retires used to log the migration and then print PASS - no issues found. On SQL Server and PostgreSQL SchemaIdentificationScript is a real schema-template setting and is never reported.

That’s the warn stage. Here’s what retirement looks like on the other side of it: a MySQL column that still declares a column-level CheckExpression no longer loads at all — SS-JSON-001 names the property and the file that carries it. The reason is worth knowing. INFORMATION_SCHEMA.CHECK_CONSTRAINTS has no link from a constraint back to a column on MySQL, so that form could never round-trip; a table-level CheckConstraints entry says the same thing and can. MariaDB is the instructive exception: it gives a column-level check the column’s own name, so it can tell which column a check belongs to, and 2.8 brought the property back there. Same property, opposite verdicts, and each engine’s catalog is the reason.

A setting with nothing to act on. SS-CDC-001, also a Warning: a SQL Server table sets CdcFilegroup but not EnableCDC, so there’s no change table to place and the setting does nothing. Set EnableCDC, or drop the filegroup.

Warnings don’t change the exit code. A board of nothing but warnings still exits 0 — they’re there to be read, not to stop you. Unless you say otherwise: add --ExitNonZeroOnWarning and a warning-only board exits 1, with Exit code 1: 1 warning(s) and ExitNonZeroOnWarning is on. under the count. Errors still exit 2, so a pipeline can tell “read this” from “this won’t deploy.”

Most of the board reproduces on every engine. Some findings can’t, because the engines disagree about what a name has to be unique within. Copy a table file to start another one, forget to rename its foreign key, and two tables now declare FK_OrderItem_Product:

EngineVerdictWhy
SQL ServerERROR [SS-FK-006], exit 2foreign-key names are unique per schema, so the second CREATE fails
MySQLERROR [SS-FK-006], exit 2unique per database
MariaDBWARN [SS-FK-006], exit 0per table from 12.1, per database before it
PostgreSQLPASSper table, on every version

MariaDB’s warning tells you what makes it go away: set the product’s MinimumVersion to 12.1 or later and the finding disappears, because the linter judges a package against the oldest server it says it supports. That is the rule behind every engine-specific finding.

PostgreSQL has a rule the others don’t. It keeps the first 63 bytes of an identifier and drops the rest, so a 64-byte name is stored under a name your package never declared — and the next deploy, not finding the declared one, fails creating it again:

ERROR [SS-IDENT-002] Template 'Main' / Table 'salesorder': Foreign key 'fk_salesorder_customer_who_placed_the_order_and_is_billed_for_it' is 64 bytes long. PostgreSQL truncates identifiers to 63 bytes, so it would be stored under a shorter name and the next deploy, not finding the declared one, would fail creating it again. Shorten it to 63 bytes or fewer.

A tool that applied one rulebook to all four engines would either miss these or cry wolf on PostgreSQL. The linter reads each package as the engine it targets would.

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.

You met the second severity tier on the first board. Here it is on its own, on a package that’s otherwise clean.

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>.<name>[.<VariantName>].json — schema and name first, then an optional .VariantName segment so an object’s conditional variants sort together on disk. It isn’t only tables: every declared-object folder gets the same check, so a PostgreSQL enum type declaring "Schema": "public" belongs in public.order_status.json, not order_status.json. A schema template is the one exception: its files carry no schema prefix, and a variant follows a ~ instead of a dot — Customers~v1.json.

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 unless you’ve asked it to with --ExitNonZeroOnWarning. 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.

From SchemaSmith 2.7.0 that finding carries a second sentence — “Structural validation used the current model merged with this file’s authored custom-property governance” — and it is worth knowing why it was added. Through 2.6.0 a stale type short-circuited structural validation entirely. That skipped the Extensions governance you authored into the file, so a package could violate its own required-field or enum rule and be told nothing, while the single finding pointed at the schema file rather than at the violation. Staleness quietly became a hole in your governance. It no longer is: the stale type is validated against the current model merged with your recovered fragment, so your own rules keep applying while you get around to regenerating.

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 board — two errors and a warning — 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 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 its own, on any engine — only --ExitNonZeroOnWarning does that. What differs is where an engine’s own rules differ: SS-FK-006, SS-IDENT-002 and the column-level CheckExpression above.


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