Defining Tables as JSON

Every table in SchemaSmith is a JSON file inside your schema package's Tables folder, declaring the desired state of that table. SchemaQuench computes the DDL to get there.

By the SchemaSmith Team · Last reviewed

JSON schema files defining database tables with columns, indexes, and constraints

Every table definition file declares exactly one table.

How SchemaSmith models tables

In SchemaSmith, your database schema lives as declarative JSON files, not as a collection of ALTER scripts or numbered migrations. Every table is one JSON file. That file describes the table as it should be: its name, schema namespace, columns, indexes, constraints, foreign keys, check expressions, and — depending on your platform — platform-specific features like XML indexes, full-text indexes, or statistics. There's no hand-written DDL, no rollback files, no version numbers. You shape the JSON; SchemaQuench reads it and computes the minimal set of DDL statements needed to transform your live database into the declared state.

The process is straightforward. You create a schema package — a directory tree with your JSON table definitions — and pass it to SchemaQuench. SchemaQuench diffs your live database against the JSON declarations, generates the necessary CREATE or ALTER statements, and executes them in order. On SQL Server, you declare columns with INT IDENTITY(1, 1) syntax; on PostgreSQL, you use INTEGER GENERATED ALWAYS AS IDENTITY; on MySQL, INT AUTO_INCREMENT. The JSON structure is identical on every supported platform — only the data types, quoting styles, and platform-native features change. You build the same mental model for schema definition on every supported engine; the file format doesn't shift, only the dialect inside it does.

Tables aren't defined in isolation. The JSON arrays in each table file capture the full picture: Columns (with nullable flags, defaults, and check expressions), Indexes (with clustering and uniqueness directives), CheckConstraints (table-level business rules), and ForeignKeys (referential integrity). On platforms that support them, you also declare XmlIndexes, FullTextIndex, and Statistics (SQL Server); ExcludeConstraints, sequences, composite types, and enums (PostgreSQL); or Engine and Collation settings (MySQL). SchemaQuench reads these arrays, aligns them against the live database, and generates the diff. Run the same package against dev, staging, and production and you get the same predictable deployment every time.

Two cross-cutting features get their own homes: DataDelivery declares how a table's reference rows land in each target database, and conditional application uses ShouldApplyExpression to scope tables, columns, indexes, and constraints to specific environments or server versions. Both appear as properties on the shared table JSON structure below.

Important: same shape, platform-native contents

Every supported platform uses the same JSON table structure, but the contents are platform-specific. Data types differ: SQL Server's INT IDENTITY, PostgreSQL's GENERATED AS IDENTITY, MySQL's AUTO_INCREMENT. Quoting differs: SQL Server uses [brackets], PostgreSQL uses bare names or "double quotes", MySQL uses `backticks` or bare names. A SQL Server dbo.Customers.json is not a PostgreSQL public.customers.json — the file structure rhymes, but the contents are platform-specific. Each platform gets its own schema package.

Table JSON format — shared properties

Every table definition file declares exactly one table. The shared properties below appear on every platform; platform-specific extensions follow in the next section.

Property Type Default Required Description
Name string Yes Table name. Use the platform's quote style: [Customer] (SQL Server), "customer" (PostgreSQL), `customer` (MySQL and MariaDB). Bare names are also accepted.
Columns array [] Yes Column definitions. See Columns.
Indexes array [] No Index and constraint definitions. See Indexes.
ForeignKeys array [] No Foreign key definitions. See Foreign keys.
CheckConstraints array [] No Table-level check constraint definitions. See Check constraints.
ShouldApplyExpression string No SQL expression evaluated at quench time. If it returns false (or 0), the entire table is skipped on this database. Tokens are resolved before evaluation. See Conditional Application.
VariantName string No Optional label for a conditional variant. Appears in deployment log messages when the variant applies, and documents the intent behind the ShouldApplyExpression. Max 128 characters.
OldName string "" No Previous table name. When set, the table is renamed during quench. Clear after the rename has been deployed everywhere.
DataDelivery object null No Declarative data delivery configuration for this table. See Data delivery.
Extensions object null No Open metadata bag. See Custom Properties.

The ShouldApplyExpression field appears on tables, columns, indexes, foreign keys, check constraints, indexed views, materialized views, and several platform-specific components. Wherever it appears, it works the same way: the engine resolves tokens, runs the expression against the target database, and skips the component if the result is falsy.

The optional VariantName label rides on that same set of components. It is metadata only: SchemaQuench writes it into the deployment log when the variant applies, so you can see which conditional variant was chosen on a given target, but it never changes the generated DDL.

Platform-specific table properties

Every engine brings capabilities that go beyond basic columns and indexes. SQL Server has temporal tables and change data capture. PostgreSQL has exclusion constraints and row-level security. MySQL and MariaDB have storage-engine tuning and auto-increment seeding. All of them live in the same table JSON you've been writing — no new tools, no separate configuration layer. SchemaSmith models each platform's table as a wrapper object, and a schema package ships one file per table, written for exactly one platform.

The engine-specific fields below extend the shared properties. Pick your platform:

SqlServerTable properties, in addition to the shared set:

