pgschema.toml, keep their rows in CSV files loaded by \copy directives, plan diffs them against the target database, and apply runs the resulting INSERT, UPDATE, and DELETE statements after the DDL, in the same transaction as the schema steps that precede them.
This is for config data: small tables that the application reads and the deployment owns. For backfills and other one-off changes to user data, see Custom Migration Steps.
Workflow
1
List the tables
Create Listed tables must have a primary key that does not include generated columns. Rows are matched by it, so use natural, stable keys (
pgschema.toml in your project directory and list the config tables. Patterns use the same glob rules as .pgschemaignore:'US', 'enterprise') rather than serial values that differ between environments.2
Dump
Run Each listed table gets An unquoted empty field, like the
dump to export the current rows. --file is required because the CSV files are written next to the schema:data/<table>.csv, sorted by primary key, and a \copy directive at the end of the main file after every other object, parent tables first. Data and schema stay in separate files:main.sql:data/plan_tier.csv:seat_limit of the enterprise tier, is NULL. A quoted empty field "" is an empty string.3
Edit
Change the CSV files. They are ordinary spreadsheets: open one in any spreadsheet application, edit the cells, and save as CSV. Keep the header line so the column order matches the directive.To declare that a table must be empty, leave only the header line.
4
Plan and apply
table.data, with the primary key in the path, so it can be inspected or edited like any other step before apply:INSERT rolls back the ALTER TABLE that preceded it. Steps that cannot run in a transaction, such as CREATE INDEX CONCURRENTLY or an online VALIDATE CONSTRAINT, commit on their own before the data steps and are not rolled back:pgschema.toml and delete its directive and CSV.
Writing Directives by Hand
You do not have to start from a dump. A directive you write yourself works the same way, as long as the table is listed:How Rows Are Compared
pgschema does not parse the values in your CSV files. It streams them into the temporary plan database with PostgreSQL’s ownCOPY, along with the rest of the schema, then reads the rows back from both databases with the same query. This means:
- Any input format or option PostgreSQL’s
COPYaccepts is valid in the directive. - Column defaults, triggers, and generated columns take effect before comparison. A column you leave out of the column list compares as its default, not as missing.
- Values are compared in PostgreSQL’s canonical text form for the column type.
1.50and1.5in anumeric(10,2)column are equal;2024-01-01T00:00:00Zand2024-01-01 00:00:00+00in atimestamptzcolumn are equal. - The comparison covers the columns of the desired table. A column added in the same plan is set on every existing row after the
ADD COLUMNstep; a column dropped in the same plan is ignored. NULLand an empty string are different values.
Ordering
Data steps run after the schema steps that create or alter tables and constraints, so new columns and new tables exist before rows are written to them. Within the data steps:DELETEstatements for rows whose secondary unique value (aUNIQUEconstraint or unique index other than the primary key) is reused by an inserted or updated row run first, child tables before parent tables, so the insert does not collideINSERTstatements run next, parent tables before child tablesUPDATEstatements run next, parent tables before child tables, so a child re-pointed at a new parent row finds it- All other
DELETEstatements run last, child tables before parent tables, after the updates that moved any remaining references away
DELETE plus an INSERT. A referenced row can be re-keyed in one plan as long as the managed rows that reference it are updated in the same plan, and a row can be re-keyed while keeping its unique values. A row that is both referenced by child rows and keeps a unique value cannot be re-keyed in one plan; edit the plan by hand for that case. Two surviving rows that swap a unique value, and collisions under an expression index such as UNIQUE (lower(code)), are not detected either and also need a hand-edited plan.
If a migration changes the primary key columns of a managed table, current rows are matched by the new key whenever its columns already exist with distinct values, which is the usual case of promoting a natural key. Only when a key column is new does the plan clear the table with one DELETE FROM and insert every row again. That full reload is blocked by any foreign key from an unmanaged table; resolve it by editing the plan.
Foreign keys between managed tables are satisfied by this order. A DELETE blocked by a foreign key from an unmanaged table fails the transaction, which is the intended outcome: resolve it by editing the plan, as described in Custom Migration Steps.
Guardrails
- Explicit opt-in. Only a
[data]entry inpgschema.tomlmakes a table managed. Rows already in the database never do. - Config and schema must agree. A listed table without a
\copydirective failsplanandapplywith a message pointing atdump, so you cannot plan a table you have not exported yet. A directive for a table that is not listed is an error too. See Config. - Deletes are visible. Every removed row is a separate
DELETEin the plan, listed by primary key, and apply asks for approval unless--auto-approveis set. - Ignored tables. A table cannot be both listed in
pgschema.tomland matched by.pgschemaignore.
Limitations
INSERTdoes not make a table managed.INSERTstatements in schema files run while building the desired state. Their rows count for listed tables and are discarded for everything else, as before.- Primary key required. Tables without a primary key cannot be managed. Unique constraints are not used for matching.
- Sequences are not advanced. Inserting explicit values into a
serialor identity column does not move the sequence. If the application also inserts into a managed table, prefer natural keys or reset the sequence yourself. - Not fingerprinted. The
source_fingerprintthat guards against concurrent changes covers the schema only. A row changed in the database betweenplanandapplyis overwritten by the plan or shows up in the next plan. - Data runs after DDL. Row changes are applied once every table, column, and constraint of the plan exists. A new constraint that current rows violate until the CSV is applied, such as a
CHECKon a column the CSV fills in, fails to validate; add the constraint in a later plan. - Primary key type changes are matched on the current text form of the key. Changing a key column’s type in a way that also changes its text form, such as
text'01'tointeger1, is planned as a delete plus an insert and fails on the duplicate key; migrate the values first, then the type. - Partitioned tables are managed through the parent table. Individual partitions cannot carry their own directive.
- Large tables are out of scope by design. The feature is tuned for tables with hundreds or a few thousand rows, where one statement per row keeps the plan reviewable.

