SQL schema
Use plain SQL DDL files as Ptah's desired schema.
Use SQL schema files when the desired schema is already written as local DDL (Data Definition Language). Ptah parses the file through its compatibility SQL parser; unsupported DDL fails explicitly instead of being skipped.
Write a schema file
Section titled “Write a schema file”Create schema.sql:
CREATE TABLE users ( id INTEGER PRIMARY KEY, email TEXT NOT NULL);PostgreSQL extension placement is preserved too. In a separate
extensions.sql, Ptah accepts both the optional WITH spelling and the bare
SCHEMA clause:
CREATE SCHEMA extensions;CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions VERSION '1.3';Render that PostgreSQL-specific file with the PostgreSQL dialect:
ptah schema render --schema-file extensions.sql --dialect postgresExpected output includes the schema precondition before the extension:
CREATE SCHEMA IF NOT EXISTS "extensions";
CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions" VERSION '1.3';A column type may name its schema, quoted or not, the way pg_dump writes a
type outside the search path: m app.mood, m "app"."Mood"[],
g public.geometry(Point, 4326). A comparison matches it to the type the
database reports by schema and name. A column moved to a type of the same name
in another schema plans the change.
See PostgreSQL defaults.
Render it
Section titled “Render it”ptah schema render --schema-file schema.sql --dialect sqliteExpected output includes:
CREATE TABLE "users" ( "id" INTEGER PRIMARY KEY, "email" TEXT NOT NULL);Rendering a SQL file back out proves the parser understood every statement,
and can retarget the schema at another dialect.
--schema-file is accepted wherever Ptah needs a desired schema:
ptah schema render, ptah schema compare, ptah schema drift, the
migration commands (ptah migrations plan / ptah migrations generate), and
every target of ptah schema export except hcl. That
includes the two documentation targets, so
a Markdown or HTML reference can be generated from this file.
Path confinement is shared by every --schema-file source; see
Schema file paths.
For PostgreSQL application roles, see Conditional role bootstrap.
Add a column after the table
Section titled “Add a column after the table”A column can be added with ALTER TABLE ... ADD COLUMN in the same file,
a schema directory, or an ordered atlas.hcl SQL src list. Columns follow
declaration order.
CREATE TABLE users (id integer PRIMARY KEY);ALTER TABLE users ADD COLUMN IF NOT EXISTS password_changed_at timestamptz;IF NOT EXISTS makes the statement do nothing for a column the table already
declares the same way. A column declared again is refused otherwise: without
IF NOT EXISTS the server refuses it, and with it the server keeps the first
declaration, so either reading would drop what the other one says.
Limit ON DELETE to some columns
Section titled “Limit ON DELETE to some columns”PostgreSQL 15 and later take a column list after ON DELETE SET NULL and
ON DELETE SET DEFAULT. The action then changes only the listed columns, so
the other columns of a composite key may be NOT NULL:
CREATE TABLE parents (tenant integer, id integer, PRIMARY KEY (tenant, id));CREATE TABLE children ( id integer PRIMARY KEY, tenant integer NOT NULL, parent_id integer, FOREIGN KEY (tenant, parent_id) REFERENCES parents (tenant, id) ON DELETE SET NULL (parent_id));Deleting a parent row sets parent_id to NULL and keeps tenant. The list is
compared as a set of columns, and a list that names every column of the key
means the same as no list.
A declaration the server would refuse, or would accept and then fail on when a parent row is deleted, is refused:
- a
NOT NULLcolumn in the list, or aNOT NULLkey column with no list; - a list after any action other than
SET NULLorSET DEFAULT, or afterON UPDATE; - a column that is not part of the key. A column-level
REFERENCEScan list only its own column.
A target without the clause refuses the list instead of widening the action to every column: PostgreSQL before 15, YugabyteDB 2024.2, CockroachDB, and the other engines. The Go annotation and Atlas HCL exports refuse such a key for the same reason, because neither format can write the list.
Defer a check to the end of the transaction
Section titled “Defer a check to the end of the transaction”A foreign key, a primary key, a UNIQUE and an EXCLUDE can put their check
off. The clauses follow the constraint, on the table or on its column, in any
order:
CREATE TABLE orders (id integer PRIMARY KEY);CREATE TABLE lines ( id integer PRIMARY KEY, order_id integer REFERENCES orders (id) DEFERRABLE INITIALLY DEFERRED, position integer UNIQUE DEFERRABLE, replaces integer, FOREIGN KEY (replaces) REFERENCES lines (id) INITIALLY DEFERRED);INITIALLY DEFERRED alone makes the constraint deferrable, as on
PostgreSQL. The reader refuses what PostgreSQL 18.6 refuses: DEFERRABLE
beside NOT DEFERRABLE, two timings, INITIALLY DEFERRED beside NOT DEFERRABLE, a clause after a CHECK, and a clause after a column that
declares no key, as in a integer NOT NULL DEFERRABLE.
A deferrable key on a column is read as the table’s key over the column, as PostgreSQL reports it. A key that defers is a different key from one that does not, so changing the clause plans a drop and an add. A foreign key cannot reference a deferrable key; PostgreSQL refuses it.
A render for a target without the clause refuses the constraint rather than write one that checks at once:
| Target | Foreign key | Primary key, UNIQUE |
|---|---|---|
| PostgreSQL | yes | yes, and EXCLUDE |
| Oracle | yes | yes |
| YugabyteDB | yes | no |
| SQLite | yes, DEFERRABLE first |
no |
| CockroachDB, MySQL, MariaDB | no | no |
The reader refuses the clauses on MySQL, MariaDB and CockroachDB, which
refuses even NOT DEFERRABLE, and on SQLite after anything but a foreign key.
The Atlas HCL and Go annotation exports refuse
a deferrable key, because neither format can write one. The clauses
PostgreSQL takes for the index behind a key, WITH (...) and USING INDEX TABLESPACE, are refused by name.
Clauses after a constraint or a key
Section titled “Clauses after a constraint or a key”These clauses after a table element change nothing the server builds, and the reader takes them:
ENFORCEDafter aCHECK, and after a foreign key on PostgreSQL. MySQL takes it after aCHECKonly, and MariaDB not at all.NOT VALIDafter aCHECKor a foreign key in a PostgreSQLCREATE TABLE, where the server records the constraint as validated.MATCH SIMPLEafterREFERENCESand its columns, beforeON DELETEandON UPDATE.- On MySQL and MariaDB, the index options
VISIBLE, MariaDB’sNOT IGNORED, andUSING BTREEorUSING HASHafter the parts of a key, the primary key included. A method written there is the oneKEY k USING HASH (a)asks for, and the later clause wins.
The PostgreSQL
and MySQL pages cover NOT ENFORCED, MATCH types,
index COMMENT, INVISIBLE, IGNORED and KEY_BLOCK_SIZE [=] n. Primary
keys keep COMMENT and KEY_BLOCK_SIZE too. NOT VALID after
ALTER TABLE ... ADD is preserved.
The index options ENGINE_ATTRIBUTE and SECONDARY_ENGINE_ATTRIBUTE have no
model fields and are refused by name. The reader also refuses a clause the
dialect’s server rejects, such as ENFORCED after a UNIQUE.
Change a table after creating it
Section titled “Change a table after creating it”A schema file is read as a script. An ALTER TABLE after the CREATE TABLE,
in the same file, a later file of a schema directory or an imported file,
changes the table the way the server would:
CREATE TABLE items (id integer NOT NULL, qty integer, note text, legacy text);ALTER TABLE items ADD PRIMARY KEY (id);ALTER TABLE items ALTER COLUMN qty SET DEFAULT 1, ALTER COLUMN qty SET NOT NULL;ALTER TABLE items ALTER COLUMN note TYPE varchar(200);ALTER TABLE items DROP COLUMN legacy;DROP TABLE and DROP INDEX remove the object and what the server drops with
it. A drop the server refuses is refused: an undeclared object without
IF EXISTS, a table a foreign key or a PostgreSQL view still reads, the index
behind a constraint, and the last index a MySQL or MariaDB foreign key needs.
On PostgreSQL, ALTER INDEX ... RENAME TO renames an index, and the constraint
it backs. A column’s own UNIQUE becomes a named one. A name another relation
holds is refused.
These operations are read:
ADD COLUMN,ADD PRIMARY KEYandADD CONSTRAINT(UNIQUE,CHECK,FOREIGN KEY);ALTER [COLUMN] ... SET DEFAULT,DROP DEFAULT,SET NOT NULL,DROP NOT NULLand[SET DATA] TYPE, with an optionalUSING;DROP [COLUMN] [IF EXISTS]andRENAME [COLUMN] ... TO;DROP CONSTRAINT [IF EXISTS]andRENAME CONSTRAINT, by the name the declaration gave the constraint or, for a column’s ownUNIQUE, the name the server gives it;- the MySQL family’s
MODIFY [COLUMN],DROP PRIMARY KEY,DROP FOREIGN KEY,DROP CHECK,DROP INDEXandRENAME INDEX, and SQL Server’sALTER COLUMNwith a new definition.
MODIFY keeps the column’s keys, as the server does. UNIQUE in it adds a
key, so on a column with its own key it adds a second one, such as x_2
beside x.
Anything else is refused by name rather than read as nothing: an ALTER COLUMN
action other than those above, RENAME TO, CASCADE, and an operation on a
table the document does not declare. So is an operation the server would
refuse:
- a second primary key;
DROP NOT NULLorDROP COLUMNon a primary key column;- a missing column or constraint, or a name already held.
Dropping or renaming a column that an index, a constraint, an expression or another table’s foreign key still names is refused too, naming that object. PostgreSQL drops or follows the object with the column, but the schema file keeps its text. Drop the object first, or declare the column under its final name.
Comments
Section titled “Comments”A comment can be set with a separate COMMENT ON, the way pg_dump writes
one, in the same file or in a later file of a schema directory:
CREATE TABLE notes (id integer PRIMARY KEY, body text);COMMENT ON TABLE notes IS 'what users wrote';COMMENT ON COLUMN notes.body IS 'the text, as typed';COMMENT ON is read in the forms pg_dump writes for the objects Ptah models:
COMMENT ON FUNCTION app.score(integer, text) IS 'ranks a note';COMMENT ON MATERIALIZED VIEW app.totals IS 'refreshed nightly';COMMENT ON TRIGGER stamp_notes ON app.notes IS 'sets updated_at';COMMENT ON POLICY own_notes ON app.notes IS 'owners only';COMMENT ON CONSTRAINT positive ON app.notes IS 'ids start at one';COMMENT ON TYPE app.mood IS 'how a user feels';That covers a table, a column, an index, a constraint, a schema, a role, a
view, a materialized view, a sequence, a domain, a composite, range or enum
type, an extension, a function, a procedure, a trigger and a policy, and the
plan writes each of them to the database. A function or a procedure may be
named with its argument list, with or without argument names, or by its name
alone when the document declares one routine of that name. A trigger, a policy
and a constraint are named ON their table.
A statement that cannot be applied is refused by name, with the file it came from:
- a comment on an object the document does not declare, or on a trigger or a policy of another table;
- a routine named by its name alone when the document declares more than one overload of it, or a function named as a procedure;
- a comment on a domain’s constraint (
ON DOMAIN) or on any other kind of object, such as a foreign table or a cast; IS NULL.
A document may declare several overloads of a function or a procedure, as
pg_dump writes them. Each is a routine of its own, told apart by its input
argument types, so COMMENT ON FUNCTION app.f(integer, text) names one of them.
Row-level security
Section titled “Row-level security”A PostgreSQL schema file declares row-level security with the statements a migration would run:
CREATE TABLE sites (id uuid PRIMARY KEY, tenant_id uuid NOT NULL);ALTER TABLE sites ENABLE ROW LEVEL SECURITY;ALTER TABLE sites FORCE ROW LEVEL SECURITY;CREATE POLICY sites_tenant ON sites USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);CREATE POLICY sites_scope ON sites AS RESTRICTIVE USING (current_setting('app.site_scope', true) = 'all');FORCE binds the table’s owner to its policies; without it the owner reads and
writes past every one of them. ENABLE and FORCE may come in either order.
AS RESTRICTIVE narrows what the permissive policies admit, and AS PERMISSIVE is the default. Both flags are compared with the database and
planned in both directions; PostgreSQL
has the details.
Three spellings are refused rather than read. DISABLE ROW LEVEL SECURITY and
NO FORCE ROW LEVEL SECURITY take a protection away, and a schema file says a
table has none by not declaring it. A FORCE for a table the file never
enables is refused too: PostgreSQL accepts it, and it changes nothing until the
table enables row-level security.
API export metadata
Section titled “API export metadata”SQL DDL cannot author Ptah’s export-only api_name, openapi_name,
graphql_name, proto_name, api_type, or api_expose metadata. Exports
work from a SQL schema, with public names, types, and exposure derived from the
persistence schema. Use
YAML, HCL, or Go annotations when
the published contract must differ from database names and types.
--dialect decides how the file is read, not only how it is written
Section titled “--dialect decides how the file is read, not only how it is written”The dialect selects the tokenizer as well as the renderer: whether a backslash
escapes inside a string, whether E'...' is an escape string, whether a string
continues on the next line, whether --x without a space is a comment, and
whether [name] is an identifier.
A file that the named engine would reject is rejected here. PostgreSQL runs
with standard_conforming_strings on, so a backslash is an ordinary character
and DEFAULT 'a\'b' is an unterminated string — PostgreSQL 18 answers
unterminated bit string literal. Read with --dialect postgres, Ptah refuses
it too. Read with --dialect mysql, where a backslash escapes, the same bytes
are a valid default.
A PostgreSQL string constant is read in every spelling the server reads.
With --dialect postgres, a comment, a column or domain default and an enum
label may each be written as a dollar-quoted string, an E'...' escape string,
a U&'...' Unicode escape string with an optional UESCAPE clause, or a
string continued on the next line:
COMMENT ON COLUMN notes.body IS 'the text, ' 'as typed';COMMENT ON TABLE notes IS $c$what users wrote, apostrophes and all$c$;Each reads as the one string the server stores, so the file compares clean
against a database it was applied to. The continuation follows PostgreSQL’s
rule: only whitespace between the two strings, with at least one newline in
it, and a -- comment counts as whitespace. Two strings on one line, or with a
block comment between them, are refused, as PostgreSQL refuses them, and so is
an escape the server rejects, such as U&'\0000'. With --dialect cockroachdb,
yugabytedb or spanner, a comment, default or label written as a continued
string or a U&'...' string is refused: CockroachDB applies a different
continuation rule and has no U&'...' strings, and the other two have not been
measured.
A version-guarded span is stepped over, and the one clause a schema needs is
read out of it. mysqldump writes a full-text index’s parser as
FULLTEXT KEY `ft` (`bio`) /*!50100 WITH PARSER `ngram` */, and that clause
reaches the schema. The rest of a guard is not read: those spans hold
version-conditional fragments Ptah does not model, and mariadb-dump opens
every file with /*M!999999\- enable the sandbox mode */ — a guard no server
executes, because no server is version 999999.
An unquoted name folds the way the engine folds it. PostgreSQL and
YugabyteDB lower the ASCII letters of an unquoted name, so CREATE TABLE Docs (Id integer) declares the table docs with the column id, and a later
ALTER TABLE DOCS or CREATE POLICY p ON docs names that table. CockroachDB
lowers every letter of an unquoted name, past ASCII too. A quoted name keeps
its case on all three. The rule covers every name the file writes: tables,
columns, indexes, constraints, policies, roles, and the object a statement such
as GRANT or CREATE INDEX names. Other dialects, and a read with no dialect,
keep a name as written.
An ALTER TABLE reaches the table and column its dialect’s server would
find for the names it writes. "Docs" and docs are two tables on PostgreSQL,
and a file may declare both: each keeps its own columns, and ALTER TABLE docs
changes docs. In a file that declares only "Docs", the same statement is
refused, as PostgreSQL refuses it. ClickHouse compares names exactly too.
SQLite ignores the case of ASCII letters, quoted or not, so ALTER TABLE docs
reaches Docs there, and ärger does not reach Ärger. SQL Server, under its
default case-insensitive collation, ignores case past ASCII as well. MySQL and
MariaDB compare column names without case. A MySQL table name follows the
server’s lower_case_table_names, which a schema file does not carry, so a
spelling reaches a table that matches it exactly, or else the one table that
differs from it only in case. A read with no dialect compares exactly.
Role names differ on CockroachDB, which lowers every role name, quoted or not:
read with --dialect cockroachdb, "App_Reader" names the role app_reader,
and "PUBLIC" is the PUBLIC keyword.
Omitting --dialect keeps a permissive reader. No dialect means no
dialect’s rules, which is what lets one file mixing conventions be read at all.
Name the dialect when the file belongs to one engine.
Use it
Section titled “Use it”Everything a desired schema is for is the same for every source and lives on
Work with a desired schema. For SQL the flag is
--schema-file. What follows is specific to this source.
Split a schema across files
Section titled “Split a schema across files”A SQL schema file can pull in other SQL files with an atlas:import comment,
one per line:
-- schema/main.sql-- atlas:import ./tables/orders.sql-- atlas:import ./tables/users.sqlPoint --schema-file at the entry point and the declarations merge in the order
the file lists them. The entry point may declare objects of its own, and an
imported file may import in turn. ptah-compat schema inspect writes this
layout when its output goes through split, so an export reads back as the
schema it was taken from.
Each path is relative to the file that writes it and must stay inside the entry
point’s directory. An absolute path, a path that climbs out with .., a missing
file, one that is not .sql, and a cycle are each refused by name.
Diff two SQL files locally
Section titled “Diff two SQL files locally”ptah schema diff compares local SQL files directly. With old.sql
describing the deployed shape and schema.sql adding a pets table, a dev
database replays both sides:
ptah schema diff \ --from old.sql \ --to schema.sql \ --dev-url "sqlite://dev?mode=memory"Expected output includes:
CREATE TABLE "pets" ( "id" INTEGER PRIMARY KEY, "name" TEXT NOT NULL, "user_id" INTEGER NOT NULL CONSTRAINT "fk_pets_user_id" REFERENCES "users" ("id"));Failure modes
Section titled “Failure modes”-
A change that a SQLite dev database cannot express as an in-place
ALTERis refused loudly rather than turned into an incomplete diff. For example, adding aNOT NULLcolumn to an existing table exits withsqlite: adding column email to table users requires a table rebuild plan. -
Unsupported DDL constructs fail with a parse error naming the statement. Treat the error as a compatibility gap and check the conformance reports.
-
A table element ends at a comma or at the closing parenthesis. A clause the reader does not take after a column or constraint is refused by its first word rather than read as another column:
CREATE TABLE t (a integer, CHECK (a > 0) NO INHERIT);unexpected NO after a table element at position 41: expected ',' or ')' -
A constraint name on
DEFAULTis refused. Ptah keeps a name onNOT NULL,CHECK,REFERENCES,UNIQUEandPRIMARY KEYwhere the dialect’s grammar takes one; the last two are read as the table constraint they describe, which is the level a name lives at. A default has no such level and no engine Ptah supports records one:CREATE TABLE t (b INTEGER CONSTRAINT c_x DEFAULT 1);named column constraint "c_x" at position 41: Ptah has nowhere to keep a nameon DEFAULT, and does not read one back from a database, so write theconstraint without a name; a name is kept on NOT NULL, CHECK, REFERENCES,UNIQUE and PRIMARY KEYWrite
b INTEGER DEFAULT 1instead. A name Ptah accepts and cannot read back would make every later comparison report a difference no apply can settle. -
An index name between
FOREIGN KEYand its column list is read under--dialect mysqland--dialect mariadb, and refused elsewhere. On MySQL it names the index the server builds for the key, which is not built where another index covers the key and gives way to a later index that begins with its columns. On MariaDB it is the key’s own name, and the key’s index takes it. No other engine has the syntax:CREATE TABLE child (a INT, FOREIGN KEY zidx (a) REFERENCES parents (id));an index name after FOREIGN KEY at position 39 is the MySQL family's alone;postgres has no such syntax, so write the key as FOREIGN KEY (columns) anddeclare the index "zidx" separatelyA name written beside an explicit
CONSTRAINTsymbol is accepted and ignored, because both engines record the symbol for the backing index too. -
CONSTRAINTwithout a name is read under--dialect mysqland--dialect mariadb, where the name is optional, and refused elsewhere. On those enginesCONSTRAINT FOREIGN KEY (a) REFERENCES parents (id)is the same unnamed key asFOREIGN KEY (a) REFERENCES parents (id), and it takes the same name. Every other dialect requires the name:CREATE TABLE child (a INT, CONSTRAINT FOREIGN KEY (a) REFERENCES parents (id));CONSTRAINT at position 27 is followed by FOREIGN, not by a name: postgresrequires a name after CONSTRAINT, and only MySQL and MariaDB accept thekeyword without one; name the constraint, or drop the CONSTRAINT keywordThe MySQL family accepts the form at table level before
PRIMARY KEY,UNIQUE,FOREIGN KEYandCHECK. On a column, MySQL accepts it only beforeCHECKand MariaDB only beforeREFERENCES. The engine answersERROR 1064to every other column spelling, and Ptah refuses it too. ACONSTRAINTin front ofKEY,INDEX,FULLTEXTorSPATIALis refused on every dialect, with a name or without one, because an index takes noCONSTRAINTkeyword. -
A named column constraint is read under
--dialect mysqlonly beforeCHECK, and under--dialect mariadbonly beforeREFERENCES. On a column, each engine takesCONSTRAINTbefore that one kind alone, with a name or without one. MySQL 8.4, MySQL 26.7 and MariaDB 11.8 answerERROR 1064to every other kind, and Ptah refuses it too:CREATE TABLE t (a INT CONSTRAINT uq UNIQUE);CONSTRAINT uq at position 22 is followed by UNIQUE: on a column, mysqlaccepts CONSTRAINT with a name only before CHECK, and answers ERROR 1064(42000) to this; drop CONSTRAINT uqDrop the name, or declare the constraint at table level to keep it, as in
CONSTRAINT uq UNIQUE (a). PostgreSQL takes a name before every column constraint, so--dialect postgresreads a namedUNIQUE,PRIMARY KEY,CHECK,REFERENCESandNOT NULLon a column. -
A column-level
REFERENCESclause is refused under--dialect mysql. What MySQL builds from it depends on the server version: MySQL 8.4 accepts the syntax and builds nothing, while MySQL 9.7 and 26.7 build a foreign key and its index. Ptah reads a SQL file with its dialect alone: neither--server-versionnor the version of the database it connects to reaches the reader, so either reading would be wrong on one of those lines:CREATE TABLE child (a INT REFERENCES parents (id));a column-level REFERENCES clause at position 26: MySQL 8.4 builds nothing fromthe clause, while MySQL 9.7 and 26.7 build a foreign key and its index, andthe SQL file is read without the server version, so Ptah refuses the clauserather than guess which schema it declares; write a table-level FOREIGN KEYclause, which every MySQL line buildsWrite the relationship as a table-level
FOREIGN KEY (a) REFERENCES parents (id)instead. MariaDB enforces the column-level spelling and builds a backing index for it, so--dialect mariadbreads it unchanged. -
A
REFERENCESclause in the column definition ofALTER TABLE ... MODIFYis refused under--dialect mysqland--dialect mariadb, withCONSTRAINTin front or without it. MySQL 8.4, 9.7 and 26.7 and MariaDB 10.11, 11.8 and 12.3 each answerERROR 1064to it, whileADD COLUMNtakes the same clause. MariaDB takes noCONSTRAINTat all in aMODIFYcolumn definition, so Ptah refuses aCONSTRAINT ... CHECKthere too:ALTER TABLE c MODIFY a INT REFERENCES p (id);REFERENCES at position 27 in ALTER TABLE ... MODIFY: mariadb takes noREFERENCES clause in a MODIFY column definition, and answers ERROR 1064(42000) to one; add the key with ALTER TABLE ... ADD FOREIGN KEYAdd the key with
ALTER TABLE c ADD FOREIGN KEY (a) REFERENCES p (id). -
ALTER TABLE ... ADD KEYadds a secondary index on MySQL and MariaDB, in every spelling the engines take:ADD KEY,ADD INDEX,ADD SPATIAL KEYandADD FULLTEXT KEY, with a key part’s prefix length and direction. AUNIQUEkey stays a constraint, because it is a uniqueness guarantee rather than an index alone. On ClickHouse,ADD INDEXdeclares a data-skipping index, as the same keyword does inside a table body. -
ALTER TABLE ... ADD PRIMARY KEYis read onto the table it names, with its prefix length and direction, exactly as the same key written inside theCREATE TABLEwould be. A statement naming a table the document does not declare is refused rather than dropped:ALTER TABLE nosuch ADD PRIMARY KEY (a);the schema model has no place for this statement: ALTER TABLE nosuch ADDPRIMARY KEY names a table this schema does not declareA primary key has nowhere to live without its table, and the document is not one any engine would run either. Declare the table first, in the same file or in an earlier one, or drop the statement. Every other
ALTER TABLEoperation is refused the same way. -
A routine whose body Ptah did not parse is refused rather than dropped. The parser understands the outer boundary of every
CREATE PROCEDUREandCREATE FUNCTIONit accepts; where it cannot model the body, it keeps the text — and text nothing read cannot be compared, so it has no place in a desired schema:CREATE PROCEDURE bump() SET @counter = @counter + 1;the schema model has no place for this statement: a mysql procedure whosebody was kept as text rather than parsed, so nothing here can compare it:CREATE PROCEDURE bump() SET @counter = @counter + 1Write the body in a form Ptah reads — a
BEGIN ... ENDblock, or aRETURN— or keep the routine out of the desired schema and manage it separately. Carried silently, the routine would be missing from the desired schema: a comparison against a database that has it reports no difference, and a migration against one that does not plans it out of existence. -
A constraint name on
NOT NULLis carried where the target persists it. The distinction is not whether the syntax parses: PostgreSQL 17 acceptsCONSTRAINT c_x NOT NULLand stores nothing, while PostgreSQL 18 records one row perNOT NULLinpg_constraintwithcontype = 'n', keyed to the column throughconkey, and can drop, add and rename it by name. MySQL and MariaDB answerERROR 1064 (42000)for the syntax outright, so--dialect mysqland--dialect mariadbrefuse it when the file is read. Elsewhere the name is gated on the target’s measured capability, and a target that cannot keep it refuses the declaration rather than silently dropping the name.
Next steps
Section titled “Next steps”- Combining SQL files with Go packages or other sources? Composite desired schema.
- Planning versioned migrations from this file? Generate migrations.
- Using Atlas-style commands end to end? Atlas compatibility overview.