Property Type Default Description
Schema string "dbo" Database schema. Bracket notation in extracted files (e.g., "[Production]").
CompressionType string "NONE" Table data compression: "NONE", "ROW", or "PAGE".
IsTemporal bool false Manages the table as system-versioned temporal. See the note below.
XmlIndexes array [] XML index definitions (primary + secondary VALUE / PATH / PROPERTY).
Statistics array [] Custom statistics definitions.
FullTextIndex object or array null Full-text index — a single definition, or an array of conditional variants. See Full-text indexes.
UpdateFillFactor bool false When true, index fill factors on this table are updated to match the JSON definitions during quench.
EnableCDC bool false Enables Change Data Capture for the table. Requires CDC enabled on the database first. See Change data capture below.
FileGroup string null Filegroup the table is stored on, as a name only — never a file path, so the package stays portable across environments. Leave it unset and SchemaSmith does not manage placement at all: the table is created wherever SQL Server would put it, and an existing table is left where it is. SchemaSmith does not create filegroups — if the named one does not exist on the target, the deploy fails. Moving an existing table to a different filegroup is a rebuild, so a declared name that differs from where the table already lives also fails; migrate it manually. Removing the property does not move anything back, only stops SchemaSmith checking placement.
HistoryTableName string null Name of the temporal history table when IsTemporal is true. null means <Name>_Hist. Pointing an existing temporal table at a different history table is not something SchemaQuench performs.
HistoryTableSchema string null Schema of the temporal history table when IsTemporal is true. null means the same schema as the versioned table.
HistoryRetentionPeriod string null Retention for the temporal history table, as the SQL Server token — for example "5 YEARS", "90 DAYS", or "INFINITE". null leaves retention unmanaged.
PartitionScheme string null Partition scheme the table is stored on, as a name only — SchemaSmith never creates a partition function or scheme, exactly as it never creates a filegroup. Declared together with PartitionColumn; one without the other is refused. Cannot be combined with FileGroup: a table lives on one data space. Applied when the table is created; a change on a deployed table is refused, because moving a table between schemes rewrites every row.
PartitionColumn string null The column the partition function is applied to. One column — SQL Server partitions on a single column. Declared with PartitionScheme or not at all.
TextImageFileGroup string null The table's TEXTIMAGE_ON filegroup — where large-object data lands — as a name only. Large-object columns are text, ntext, image, xml, and the (MAX) types; a FILESTREAM column does not count. Create-time only.
FileStreamFileGroup string null The table's FILESTREAM_ON filegroup, as a name only. null means the database's default FILESTREAM filegroup. Effectively immutable — SQL Server refuses to reassign a table that already has one.
Ledger string null "AppendOnly" or "Updatable" creates a tamper-evident ledger table. Cannot be combined with IsTemporal, since a ledger table manages its own history. Requires SQL Server 2022. Close to permanent: there is no ALTER to or from a ledger table, and DROP does not remove one, so a change on a deployed table is refused.
GraphType string null "Node" or "Edge" creates the table AS NODE / AS EDGE. Create-time only — SQL Server has no ALTER that converts a table to or from a graph table. Requires SQL Server 2017; below that the table deploys as an ordinary one and the downgrade is reported. The graph pseudo-columns SQL Server adds are never extracted and never dropped.
EnableChangeTracking bool false When true, the table is enabled for SQL Server change tracking. Requires change tracking enabled on the database. Unrelated to the full-text index option also spelled ChangeTracking.
TrackColumnsUpdated bool false Only meaningful with EnableChangeTracking. When true, change tracking records which columns changed rather than merely that the row did, at the cost of extra tracking storage.
XmlCompression bool false SQL Server 2022+. Compresses XML column data in place, independently of CompressionType — a table can carry both. Deployable from 2022 but only readable from 2025, so on 2022–2024 SchemaSmith applies it and cannot read it back; the declared value is carried forward rather than dropped. Below 2022 the clause is suppressed and reported.
MemoryOptimized bool false Creates the table in the In-Memory OLTP engine instead of on disk. See Memory-optimized tables below.
Durability string "SCHEMA_AND_DATA" "SCHEMA_AND_DATA" keeps schema and rows across a restart; "SCHEMA_ONLY" keeps the schema and discards every row. Only meaningful with MemoryOptimized.

Temporal tables. Set IsTemporal to true and SchemaSmith adds the system-time period columns (ValidFrom, ValidTo), the PERIOD FOR SYSTEM_TIME declaration, and SYSTEM_VERSIONING = ON pointing at a <Name>_Hist history table, and protects the period columns from drop detection. Toggle it back to false and SchemaSmith emits SET (SYSTEM_VERSIONING = OFF) — clean in both directions. The history table itself (<Name>_Hist in the same schema) is not created for you: declare it as a sibling table JSON, or create it in a Before script before the temporal table is quenched.

PostgreSqlTable properties, in addition to the shared set:

Property Type Default Description
Schema string "public" Database schema. Double-quote notation in extracted files (e.g., "\"sales\"").
Statistics array [] Extended statistics definitions.
ExcludeConstraints array [] Exclusion constraint definitions. See Exclude constraints below.
RowLevelSecurity bool false Enables row-level security on the table. Without at least one permissive policy in Policies, no rows are visible to any user except the table owner.
ForceRowLevelSecurity bool false When true, row-level security is enforced even for the table owner.
Policies array [] Row-level security policy definitions, including Name, Permissive ("PERMISSIVE" policies are OR-ed, "RESTRICTIVE" are AND-ed), Command ("ALL", "SELECT", "INSERT", "UPDATE", "DELETE"), Roles, UsingExpression, and WithCheckExpression. See Row-level security below.
AccessMethod string null Table storage access method (e.g., "heap"). Distinct from the per-index AccessMethod covered under Indexes.
PersistenceType string null Persistence override (e.g., "UNLOGGED", "TEMPORARY").
UpdateFillFactor bool false Enables fill-factor reconciliation for this table.
FillFactor short (0–100) 0 Table fill factor. 0 means use the server default.

Row-level security. SchemaSmith manages the table-level RowLevelSecurity and ForceRowLevelSecurity flags and converges your declared Policies set, creating those that are missing and dropping ones no longer declared. It converges that set by name: PostgreSQL stores USING and WITH CHECK expressions normalized, so a text comparison would report a change on every deploy. Editing an expression on an existing policy therefore has no effect — rename the policy, or remove it and add it back under a new name, to change one. Both routes end in a new name because the set converges by name. Materialized views aren't tables: they live as their own JSON files in a Materialized Views/ folder alongside your tables, where SchemaSmith manages their create/drop lifecycle and their own indexes.

MySqlTable properties, in addition to the shared set:

