Deploy one change across three database engines
Three databases. Three teams. Three engines. And one change class that has to land on all of them by end of sprint.
If you’ve been managing them each with a separate pile of hand-rolled scripts, that’s three separate headaches, three chances to drift, and three different answers to “did it deploy?” SchemaSmith doesn’t collapse them into one combined package — it keeps them native and separate. What it gives you is the same rhythm on all three.
Hey folks. I’m Forge Barrett, master of the Content Forge here at SchemaSmith.
This is Module 1 of Course 9, and here’s what we’re going to prove: when your team runs SQL Server, PostgreSQL, and MySQL as independent services, you can apply one class of schema change to each one — the same structure, the same deploy command, the same verification — and the only thing that differs is what the DDL looks like under the hood. The tooling stays consistent. The packages stay native. The deploys stay independent.
Three engines. One rhythm. Let’s see it.
The three services and why each engine
Section titled “The three services and why each engine”The lab ships three packages that model a realistic multi-engine team:
- Orders — SQL Server. Transactional data with brackets,
NVARCHAR, and the schema discipline that SQL Server naturally enforces. This is the engine you reach for when auditability and FK integrity across a complex order graph matter most. - Catalog — PostgreSQL. Rich types, lowercase identifiers, and
texteverywhere the column width isn’t a constraint. PostgreSQL earns its spot when the data model is expressive and the query layer needs power. - Sessions — MySQL. High-volume, write-heavy event tracking. Backtick-quoted identifiers,
InnoDB,utf8mb4— the engine tuned for throughput at the edge.
Three different strengths. Three different native conventions. All three managed by SchemaSmith with the same package layout and the same deploy command.
One package per engine — always. Each service is its own SchemaSmith package targeting one engine. In production, each lives in its own repo. They co-locate in the lab only because that’s how lab bundles ship. There is no single package targeting all three engines — that’s not how SchemaSmith works, and it’s not how production services work. Native + separate. Always.
The change class: three parts, three packages
Section titled “The change class: three parts, three packages”Every sprint brings changes like this one: add an optional field, index it, add a lookup table to validate it later. Module 2 will go deep on why the DDL looks different per engine. Right now, feel the pattern. Same three parts, three times over, independently deployed.
Part 1 — Nullable column
Section titled “Part 1 — Nullable column”A new optional column on the core table of each service.
Orders / SQL Server — [Phone] on [Customer]:
{ "Name": "[Phone]", "DataType": "NVARCHAR(30)", "Nullable": true}Catalog / PostgreSQL — brand on product:
{ "Name": "brand", "DataType": "text", "Nullable": true}Sessions / MySQL — `DeviceModel` on `Session`:
{ "Name": "`DeviceModel`", "DataType": "varchar(60)", "Nullable": true, "CharacterSet": "utf8mb4"}Brackets vs lowercase vs backticks. NVARCHAR(30) vs text vs varchar(60). Same intent — an optional column you can populate later — expressed natively per engine. SchemaSmith generates the right ALTER TABLE for each one. You never write it by hand.
Part 2 — Index on the new column
Section titled “Part 2 — Index on the new column”An index so lookups on that column don’t scan the whole table.
Orders / SQL Server — [IX_Customer_Phone]:
{ "Name": "[IX_Customer_Phone]", "PrimaryKey": false, "Unique": false, "IndexColumns": "[Phone]", "CompressionType": "NONE", "Clustered": false}Catalog / PostgreSQL — ix_product_brand:
{ "Name": "ix_product_brand", "PrimaryKey": false, "Unique": false, "IndexColumns": "brand"}Sessions / MySQL — IX_Session_DeviceModel:
{ "Name": "IX_Session_DeviceModel", "PrimaryKey": false, "Unique": false, "IndexColumns": "`DeviceModel`", "IndexType": "BTREE"}SQL Server lets you specify compression and clustering. PostgreSQL keeps it minimal — the engine chooses B-tree by default. MySQL names the type explicitly. Each declaration is at home in its own engine. You’re not fighting the engine’s conventions; you’re declaring into them.
Part 3 — Reference table, seeded
Section titled “Part 3 — Reference table, seeded”A lookup table with rows that have to land in the right order — schema first, then data. The JSON defines the table; the After Script delivers the rows.
Orders / SQL Server — [OrderStatus] table + 01_seed_orderstatus.sql:
Table (dbo.OrderStatus.json):
{ "Name": "[OrderStatus]", "Columns": [ { "Name": "[Code]", "DataType": "NVARCHAR(20)" }, { "Name": "[Description]", "DataType": "NVARCHAR(100)" } ]}After Script:
IF NOT EXISTS (SELECT 1 FROM dbo.OrderStatus WHERE [Code] = 'NEW') INSERT dbo.OrderStatus ([Code], [Description]) VALUES ('NEW', 'New order');IF NOT EXISTS (SELECT 1 FROM dbo.OrderStatus WHERE [Code] = 'PAID') INSERT dbo.OrderStatus ([Code], [Description]) VALUES ('PAID', 'Paid');IF NOT EXISTS (SELECT 1 FROM dbo.OrderStatus WHERE [Code] = 'SHIPPED') INSERT dbo.OrderStatus ([Code], [Description]) VALUES ('SHIPPED', 'Shipped');IF NOT EXISTS (SELECT 1 FROM dbo.OrderStatus WHERE [Code] = 'CANCELLED') INSERT dbo.OrderStatus ([Code], [Description]) VALUES ('CANCELLED', 'Cancelled');Catalog / PostgreSQL — product_status table + 01_seed_product_status.sql:
Table (public.product_status.json):
{ "Name": "product_status", "Columns": [ { "Name": "code", "DataType": "text" }, { "Name": "description", "DataType": "text" } ]}After Script:
INSERT INTO product_status (code, description)VALUES ('ACTIVE', 'Active'), ('DISCONTINUED', 'Discontinued'), ('DRAFT', 'Draft')ON CONFLICT (code) DO NOTHING;Sessions / MySQL — `EventCategory` table + 01_seed_eventcategory.sql:
Table (`EventCategory`.json):
{ "Name": "`EventCategory`", "Columns": [ { "Name": "`Code`", "DataType": "varchar(30)", "CharacterSet": "utf8mb4" }, { "Name": "`Description`", "DataType": "varchar(120)", "CharacterSet": "utf8mb4" } ], "Engine": "InnoDB", "CharacterSet": "utf8mb4"}After Script:
INSERT IGNORE INTO `EventCategory` (`Code`, `Description`)VALUES ('PAGE_VIEW', 'Page view'), ('ADD_TO_CART', 'Add to cart'), ('CHECKOUT', 'Checkout'), ('LOGOUT', 'Logout');Now look at the idempotency strategy across the three scripts. SQL Server uses IF NOT EXISTS guards — check before each insert, run clean on re-deploy. PostgreSQL uses ON CONFLICT (code) DO NOTHING — the engine’s native upsert. MySQL uses INSERT IGNORE — the MySQL-idiomatic way to skip a row that already exists. Three different SQL dialects for the same guarantee: run the script twice and the second run is a no-op. That’s not a quirk — it’s fidelity. Each script is at home in the engine it targets.
The identical workflow
Section titled “The identical workflow”Three packages, three deploys. Each one runs the same command:
schemaquench --ConfigFile:<config-path> --LogPath:<log-path>Same tool. Same flags. Same package layout — baseline/ for the starting point, after/ for the target state, Templates/Main/ for declarations, After Scripts/ for the seed data. You run it three times, independently, against three different connection strings. Each one goes baseline → after → done.
And when you re-run any of them? Clean. Zero changes detected. SchemaSmith compares the declared state against the live database and finds nothing to do — the column is there, the index is there, the rows are there. Re-running is safe. That’s convergence, and it’s one of the things you can count on across every engine.
The verification tells the story:
- SQL Server —
[Phone]column on[Customer],IX_Customer_Phoneindex, 4 rows in[OrderStatus], 2 FKs on the Orders table graph. - PostgreSQL —
brandcolumn onproduct,ix_product_brandindex, 3 rows inproduct_status. - MySQL —
`DeviceModel`column on`Session`,IX_Session_DeviceModelindex, 4 rows in`EventCategory`.
Three engines. Three exit 0s. Three independent confirms.
What’s next
Section titled “What’s next”You’ve felt the parity. Same structure, same rhythm, three native packages, three independent deploys. The workflow is consistent. The DDL is not — and that’s the point.
Module 2 is where we slow down on the fidelity side: why brackets vs lowercase vs backticks aren’t just cosmetic, why NVARCHAR and text and varchar(60) aren’t interchangeable, and what it means that each package is genuinely native to its engine rather than a lowest-common-denominator abstraction. The differences you saw here are a feature. We’ll show you why.
Three native packages, each in its own repo, three engines in their native form, one tool holding the rhythm steady. That’s not a workaround — that’s the architecture. A smith who works three metals doesn’t pretend they’re all the same alloy. They know each one, they choose the right tool per strike, and the results hold because of that discipline, not in spite of it.
Subscribe and stick around. Next time, we’ll forge into the native-fidelity side — why each engine’s conventions are an asset, not an obstacle.
Until then, may your three engines each hold fast in their own metal.
— Forge
Check yourself: The lab deploys three packages: Orders (SQL Server), Catalog (PostgreSQL), and Sessions (MySQL). Are these three deploys part of one combined operation, or three independent ones — and why does that distinction matter in production?
They are three fully independent deploys. Each package targets one engine with its own connection string, its own baseline and after states, and its own SchemaQuench run. Nothing about deploying Orders triggers or depends on deploying Catalog or Sessions. In production, each service lives in its own repo and deploys on its own schedule. They co-locate in the lab only because lab bundles ship that way. The independence is the point: when the Catalog team changes a column, they don’t need a coordinated release with the Orders team — their package, their deploy, their engine.
Check yourself: The three seed scripts use different SQL idioms — `IF NOT EXISTS` guards, `ON CONFLICT DO NOTHING`, and `INSERT IGNORE` — but they all do the same thing. What property does that shared behavior give you, and what does it mean for re-running the deploy?
All three scripts are idempotent — running them a second time produces no changes, no errors, and no duplicate rows. Each script is written in the native idiom of its engine: SQL Server’s row-check pattern, PostgreSQL’s upsert syntax, MySQL’s skip-on-collision flag. The property you get is safe re-deploy: run any of these packages again after the initial deploy and SchemaQuench finds nothing to change — the column exists, the index exists, the rows are already there. Convergence. That’s a guarantee you can count on across every engine.