Property Type Default Description
Engine string "InnoDB" Storage engine.
RowFormat string null Row format: "DYNAMIC", "COMPACT", "COMPRESSED", or "REDUNDANT".
CharacterSet string null Default character set for the table.
Collation string null Default collation for the table.
Comment string null Table comment.
AutoIncrementValue ulong null Initial auto-increment seed. Applied with set-if-higher semantics. See the note below.
FullTextIndexes array [] Full-text index definitions (MySQL supports multiple per table). See Full-text indexes.
Compression string null InnoDB transparent page compression: "zlib", "lz4" or "none". Cannot be combined with RowFormat: "COMPRESSED" — MySQL refuses that with error 1031, and --Validate reports SS-CO-001 first.
KeyBlockSize int null InnoDB compressed-page size in KB (1, 2, 4, 8, 16). Only meaningful with RowFormat: "COMPRESSED".
Encryption string null InnoDB at-rest tablespace encryption: "Y" or "N". Unset means SchemaSmith does not manage encryption, so a tablespace someone encrypted by hand is left as it is. Converges both ways.
Tablespace string null The general tablespace the table is placed in. Applied at create; a move is refused by name. Unset means placement is unmanaged.
DataDirectory string null The filesystem directory the table's data file is placed in (InnoDB DATA DIRECTORY). MySQL additionally requires the directory to be listed in the server's innodb_directories. Applied at create; a move is refused by name.
Partitioning object null How the table is partitioned. See Partitioning below. Leave it unset and SchemaSmith does not manage partitioning at all, so a table someone partitioned by hand is left exactly as it is.

Auto-increment seed. AutoIncrementValue is applied at quench time using set-if-higher semantics: the seed is only raised, never lowered. MySQL clamps a below-current value to max+1, so SchemaSmith skips the statement in that case to avoid phantom DDL on every quench.

MariaDB table properties, in addition to the shared set:

Property Type Default Description
Engine string "InnoDB" Storage engine.
RowFormat string null Row format: "DYNAMIC", "COMPACT", "COMPRESSED", or "REDUNDANT".
CharacterSet string null Default character set for the table.
Collation string null Default collation for the table.
Comment string null Table comment.
AutoIncrementValue ulong null Initial auto-increment seed. Applied with set-if-higher semantics. See the note below.
FullTextIndexes array [] Full-text index definitions (MariaDB supports multiple per table). See Full-text indexes.
KeyBlockSize int null InnoDB compressed-page size in KB (1, 2, 4, 8, 16). Only meaningful with RowFormat: "COMPRESSED".
PageCompressed bool false InnoDB page compression — MariaDB's equivalent of the Compression string MySQL uses, which MariaDB does not support. Cannot be combined with RowFormat: "COMPRESSED" (errno 140); --Validate reports SS-CO-001.
PageCompressionLevel int null Compression level 1–9. Ignored unless PageCompressed is set, which --Validate reports as SS-CO-002.
Encrypted bool false InnoDB at-rest tablespace encryption (ENCRYPTED=YES). Converges both ways — toggling it rebuilds the tablespace in place and discards nothing, so neither direction is refused.
EncryptionKeyId int null The encryption key to use (ENCRYPTION_KEY_ID). Only meaningful alongside Encrypted.
DataDirectory string null The filesystem directory the table's data file is placed in (InnoDB DATA DIRECTORY). Applied at create; a move is refused by name.
Partitioning object null How the table is partitioned. See Partitioning below. Leave it unset and SchemaSmith does not manage partitioning at all, so a table someone partitioned by hand is left exactly as it is.
IsSystemVersioned bool false The table keeps its own row history (WITH SYSTEM VERSIONING). Deployed, not just extracted — and removing it is refused by name. See System versioning and periods below. Requires MariaDB 10.3.
Periods array [] Application-time period definitions (PERIOD FOR). See System versioning and periods below.

Auto-increment seed. AutoIncrementValue is applied at quench time using set-if-higher semantics: the seed is only raised, never lowered. MariaDB clamps a below-current value to max+1, so SchemaSmith skips the statement in that case to avoid phantom DDL on every quench.

Exclude constraints PostgreSQL

Unique indexes enforce equality: no two rows can have the same value. Exclusion constraints enforce an operator relationship: no two rows can satisfy a given operator pair. The canonical use case is non-overlapping reservation periods — a GiST index with the && (overlap) operator guarantees that no two reservations for the same room span the same time. Declare these in the ExcludeConstraints array on the table.

Property Type Description
Name string Constraint name.
AccessMethod string Index access method backing the constraint (e.g., "gist").
ExcludeColumns array One or more { "Column", "Operator" } pairs.
FilterExpression string Optional WHERE clause.
ShouldApplyExpression string Conditional inclusion. See Conditional Application.
VariantName string Optional label for a conditional variant. Max 128 characters.
Deferrable bool Whether the constraint is deferrable.
InitiallyDeferred bool Whether the constraint defers by default.

A non-overlapping reservation constraint:

{
  "Name": "no_overlapping_reservations",
  "AccessMethod": "gist",
  "ExcludeColumns": [
    { "Column": "room_id",         "Operator": "="  },
    { "Column": "reserved_period", "Operator": "&&" }
  ]
}

Per-table drop protection

A table's JSON can carry any of the object-level drop flags — DropColumnsRemovedFromProduct, DropForeignKeysRemovedFromProduct, DropCheckConstraintsRemovedFromProduct, DropIndexesRemovedFromProduct — set to false to shield that table's own columns, foreign keys, check constraints, or indexes from by-absence removal, even when the environment, product, or template tier permits drops. The table tier can only tighten: a false is a hard guardrail. It can never set true to re-enable a drop that a higher tier has suppressed. (DropTablesRemovedFromProduct has no table-level equivalent — a removed table has no JSON left to carry the flag.) See Drop Control for the full four-tier cascade.

See the platform-specific reference

Each wrapper appears in worked examples on the platform's daily workflows page: SQL Server, PostgreSQL, MySQL, MariaDB.

Columns

Every entry in the Columns array defines one column. The shared shape is small; platform-specific column subclasses add fields where the engine genuinely differs.

Shared column properties

Property Type Default Description
Name string Column name.
DataType string Platform-appropriate data type with precision/scale/length. SQL Server: NVARCHAR(50), INT IDENTITY(1,1). PostgreSQL: VARCHAR(50), INTEGER GENERATED ALWAYS AS IDENTITY. MySQL and MariaDB: VARCHAR(50), INT AUTO_INCREMENT.
Nullable bool false Whether the column allows NULL.
Default string Default constraint expression — e.g., getdate() (SQL Server), now() (PostgreSQL), CURRENT_TIMESTAMP (MySQL and MariaDB).
ShouldApplyExpression string Conditional inclusion. See Conditional Application.
VariantName string Optional label for a conditional variant. Appears in deployment log messages when the variant applies. Max 128 characters.
OldName string "" Previous column name for rename detection. Clear after the rename has deployed everywhere.
Extensions object null Custom metadata for this column. See Custom Properties.

User-defined types

When a database uses user-defined types (CREATE TYPE / CREATE DOMAIN), the DataType value is the type name. The type must be created in the appropriate types script folder (DataTypes/ on SQL Server; Domain Types/, Enum Types/, or Composite Types/ on PostgreSQL) before the table quench runs.

Computed and generated columns

A generated column derives its value from an expression over other columns in the same row — the engine keeps it current on every insert and update, with no triggers and no application-layer logic. SQL Server uses ComputedExpression; PostgreSQL, MySQL, and MariaDB use GenerationExpression. Whether that value is stored on disk or recomputed on every read is a per-platform choice — see each tab below.

Declare the expression with ComputedExpression; set Persisted to store the result on disk.

{
  "Name": "FullName",
  "ComputedExpression": "[FirstName] + ' ' + [LastName]",
  "Persisted": true
}

Declare it with GenerationExpression and "Generated": "ALWAYS". It's STORED by default; set "Virtual": true and PostgreSQL recomputes it on every read (VIRTUAL) instead.

{
  "Name": "full_name",
  "DataType": "TEXT",
  "Generated": "ALWAYS",
  "GenerationExpression": "first_name || ' ' || last_name",
  "Nullable": false
}

A MySQL generated column must be defined after every column it references. SchemaSmith resolves the creation order automatically with a topological sort (and circular-dependency detection) — declare the expression and the tool handles where the column lands. Use "Generated": "STORED" or "VIRTUAL" to control persistence.

{
  "Name": "full_name",
  "DataType": "VARCHAR(255)",
  "GenerationExpression": "CONCAT(first_name, ' ', last_name)",
  "Nullable": false
}

A MariaDB generated column must be defined after every column it references. SchemaSmith resolves the creation order automatically with a topological sort (and circular-dependency detection) — declare the expression and the tool handles where the column lands. Use "Generated": "STORED" or "VIRTUAL" to control persistence.

{
  "Name": "full_name",
  "DataType": "VARCHAR(255)",
  "GenerationExpression": "CONCAT(first_name, ' ', last_name)",
  "Nullable": false
}

Platform-specific column properties

Each engine's column subclass adds fields on top of the shared set, where that engine genuinely differs. Pick your platform:

Extras: CheckExpression, ComputedExpression, Persisted, Sparse, IsColumnSet, Collation, DataMaskFunction, BackfillExistingRows, FileStream. FileStream on a VARBINARY(MAX) column stores its value as a file on an NTFS filegroup instead of in the row; the table must carry a ROWGUIDCOL column covered by a single-column primary key or unique constraint — a unique index looks equivalent and is not. Identity is part of the DataType string (INT IDENTITY(1,1)); ROWGUIDCOL likewise (UNIQUEIDENTIFIER ROWGUIDCOL).

BackfillExistingRows: true populates rows already in the table when a column is added, using that column's Default. SQL Server leaves those rows NULL when a nullable column with a default is added — a NOT NULL column is backfilled anyway — so this is the setting that makes “new nullable column, existing rows get the default” authorable. It requires a Default; without one there is no value to apply and --Validate reports SS-COL-001. It has no effect on a column created as part of a new table, since there are no existing rows. The setting exists only here because every other supported engine backfills the default on ADD COLUMN already.

IsColumnSet: true declares COLUMN_SET FOR ALL_SPARSE_COLUMNS — an XML column that aggregates the table's sparse columns. Available at the SQL Server 2008 floor, alongside Sparse. Adding a column set and the sparse columns it aggregates together in one deploy always works, whether the table is new or already exists.

Known limitation: converting an already-deployed plain column into a column set in the same deploy that also introduces a brand-new sparse column is not supported — the new sparse column commits before the conversion runs, and SQL Server refuses a column set on a table that already has a sparse column.

{
  "Name": "[SparseData]",
  "DataType": "XML",
  "IsColumnSet": true
}

Extras: GenerationExpression (for stored generated columns), Collation. Identity columns are expressed via DataTypeINTEGER GENERATED BY DEFAULT AS IDENTITY or GENERATED ALWAYS.

Identity lives in the DataType string rather than in a separate property, so no extra field is needed to declare it.

{
  "Name": "id",
  "DataType": "INTEGER GENERATED BY DEFAULT AS IDENTITY"
}

Extras: GenerationExpression, CharacterSet, Collation, Comment, Invisible, Srid, OnUpdateCurrentTimestamp. Auto-increment is expressed via DataType (INT AUTO_INCREMENT).

Invisible: true hides a column from SELECT * and from an INSERT that names no column list; the column remains readable when named explicitly. Requires MySQL 8.0.23 — below that the column deploys visible, per the unsupported-feature policy.

Srid restricts a spatial column to one spatial reference system — "Srid": 4326 deploys as col POINT SRID 4326, accepting only geometries in that reference system. Requires MySQL 8.0.3 or later; below that the restriction is silently skipped and the column deploys unrestricted. Omit it for an unrestricted spatial column.

OnUpdateCurrentTimestamp auto-refreshes a TIMESTAMP or DATETIME column on every UPDATE, and an optional fractional-seconds precision round-trips exactly ("CURRENT_TIMESTAMP(3)"). Independent of Default: Default governs the value on INSERT, this governs the refresh on UPDATE — declare either, both, or neither. Available on every supported MySQL version.

{
  "Name": "updated_at",
  "DataType": "TIMESTAMP",
  "Default": "CURRENT_TIMESTAMP",
  "OnUpdateCurrentTimestamp": "CURRENT_TIMESTAMP"
}

Extras: GenerationExpression, CharacterSet, Collation, Comment, Invisible, OnUpdateCurrentTimestamp, WithoutSystemVersioning. Auto-increment is expressed via DataType (INT AUTO_INCREMENT). MariaDB has no spatial-reference restriction attribute at any version, so Srid does not apply here.

Invisible: true hides a column from SELECT * and from an INSERT that names no column list; the column remains readable when named explicitly. Requires MariaDB 10.3 — below that the column deploys visible, per the unsupported-feature policy.

OnUpdateCurrentTimestamp auto-refreshes a TIMESTAMP or DATETIME column on every UPDATE, and an optional fractional-seconds precision round-trips exactly ("CURRENT_TIMESTAMP(3)"). Independent of Default: Default governs the value on INSERT, this governs the refresh on UPDATE — declare either, both, or neither. Available on every supported MariaDB version.

{
  "Name": "updated_at",
  "DataType": "TIMESTAMP",
  "Default": "CURRENT_TIMESTAMP",
  "OnUpdateCurrentTimestamp": "CURRENT_TIMESTAMP"
}

Always Encrypted SQL Server

Always Encrypted lets SQL Server store sensitive column data in encrypted form that the server itself cannot read — only authorized clients holding the Column Master Key can decrypt. It is a SQL Server feature with no equivalent on PostgreSQL, MySQL, or MariaDB. SchemaTongs extracts encrypted columns and captures all three encryption properties; SchemaQuench declares them with the exact ENCRYPTED WITH (…) syntax SQL Server requires — extract once, deploy everywhere the CMK is distributed, no hand-written DDL. These three properties appear on SQL Server column definitions only and are ignored on other platforms.

Property Type Default Description
EncryptionType string "NONE" "DETERMINISTIC" (repeatable ciphertext, supports equality comparisons) or "RANDOMIZED" (non-repeatable ciphertext, stronger protection). "NONE" or absent means no encryption.
EncryptionKey string "" Column Encryption Key name, bracket-wrapped (e.g., "[MyCEK]"). Must match a CEK already installed in the target database.
EncryptionAlgorithm string "" Encryption algorithm name. The only currently supported value is "AEAD_AES_256_CBC_HMAC_SHA_256".

When EncryptionType is not "NONE", SchemaQuench emits this clause into the column DDL:

ENCRYPTED WITH (
    COLUMN_ENCRYPTION_KEY = [EncryptionKey],
    ENCRYPTION_TYPE = [EncryptionType],
    ALGORITHM = '[EncryptionAlgorithm]'
)
{
  "Name": "[SSN]",
  "DataType": "NVARCHAR(11)",
  "Nullable": false,
  "Collation": "Latin1_General_BIN2",
  "EncryptionType": "DETERMINISTIC",
  "EncryptionKey": "[CustomerCEK]",
  "EncryptionAlgorithm": "AEAD_AES_256_CBC_HMAC_SHA_256"
}

Changing encryption on a populated column is not supported in-quench. If you change EncryptionType, EncryptionKey, or EncryptionAlgorithm on a column that already has data — or add encryption to a column that was previously plaintext — SchemaQuench raises a hard error before any DDL runs, names the column, and leaves it untouched. This is a fundamental SQL Server constraint, not a tooling limitation: a standard server holds no Column Master Key and cannot re-encrypt data server-side. The guard fires in both live and WhatIf mode, so the constraint shows up in previews. The workaround is a full-table rebuild — a Before script that creates the new table with the target encrypted schema and copies rows over a Column Encryption Setting=Enabled connection (the driver handles decrypt/re-encrypt client-side), then an After script that recreates foreign keys, constraints, and extended properties. Run it in a maintenance window.

Encrypted columns need a BIN2 collation

Encrypted character columns require a BIN2 collation (e.g., Latin1_General_BIN2). SQL Server enforces this — a non-binary collation on an encrypted column fails at DDL execution time.

See the platform-specific reference

Each platform adds engine-specific column fields — collation, data masking, generated-column syntax, character sets. Worked examples live on the SQL Server, PostgreSQL, MySQL, and MariaDB daily workflows pages.

Indexes

Every entry in the Indexes array defines an index or key constraint on the table. The shared shape covers the common cases; per-platform index types extend it.

Shared index properties

Property Type Default Description
Name string Index or constraint name.
PrimaryKey bool false true for a primary key constraint.
Unique bool false true for a unique index.
UniqueConstraint bool false true for a UNIQUE constraint (as opposed to a unique index).
IndexColumns string Comma-separated column names with optional sort direction.
IncludeColumns string Comma-separated INCLUDE / covering columns where supported.
FilterExpression string Filtered / partial index WHERE clause.
ShouldApplyExpression string Conditional inclusion. See Conditional Application.
VariantName string Optional label for a conditional variant. Appears in deployment log messages when the variant applies. Max 128 characters.
Extensions object null Custom metadata. See Custom Properties.

Examples

SQL Server quoting; clustered primary key.

{
  "Name": "[PK_Product_ProductID]",
  "PrimaryKey": true,
  "Unique": true,
  "Clustered": true,
  "IndexColumns": "[ProductID]"
}

PostgreSQL filtered / partial index.

{
  "Name": "ux_customer_email_active",
  "Unique": true,
  "IndexColumns": "email",
  "FilterExpression": "is_active = true"
}

SQL Server covering index with INCLUDE columns.

{
  "Name": "[IX_Customer_Name]",
  "IndexColumns": "[LastName] ASC, [FirstName] ASC",
  "IncludeColumns": "[Email], [Phone]"
}

Platform-specific index types

Beyond the shared shape, each engine adds its own index types and modifiers.

Extras: Clustered, ColumnStore, CompressionType (NONE / ROW / PAGE), FillFactor, UpdateFillFactor, FileGroup, PartitionScheme, PartitionColumn, IgnoreDuplicateKey, PadIndex, BucketCount. PartitionScheme and PartitionColumn place an index independently of its table — an index is not required to be aligned with the table it indexes. IgnoreDuplicateKey changes what your application sees rather than how fast it runs: off, a duplicate insert fails the whole statement with error 2601 and nothing is written; on, the duplicate row is discarded with a warning and the rest of the statement succeeds, so a multi-row INSERT containing one duplicate lands the other rows instead of rolling back. It is only valid on a unique index or unique constraint. PadIndex applies FillFactor to the intermediate pages as well as the leaf pages, and has no effect without one. BucketCount applies only to an index on a memory-optimized table, where it makes the index a hash index. An index created with no FileGroup of its own follows its table's filegroup, not the database default; an index is declared independently of its table's filegroup, which lets you keep a large table's data and its indexes on separate storage. Add "ColumnStore": true to build a columnstore index instead of the default B-tree — the right shape for analytic scans over millions of rows — and pair it with "CompressionType": "COLUMNSTORE_ARCHIVE" for maximum compression on cold data.

{
  "Name": "[CCI_Sales]",
  "ColumnStore": true,
  "CompressionType": "COLUMNSTORE_ARCHIVE"
}

Extras: AccessMethod, Tablespace, and IncludeColumns (PostgreSQL 11+ covering indexes). Set AccessMethod to pick the index method — gin for JSONB and arrays, gist for ranges and geometry, brin for append-only time-series — and SchemaSmith emits the matching USING clause.

{
  "Name": "ix_docs_tags",
  "IndexColumns": "tags",
  "AccessMethod": "gin"
}

Extras: IndexType (BTREE / HASH) and a visible/invisible flag. Set "IndexType": "SPATIAL" to index a geometry column — SchemaSmith emits CREATE SPATIAL INDEX, so you can pair it with MySQL's spatial functions for proximity and bounding-box queries.

{
  "Name": "sp_geom",
  "IndexColumns": "location",
  "IndexType": "SPATIAL"
}

Extras: IndexType (BTREE / HASH) and a visible/invisible flag. Set "IndexType": "SPATIAL" to index a geometry column — SchemaSmith emits CREATE SPATIAL INDEX, so you can pair it with MariaDB's spatial functions for proximity and bounding-box queries.

{
  "Name": "sp_geom",
  "IndexColumns": "location",
  "IndexType": "SPATIAL"
}

Full-text indexes

Natural-language search over text columns needs a full-text index — a catalog-backed structure the engine manages separately from its B-tree indexes. It's declared as a property on the table, not as an entry in the Indexes array.

SQL Server allows one full-text index per table — but that one index can cover multiple text columns. Declare it as a single FullTextIndex object, or as an array of conditional variants when different targets need different definitions (a different catalog per region, say). At deploy time each variant's ShouldApplyExpression runs against the target and the matching variant deploys.

Property Type Description
FullTextCatalog string Name of the full-text catalog.
KeyIndex string Name of the unique index used as the full-text key.
ChangeTracking string "OFF", "MANUAL", or "AUTO".
StopList string Name of a full-text stop list.
Columns string Comma-separated column specification, e.g. "[Title],[Body] TYPE COLUMN [BodyType] LANGUAGE 1033 STATISTICAL_SEMANTICS". Each entry is a bracketed column name, optionally followed by TYPE COLUMN [col] (the column holding a binary document's file extension), LANGUAGE <lcid> (the word breaker to tokenize with), and STATISTICAL_SEMANTICS.
ShouldApplyExpression string Boolean expression evaluated on the target; the index (or variant) applies only when true.
VariantName string Optional label for a conditional variant. Max 128 characters.

Variant rules:

  • One match per target. With more than one variant, every variant must declare a ShouldApplyExpression, and those expressions must be mutually exclusive on any given target. Two variants matching the same target fails the deployment with a clear error.
  • No match means none. When no variant matches a target, any existing full-text index on that table is removed — absence of a match is treated as "no full-text index here," not "leave it alone."
  • No-op when unchanged. When the deployed index already matches the selected variant, re-deployment does no full-text work: no drop, no repopulation.
"FullTextIndex": [
  {
    "FullTextCatalog": "[FTC_East]",
    "KeyIndex": "[PK_Docs]",
    "Columns": "[Title],[Body]",
    "ShouldApplyExpression": "'{{Region}}' = 'East'"
  },
  {
    "FullTextCatalog": "[FTC_West]",
    "KeyIndex": "[PK_Docs]",
    "Columns": "[Title],[Body]",
    "ShouldApplyExpression": "'{{Region}}' = 'West'"
  }
]

MySQL allows multiple full-text indexes per table, so the property is FullTextIndexes (an array). Each index can specify a custom parser — "Parser": "ngram" for CJK and other non-space-delimited languages, for example. SchemaSmith creates each one with CREATE FULLTEXT INDEX … WITH PARSER when a parser is specified.

Property Type Description
Name string Index name.
Columns string Comma-separated column names.
Parser string Optional parser name (e.g., "ngram").
Comment string Index comment.
ShouldApplyExpression string Conditional inclusion.
VariantName string Optional label for a conditional variant. Max 128 characters.
"FullTextIndexes": [
  {
    "Name": "ft_body",
    "Columns": "title, body",
    "Parser": "ngram"
  }
]

MariaDB allows multiple full-text indexes per table, so the property is FullTextIndexes (an array). Each index can specify a custom parser. SchemaSmith creates each one with CREATE FULLTEXT INDEX … WITH PARSER when a parser is specified.

Property Type Description
Name string Index name.
Columns string Comma-separated column names.
Parser string Optional parser-plugin name. MariaDB ships no full-text parser plugins by default.
Comment string Index comment.
ShouldApplyExpression string Conditional inclusion.
VariantName string Optional label for a conditional variant. Max 128 characters.
"FullTextIndexes": [
  {
    "Name": "ft_body",
    "Columns": "title, body"
  }
]

See the platform-specific reference

Each platform extends the shared index properties with engine-native features — clustered and columnstore indexes, access methods, index types, visibility flags. Worked examples live on the SQL Server, PostgreSQL, MySQL, and MariaDB daily workflows pages.

Foreign keys

Referential integrity lives in the table JSON alongside columns and indexes, not in separate migration scripts. The ForeignKeys array captures each relationship: the local columns, the related table they reference, and the optional cascade actions for deletes and updates. SchemaQuench reads the array, diffs it against the live database, and adds, drops, or recreates constraints to match.

Property Type Default Description
Name string Constraint name.
Columns string Comma-separated local column names.
RelatedTableSchema string platform default Schema of the referenced table (dbo on SQL Server, public on PostgreSQL, omitted on MySQL and MariaDB).
RelatedTable string Referenced table name.
RelatedColumns string Comma-separated referenced column names.
DeleteAction string null "NO ACTION", "CASCADE", "SET NULL", "SET DEFAULT", or "RESTRICT" where supported.
UpdateAction string null Same values as DeleteAction.
ShouldApplyExpression string Conditional inclusion. See Conditional Application.
VariantName string Optional label for a conditional variant. Appears in deployment log messages when the variant applies. Max 128 characters.
Extensions object null Custom metadata. See Custom Properties.

For composite foreign keys, list all columns in both Columns and RelatedColumns in matching order.

Check constraints

Table-level check constraints sit in the CheckConstraints array. Used when CheckConstraintStyle is "TableLevel" in Product.json, or when a check constraint spans multiple columns.

Property Type Default Description
Name string Constraint name.
Expression string Boolean SQL expression.
ShouldApplyExpression string Conditional inclusion. See Conditional Application.
VariantName string Optional label for a conditional variant. Appears in deployment log messages when the variant applies. Max 128 characters.
Extensions object null Custom metadata. See Custom Properties.

When CheckConstraintStyle is "ColumnLevel" (the default), single-column check constraints are written as CheckExpression on the column instead. Multi-column constraints always use the CheckConstraints array.

Memory-optimized tables SQL Server

A memory-optimized (In-Memory OLTP) table lives in memory rather than on disk pages, with a lock-free concurrency model that removes the latch and lock contention a hot table hits under load. SQL Server asks a lot in return: the table is built differently, its indexes are declared differently, and almost nothing about it can be altered afterwards.

SchemaSmith declares the whole shape in one table file — the engine choice, the durability, and the inline indexes — and refuses by name the changes SQL Server cannot make, rather than emitting DDL that would fail halfway. Set MemoryOptimized to true, and Durability if you want "SCHEMA_ONLY"; the default keeps both the schema and the rows across a restart.

{
  "Schema": "[dbo]",
  "Name": "[SessionCache]",
  "MemoryOptimized": true,
  "Durability": "SCHEMA_ONLY",
  "Columns": [
    { "Name": "[SessionId]", "DataType": "BIGINT", "Nullable": false },
    { "Name": "[Payload]", "DataType": "NVARCHAR(4000)", "Nullable": true }
  ],
  "Indexes": [
    { "Name": "[PK_SessionCache]", "IndexColumns": "[SessionId]", "PrimaryKey": true, "Unique": true, "BucketCount": 1000000 }
  ]
}

Prerequisites

The database needs a MEMORY_OPTIMIZED_DATA filegroup and the server needs In-Memory OLTP support. SchemaSmith creates neither, and does not degrade the table to a disk table when they are missing — it stops before any DDL runs, naming the table. Quietly deploying a disk table would change the table's durability and concurrency semantics while reporting success, leaving the application running against something other than what the package asked for. Add the filegroup in a migration script, or drop MemoryOptimized.

Indexes are inline

SQL Server rejects CREATE INDEX against a memory-optimized table, so every index is emitted inside the CREATE TABLE statement. Declare them in the ordinary Indexes array and SchemaSmith places them correctly — a primary key becomes PRIMARY KEY NONCLUSTERED, and an index with a BucketCount becomes a HASH index. CompressionType and XmlCompression are ignored here; neither applies to a memory-optimized table.

What is refused

SQL Server has no ALTER for any of these, so SchemaSmith reports them by name and stops rather than attempting them. Each is a table recreate — do it in a migration script.

ChangeWhy it cannot be applied
MemoryOptimized on or offNo ALTER converts a table to or from the In-Memory engine.
DurabilityNo ALTER for a memory-optimized table's durability.
Adding, removing, or altering an inline indexMemory-optimized indexes are immutable through ordinary CREATE / DROP INDEX — including a uniqueness change or a change of key columns.
BucketCountSame immutability. Comparison allows for SQL Server rounding the count up to the next power of two, so only a genuine change is reported.

Ownership

Every other SQL Server table carries its SchemaSmith ownership in a ProductName extended property. Memory-optimized tables reject extended properties outright, so ownership for them is recorded in a SchemaSmith.ProductOwnership table in the target database instead. This is a storage change, not a behavior change: drop-by-absence, cross-product protection and PreventDrop all work exactly as they do elsewhere. Rows for tables that no longer exist are pruned on each deploy.

Placement cannot be combined

A memory-optimized table lives in the MEMORY_OPTIMIZED_DATA filegroup, so it cannot also declare FileGroup, TextImageFileGroup, FileStreamFileGroup, or PartitionScheme. --Validate reports that combination as SS-XTP-001 before you deploy.

Partitioning MySQL and MariaDB

MySQL and MariaDB carry a table's partition definition in the table DDL itself — there is no separate scheme object to point at, the way SQL Server has one — so the package carries the whole definition, declared through the table's Partitioning object.

It is applied when the table is created, and a change on a deployed table is refused. ALTER TABLE … PARTITION BY rewrites every row, and comparing two layouts cannot tell you whether a split or a merge was intended — so a declaration that disagrees with the deployed table names both and stops rather than attempting an ambiguous change.

PropertyTypeDefaultDescription
MethodstringRANGE, LIST, HASH, KEY, RANGE COLUMNS or LIST COLUMNS. Required. The COLUMNS forms take a column list rather than an expression, and compare values column by column.
ExpressionstringThe partitioning expression (Id, YEAR(created)), or a comma-separated column list for the COLUMNS methods. Required.
PartitionCountintnullHASH and KEY only: how many partitions to spread across. RANGE and LIST name each partition individually instead.
Partitionsarray[]RANGE and LIST only, in declared order. Each entry is a Name and a Values — what follows VALUES LESS THAN for RANGE (a value, a tuple for RANGE COLUMNS, or MAXVALUE) or VALUES IN for LIST. RANGE boundaries must ascend, and the engine rejects a definition where they do not, so the list is written and read in declared order rather than sorted.
{
  "Name": "`order_history`",
  "Columns": [
    { "Name": "`Id`", "DataType": "int", "Nullable": false },
    { "Name": "`Placed`", "DataType": "date", "Nullable": false }
  ],
  "Indexes": [
    { "Name": "PRIMARY", "PrimaryKey": true, "Unique": true, "IndexColumns": "`Id`" }
  ],
  "Partitioning": {
    "Method": "RANGE",
    "Expression": "Id",
    "Partitions": [
      { "Name": "p_early", "Values": "1000000" },
      { "Name": "p_rest",  "Values": "MAXVALUE" }
    ]
  }
}

The comparison ignores backticks, whitespace and case, because the engines do not agree on how they report a partition expression back: MySQL 5.7 returns the text you wrote, while MySQL 8, MariaDB 10.2 and MariaDB 11.4 all return a rewritten form. Without normalizing, the same package would look changed on one engine and unchanged on another.

MySQL requires every UNIQUE and PRIMARY KEY to contain every partitioning column — the engine's rule, not SchemaSmith's, and a definition breaking it fails with the engine's own error. Leaving Partitioning unset means SchemaSmith does not manage partitioning at all, so a table someone partitioned by hand is left exactly as it is.

System versioning and periods MariaDB

Set IsSystemVersioned and the table keeps its own row history. A new table is created WITH SYSTEM VERSIONING, and an existing ordinary table that starts declaring it converges through ALTER TABLE … ADD SYSTEM VERSIONING. Removing it is refused by name: MariaDB's DROP SYSTEM VERSIONING purges the accumulated row history rather than switching the attribute off, so SchemaSmith stops instead — and the refusal fires under --WhatIf too. Extraction only writes the property when it is true, so that refusal reaches you only after a deliberate hand-edit. Requires MariaDB 10.3; below that, and on MySQL at any version, the clause is dropped and the degrade is reported through Target:UnsupportedFeaturePolicy.

A period is a different question. It names a pair of columns describing the interval a row is valid for — the dates a price applied, or an assignment ran — where system versioning records when a row was stored. A table can declare both, and the values in an application-time period are the application's to set. Each entry in the Periods array is a Name, a StartColumn and an EndColumn:

{
  "Name": "`Rate`",
  "Periods": [
    { "Name": "Validity", "StartColumn": "ValidFrom", "EndColumn": "ValidTo" }
  ]
}

The SYSTEM_TIME period is not listed in Periods. MariaDB reports it alongside application periods, but the table already declares that state through IsSystemVersioned, and carrying it in both places would let a package contradict itself. A period the package no longer declares is removed only if you set DropPeriodsRemovedFromProduct, which defaults to off unlike most of the Drop…RemovedFromProduct family, because a package with no Periods entry may simply predate period support rather than be asserting the table has none.

Extraction has a version blind spot, and it is not the version you would expect. Periods work from MariaDB 10.4.3, but the catalog that reports them only arrives in 11.4. Extracting from anything in between returns no periods even where the table plainly has them, so a package round-tripped through such a server loses them. Deploying a declared period to those versions works normally; only the read is blind. If you extract from MariaDB below 11.4, check your periods survived.

WithoutSystemVersioning excludes a single column from a versioned table's row history, so an UPDATE touching only that column writes no history row — useful for a large or high-churn column whose history is not worth keeping. It is meaningless unless the table is system-versioned: MariaDB accepts the clause on an ordinary table and silently discards it, so --Validate reports SS-SV-001 rather than letting it look applied. Changing it on a deployed column is an ALTER, so it needs SystemVersioningAlterHistory: "KEEP". Requires MariaDB 10.3.4+.

Change data capture SQL Server

Set EnableCDC to true and the table records its inserts, updates and deletes into a change table that SQL Server maintains. Downstream readers consume what happened instead of polling the live table for differences.

{
  "Name": "[Orders]",
  "Schema": "[dbo]",
  "EnableCDC": true
}

The column set you capture is locked in at the moment CDC is enabled. A table tracking three columns keeps capturing those three columns, whatever schema changes happen afterwards — which is what makes a schema change on a tracked table worth understanding.

CDC must be enabled on the database first, using sys.sp_cdc_enable_db. SchemaSmith does not do this for you, and that is deliberate: the call changes retention, cleanup jobs and storage for every table in the database, which is not a decision one table’s package should make. Enabling CDC at the database level is an administrator’s election, and the package respects that boundary.

Declare EnableCDC without running that command first and the table still deploys. Capture is reported as downgraded in the deploy log, the affected tables are named, and the command to enable it is printed — the outcome is reported, never silent. If the target’s unsupported-feature policy is set to fail, the deploy fails before touching any columns instead, and again names the tables.

When a deploy changes the columns of a tracked table, SchemaSmith creates a second capture instance covering the new column set while leaving the original instance and its captured history untouched. The command to remove the old instance is printed in the log, but removing it is your call, because only you know when your downstream readers have finished draining it. That command is sys.sp_cdc_disable_table.

Setting EnableCDC back to false disables capture on the table and drops its capture instances and their history. That is a deliberate opt-out, not a side effect of any schema change.

Two capture instances is the ceiling

SQL Server allows two capture instances per table, so the old instance occupies one of the two slots and a second column change before it is removed has nowhere to rotate to. SchemaSmith refuses that deploy before touching any column, naming the tables at the limit and the command to clear them, so nothing is left half-applied. Drop the drained instance and re-run the deploy.

Pick your platform

Each platform reference covers the complete table JSON format, the DDL SchemaQuench emits, and the platform-native concepts it handles for that engine.

SQL Server

Walk through adding tables with INT IDENTITY, modifying columns, writing CREATE OR ALTER procedures, extracting drift with SchemaTongs, and bootstrapping new databases with the Initialize template.

PostgreSQL

Walk through adding tables with GENERATED AS IDENTITY, modifying columns, writing CREATE OR REPLACE functions, extracting drift with SchemaTongs, and bootstrapping new databases with the Initialize template.

MySQL

Walk through adding tables with AUTO_INCREMENT, modifying columns, writing procedures with DELIMITER //, extracting drift with SchemaTongs, and bootstrapping new databases with the Initialize template.

MariaDB

Walk through adding tables and columns, changing data types, adding indexes, writing procedures and views with CREATE OR REPLACE, previewing changes with WhatIf, and extracting drift with SchemaTongs.

Hands-on lab

Write your first table as a JSON file with columns, indexes, and a primary key, then deploy it as a package.

Start the first package

Shape. Strengthen. Succeed.

Free schema-as-code for SQL Server, PostgreSQL, MySQL, and MariaDB.

The source is on GitHub for anyone to read.

Get Started on GitHub