Changelog¶
[1.89.0] - 2026-10-20¶
-
Add
-- pista:backfill <value>. It sets the value of the existing rows when aNOT NULLcolumn is added, soADD COLUMNdoes not fail on a table with rows. -
-- pista:retype-usingnow fails the parse when its argument is not one expression, and drops a trailing comment. The plan writes the expression in pistachio's form, such ascode::intforcode::integer.
[1.88.1] - 2026-10-19¶
-
dumpno longer writes a serial column widened tobigintasbigserialwhile its sequence is stillinteger. It now writes the sequence and thenextval()default. -
dump --omit-schemanow drops the schema from an index on a table, or in a schema, named after a keyword such asposition.--schema-mapnow also maps a schema named after such a keyword. -
An inline directive followed by a
/* ... */comment before its column or constraint now applies to that element. Before, the directive was ignored. -
A trigger whose
REFERENCINGclause namesNEW TABLEbeforeOLD TABLEno longer plans aCREATE OR REPLACE TRIGGERon every run.
[1.88.0] - 2026-10-18¶
- Add
-- pista:refuse-hazard. Written beforeCREATE TABLE, it does what--refuse-hazarddoes, for that table and its partitions orINHERITSchildren only.
[1.87.0] - 2026-10-17¶
- Add
--refuse-hazardtoplanandapply. It refuses a plan that contains a statement of the given kind:rewrite,scan,blocks-readsorblocks-writes. The kinds are those of--explain. A refused plan exits with 3.
[1.86.0] - 2026-10-16¶
-
Add
-- pista:not-null-via-checkand--not-null-via-check. They addNOT NULLto an existing column through a temporaryCHECKconstraint, so the table scan does not block reads and writes.apply --with-txrefuses such a plan. Plan files written by earlier versions are refused. -
plannow drops a removed CHECK constraint afterSET NOT NULLon the same table. Before, the CHECK was dropped first, so a validatedCHECK (col IS NOT NULL)could not keepSET NOT NULLfrom reading every row.--explainno longer shows a scan for such aSET NOT NULL.
[1.85.1] - 2026-10-15¶
-
diffwithout--gitnow exits with 80, the usage error status, when it is not given exactly two files. Before, it exited with 1. -
apply-fromnow prints the invalid index warning thatplanandapplyprint.
[1.85.0] - 2026-10-14¶
-
BREAKING:
plan,apply,diffand the other commands that read desired schema files now reject a file whose last statement has no semicolon. Before, such a file was read when it was the last file, and when another file followed it, the statement ran into the first statement of that file and the error pointed at the next file. -
planis faster. At 1,000 tables it takes 10 to 25% less time than 1.84.0. The inline directive checks skip a statement with no-- pista:directive, and the line numbers recorded for lint are no longer counted from the start of the file for every object. -
dumpandplannow quote a routine parameter orRETURNS TABLEcolumn named after a keyword such astime,positionorvalues. Before, they wrote the name bare, which is a syntax error there, so the routine could not be created and adumpof it could not be read back.
[1.84.0] - 2026-10-14¶
-
applyandapply-fromtake--lock-timeoutand--statement-timeout. They setlock_timeoutandstatement_timeoutbefore the pre-SQL. -
applyandapply-fromtake--lock-retries. It runs a schema change again when the change fails becauselock_timeoutran out. Only the failed statement runs again. The pre-SQL,-- pista:executestatements andCONCURRENTLYindex statements do not run again.--lock-retry-delaysets the wait before each retry. The default is 1 second.--lock-retriescannot be used with--with-txor--try-tx.
[1.83.1] - 2026-10-12¶
planandapplynow reject a desired table thatPARTITION OForINHERITSmakes its own ancestor, such asCREATE TABLE a PARTITION OF a .... Before,plannever finished.
[1.83.0] - 2026-10-11¶
- BREAKING: Remove
--dsql-ignore-asyncandPISTA_DSQL_IGNORE_ASYNC. With--engine dsql,planandapplynow always ignoreASYNCinCREATE INDEX ASYNCin the desired schema.
[1.82.1] - 2026-10-11¶
-
dumpnow writes the sequence of aserialcolumn with its options when they are not the defaults. Before, it wrote the column asserial, so the restored sequence lost options such asINCREMENT BYandSTART WITH. -
A domain is now created after the routines it may call. Before, a domain whose
CHECKcalled a routine created in the same run was created first and failed, as did applying adumpof it to an empty database with--manage-routine. -
plannow drops a removed or changed constraint before it changes the type of a column. PostgreSQL rebuilds theCHECKconstraints on a column whose type changes, so aCHECKthe new type rejects failed the change, as when atextcolumn with aCHECKon its values became an enum. -
DEFAULT NULLon a column or a domain, with or without a cast, no longer appears in the plan on every run where PostgreSQL stores no default for it.
[1.82.0] - 2026-10-11¶
-
BREAKING: Rename
--dsql-no-wait-index-buildto--dsql-no-wait-job, andPISTA_DSQL_NO_WAIT_INDEX_BUILDtoPISTA_DSQL_NO_WAIT_JOB. It now also skips the wait for a constraint validation. -
With
--engine dsql,planno longer rejectsDROP COLUMNor adding a check constraint. A check constraint or a foreign key is addedNOT VALID, then validated withALTER TABLE ASYNC ... VALIDATE CONSTRAINT, andapplywaits for the job.
[1.81.0] - 2026-10-11¶
-
Amazon Aurora DSQL support:
plan,apply,apply-fromanddumptake--engine dsqlfor Amazon Aurora DSQL. See the Aurora DSQL guide. -
pista fmt --dsql-strip-asyncremovesASYNCfromCREATE INDEX ASYNC.planandapplytake--dsql-ignore-asyncto ignore it in the desired schema under--engine dsql. -
planandapplywarn about each invalid index, such as one left by a failedCREATE INDEX CONCURRENTLY.
[1.80.1] - 2026-10-09¶
- A lint rule can have
let, a list of named values. Each item hasnameandexpr, a CEL expression.assertand later items can use the names. The items are evaluated in order whenwhenis true. The standard rulesprefer-bigint-key,fk-needs-indexandredundant-indexinrules/now use it.
[1.80.0] - 2026-10-09¶
-
The documentation site no longer publishes the JSON Schema
schema-1.3.json. Useschema-1.4.json. -
parseanddump --jsonwritekeysandwherefor an index.keyslists the key elements as SQL, with their operator classes, collations and sort order.whereis the condition of theWHEREclause, ornull. The JSON Schema is nowschema-1.4.json. -
The standard rules
duplicate-indexandredundant-indexinrules/now readkeysandwhereinstead of the index definition.duplicate-indexnow also reports two unique indexes that differ only inNULLS NOT DISTINCT.
[1.79.1] - 2026-10-09¶
plannow finds a change to the table prefix of a column in a view over a join. Before, pistachio removed every prefix before it compared two views. So changingu.idtoo.idin a join, oro.user_id = u.idtoo.user_id = o.idin a sub-query, planned no change. This applies to materialized views too. A view over a join that leaves a column without its prefix is now replaced on every run. Write the prefix, asdumpdoes.
[1.79.0] - 2026-10-09¶
planandapplytake--evaluate-literals. It compares a string literal with a cast by value, not by text, in theDEFAULTof a column or domain, a generated column and aCHECKconstraint. For example,DEFAULT '1 hour'on anintervalcolumn no longer plansSET DEFAULTon every run. The literals whose text differs are sent to the server in one query. The option is off by default.
[1.78.1] - 2026-10-07¶
- A lint rule can have
when, a CEL expression that limits the rule to some objects. The rule checks an object only ifwhenis true.
[1.78.0] - 2026-10-07¶
-
The documentation site no longer publishes the JSON Schemas
schema-1.0.json,schema-1.1.jsonandschema-1.2.json. Useschema-1.3.json. -
parseanddump --jsonwrite new fields that repeat part of another field in a form that needs no SQL parsing. A column hasbase_typeandis_array. An index hascolumns,include,unique,methodandpartial. A foreign key hasref_columns,on_delete,on_updateandmatch. An index, a constraint and a foreign key haveauto_named, which istruewhen the file declares the object without a name. The JSON Schema is nowschema-1.3.json. -
The new command
pista lintchecks the tables, columns, indexes and foreign keys in schema files against rules written in CEL.--rulesnames rule files or directories of them. A rule reads the objects asparsewrites them. A rule can carry adescriptionthat says what it is for. Each object that breaks a rule is printed with its file, line and column, and the exit status is 2. pistachio has no built-in rules. The repository has standard rules inrules/to copy. -
The new directive
-- pista:lint-ignore [<rule>...] [-- <reason>]turns lint rules off for the table, column, index or foreign key it is written before. With no rule name, it turns every rule off.pista lintwarns about a name that no rule has.
[1.77.0] - 2026-10-04¶
-
pistachio now reads the column names in
CREATE VIEW v (x, y) AS ..., and inCREATE MATERIALIZED VIEW. Before, it ignored them, so the view got the column names of its query and appeared in the plan on every run. -
A call that spreads its arguments over a
VARIADICparameter, such asjsonb_extract_path_text(d, 'a', 'b'), no longer appears in the plan on every run. PostgreSQL stores it asVARIADIC ARRAY['a', 'b']. Before, an index, a constraint, a default, a policy, a trigger or a view that used one was planned again on every run, and a generated column that used one failed the plan. -
A
CASEwritten withoutELSEno longer appears in the plan on every run. PostgreSQL stores it with anELSE NULLcast to the result type. Before, an index, a constraint, a default, a policy or a view that used one was planned again on every run, and a generated column that used one failed the plan. -
The new directive
-- pista:retype-using <expression>gives theUSINGexpression for a column type change. If the column has a default, pistachio drops it before the change and sets it again after. Before, a type change with no implicit or assignment cast, such astextto an enum, failed at apply.
[1.76.0] - 2026-10-04¶
-
fmt --strip-renamed-fromremoves every-- pista:renamed-fromdirective. -
With
--manage-routine, a routine in a schema whose name needs quoting, such as"App", no longer plans aCREATE OR REPLACEand aDROPon every run when the file qualifies a type in that schema. -
dump --splitnow expands a leading~, as the other path options do. -
plan --explainnow writesmay rewritefor a column type change to a type that the same plan creates. Before, the change got no comment. -
plan --explainnow resolves a type in a schema whose name needs quoting, such as"App". Before, a change to such a type got no comment, and a column added with such a constrained domain was not reported as a rewrite.
[1.75.0] - 2026-10-04¶
-
On PostgreSQL 18,
dumpnow keeps a NOT NULL constraint name after a table or column rename. Before, it dropped every name ending in_not_null, so the restored constraint got a different name. -
BREAKING: Sequences owned by a column are now managed. Before, pistachio ignored them. With
--allow-drop sequence, an owned sequence that is not in the desired schema is now dropped, unless the column uses it as its default. Dumps from earlier versions do not include such sequences. -
planandapplynow emitALTER SEQUENCE ... OWNED BYorOWNED BY NONEwhen the owner of a sequence changes. -
dumpnow writes an owned sequence with a custom name, with its options andOWNED BY. Before, it wrote the column asserial, so the restored sequence got the default name and options. -
dumpnow keeps thenextvaldefault of a column that is notsmallint,integerorbigint. -
A desired sequence with no owner now detaches a sequence of the same name that a column owns. Before, the plan created it again and apply failed.
-
When a
serialcolumn becomes an identity column or gets another default, its old sequence is now dropped. This needs--allow-drop sequence.
[1.74.0] - 2026-10-04¶
-
BREAKING: A primary key written
USING INDEXnow needs aCREATE INDEXfor its index in the schema files. Without one, reading the files fails with an error. -
The columns of a primary key written
USING INDEXare now read as NOT NULL. Before, a column the file declared nullable got aDROP NOT NULLon every plan, and PostgreSQL rejected it. -
For a new table, a constraint written
USING INDEXis now added after the table's indexes are created. Before, it was written insideCREATE TABLE, which PostgreSQL rejects. -
Adding an index and a
USING INDEXconstraint on it to an existing table in one run now creates the index first. Before, the plan had noCREATE INDEX, so theADD CONSTRAINTfailed. -
dumpnow writes an index comment right after itsCREATE INDEX, rather than with the table's other comments at the end.
[1.73.2] - 2026-10-03¶
--explainnow shows the size of a table that a foreign key references without a schema name. Before, it showed only the table name.
[1.73.1] - 2026-10-01¶
LIKEinCREATE TABLEis now an error. Before, pistachio ignored it with a warning, so the plan dropped every column it would copy. A table with onlyLIKEwas created with no columns.
[1.73.0] - 2026-10-01¶
-
Update pg_query_go to 6.2.5.
-
planandapplynow keep the parentheses in(ARRAY[a, b])[1]. They wroteARRAY[a, b][1], which PostgreSQL rejects. This affected check constraints, defaults, generated columns, indexes, policies, triggers and views. -
Renaming a column to
valueno longer drops and re-creates the unique or primary key constraint on it. -
ADD CONSTRAINTnow quotes a key column named after a keyword such astime, as PostgreSQL does. -
--versionnow prints the version of pg_query_go on a second line.
[1.72.1] - 2026-09-30¶
-
A routine whose body ends in
$is now written in a form that parses. Before,dump,planandapplywrote its body as$$...$$$. -
planno longer fails withcannot change base type of domainfor a domain whose base type is in the same schema, when the schema name needs quoting and--search-pathincludes the schema. -
An index on a column declared
COLLATE "C"that is written as(name COLLATE "C")is no longer dropped and created on every plan.
[1.72.0] - 2026-09-30¶
-
Add
dump --omit-partition-child-index. It leaves out a partition's copy of its parent's index, which PostgreSQL creates again when the dump is loaded. A partition is then added to such a file withCREATE TABLE ... PARTITION OFalone. -
Adding a partition to a table that has an index no longer fails with
relation ... already existswhen the file also writes the partition's copy of the index, asdumpdoes.CREATE TABLE ... PARTITION OFcreates the copy, so the plan leaves out aCREATE INDEXthat has the name PostgreSQL gives the copy and the definition of the parent's index.
[1.71.0] - 2026-09-29¶
-
Comments on constraints, foreign keys, triggers, policies and domain constraints are now managed. Before,
COMMENT ON CONSTRAINT,COMMENT ON TRIGGERandCOMMENT ON POLICYin a schema file were ignored with a warning, anddumpdid not write them. -
The plan file version is now 5. Run
plan --outagain for a plan file an older pista wrote. -
The JSON Schema is now
schema-1.2.json. It addscommentto a constraint, a foreign key, a trigger, a policy and a domain constraint. -
A
USING INDEXconstraint that keeps the index's own name, or names none, no longer fails withduplicate relation name. -
The comment on a table, view or column is now read only from comments on relations. After OID wraparound, a comment on another kind of object that shared the OID could be read as the table's.
-
The error for a
--pre-sql-fileor--concurrently-pre-sql-filethat cannot be read, and for adump --splitfile that cannot be written, no longer names the file twice. -
The
-- Connected toline for a Unix-socket connection now includes the port, which picks the socket file. -
A materialized view written with
WITH NO DATAis now created with it.applydropped the clause and ran the view's query over its source tables. Whether a view is populated is not compared, anddumpdoes not write the clause. -
fmtno longer refuses a file with trailing whitespace after a--comment. The whitespace is removed. -
A policy whose role list names
PUBLICalongside other roles no longer plans anALTER POLICYon every run. PostgreSQL keeps onlyPUBLIC, and the desired side now does too. -
A
-- pista:directive written after code on the same line, as inCREATE TABLE a (...); -- pista:ignore, is now an error. It used to apply to the next statement instead of the one before it, and one after a column or an enum value was silently dropped. Move the directive to its own line above its statement. -
A
-- pista:directive with no statement after it in the same file is now an error. It used to apply to the first statement of the next schema file. -
dumpnow writesSET STORAGEandSET COMPRESSIONfor the columns anINHERITSchild declares, and a newINHERITSchild gets them when it is created. Before, the next plan showed them as drift. -
COMMENT ON TYPEon a domain is now read. Before, it was ignored and every plan dropped the comment withCOMMENT ON DOMAIN ... IS NULL.
[1.70.1] - 2026-09-28¶
- An index written with
COLLATE "default"on a column of the default collation is no longer dropped and created on every plan.
[1.70.0] - 2026-09-28¶
-
--manage-routinenow manages routines with aBEGIN ATOMICorRETURNbody. They are created after the tables and views they read, so aCHECK, default or index that calls one cannot be created in the same run. An existing one that the desired schema does not define now shows up as a drop. -
The plan file version is now 4. Run
plan --outagain for a plan file an older pista wrote. -
The JSON Schema is now
schema-1.1.json. It addssql_bodyto a routine.
[1.69.1] - 2026-09-27¶
planis faster. At 1,000 tables it takes about a quarter less time. The keyword lookup that decides whether an identifier needs quotes and the checks for whether a column default needs parentheses and which columns it references now run once per distinct name or expression, and a file with no-- pista:directive skips the directive checks.
[1.69.0] - 2026-09-27¶
-
A constraint named with a keyword such as
timeorvaluesnow plans correctly.planwroteADD CONSTRAINT time CONSTRAINT "time" CHECK ..., which PostgreSQL rejects. -
Renaming a column used in the
WHEREclause of an exclusion constraint no longer drops and re-adds the constraint. The rename is carried into the clause, as it already is for a partial index. -
The sizes
--explainprints now matchpg_size_pretty. A size just under a half unit could come out one unit too high, such as11 MBfor 11009536 bytes, whichpg_size_prettyshows as10 MB.
[1.68.0] - 2026-09-27¶
-
--schema-mapnow maps the indexes of a materialized view.dumpwrote an index and its comment under the unmapped schema, so planning the dump failed, andplancreated the index in the mapped schema. -
--schema-mapno longer rewrites a string literal in an index, constraint, policy, trigger or view definition. It still rewrites one that names an object, such as the argument ofnextvalor a literal cast toregclass, as it does in a default. A table alias named like a mapped schema, as inSELECT staging.id FROM staging.users staging, is no longer rewritten either. -
plannow fails when an index on a partitioned table would be created or droppedCONCURRENTLY, through--force-index-concurrentlyor-- pista:concurrently. PostgreSQL rejects both, soapplyused to stop at that statement. -
forcein a config file is now an error. It turned off the drift check of everyapply-fromthat read the file. Pass--forceon the command line. -
A foreign key written without the referenced columns, such as
REFERENCES users, no longer plans a drop and an add on every run. The columns are taken from the referenced table's primary key in the desired schema, so a key to a table the desired schema does not declare still does. -
A domain constraint can be kept
NOT VALID. Write it asALTER DOMAIN ... ADD CONSTRAINT ... NOT VALIDafterCREATE DOMAIN.dumpwrites it the same way. Before,dumpwrote it insideCREATE DOMAIN, and planning the dump validated the constraint.
[1.67.1] - 2026-09-27¶
-
Internal refactoring in the CLI commands, the object filters and the trigger diff, with no change in behavior.
-
More test cases for the parser and the diff.
[1.67.0] - 2026-09-27¶
-
A directive with a space after
-- pista:, such as-- pista: ignore, is now an error. It used to be ignored silently. -
The error for a
CREATE POLICYorCREATE TRIGGERon a table that is not declared before it now shows the file and line, asCREATE INDEXdoes. -
The ignored-statement warning and the per-file errors of
fmtstart withpista:instead ofpistachio:, the same program name as the other errors. -
A
-- pista:renamed-frombefore a column or constraint written with a leading comma, such as, name text, now renames it. It used to be ignored, and the plan added a new column or constraint. -
Internal refactoring in the parser, catalog, diff and dump code, with no change in behavior.
[1.66.1] - 2026-09-26¶
fmtstarts a statement at the first column instead of keeping its indentation.
[1.66.0] - 2026-09-26¶
-
Creating a partitioned table, its partitions and their indexes in one run no longer fails with
relation ... already exists. This is whatapplyof adumpdoes on an empty database. The index on the partitioned table is now created after the partitions' indexes and attaches them. -
An index added to or changed on an existing partitioned table no longer leaves each partition with two copies when the file also declares the partition's copy.
-
dumpwrites an index on a partitioned table withoutONLY. WithONLY, the index was left invalid. -
The plan file version is now 3.
apply-fromrefuses a file written by an earlier version, so runplan --outagain. -
planfails, naming the dependents, when it would change the type of a column that a view, rule, trigger, policy,BEGIN ATOMICroutine or generated column depends on. PostgreSQL refuses the change, soapplyfailed partway. -
planfails, naming the dependents, when it would drop or change a key or an index that a foreign key or a view depends on. PostgreSQL refuses the drop, soapplyfailed partway. -
With
--manage-routine,planfails when a routine it has to drop and recreate is used by a constraint, default, index, view, policy or trigger, and names them. PostgreSQL refuses that drop, soapplyused to fail partway.
[1.65.0] - 2026-09-25¶
- Add
--explaintodiff. It writes the commentplan --explaindoes, without sizes, and marks a type change or a default that calls a functionmay rewrite.
[1.64.0] - 2026-09-24¶
-
A view that selects a bare string or
NULLconstant, such asSELECT id, 'i' AS src FROM items, no longer plansCREATE OR REPLACE VIEWon every run. The catalog prints the constant as'i'::text. -
Renaming a table or view with
-- pista:renamed-fromno longer re-creates its unchanged triggers. PostgreSQL moves the triggers with the rename. -
A view or materialized view over
UNION,INTERSECTorEXCEPTno longer plans a replacement on every run. The output names PostgreSQL writes onto the laterSELECTs are ignored in the comparison. -
-- pista:renamed-fromon an index of a materialized view now plansALTER INDEX ... RENAME TO. The directive was ignored, so the index was dropped and created again. -
Renaming a materialized view with
-- pista:renamed-fromno longer drops and re-creates its indexes. -
With
--manage-routine, a routine parameter default written without a cast, such asa text DEFAULT 'x', no longer plansCREATE OR REPLACE FUNCTIONon every run. The catalog reads it back as'x'::text, which was compared as text. -
Renaming an enum, domain, composite type or sequence with
-- pista:renamed-fromno longer retypes the columns that use it or resets theirnextval()default. The extra statements failed the apply when a view read such a column, and a domain over a renamed type stopped the plan with a base type error. -
A
DEFERRABLEorINITIALLY DEFERREDwritten on a column constraint, such asREFERENCES users (id) DEFERRABLE INITIALLY DEFERRED, is now read. It was dropped, so the constraint was created not deferrable. A combination PostgreSQL rejects, such asNOT DEFERRABLE INITIALLY DEFERRED, is an error. -
A foreign key that references a table in another schema by its qualified name, such as
REFERENCES public.base (id)on a table inapp, no longer drops and re-adds itself on every plan. -
Error messages no longer repeat the program name, as in
pista: error: pistachio: .... The schema errors name--schemasinstead ofOptions.Schemas, and a failed connection readsfailed to connect to database.
[1.63.0] - 2026-09-24¶
-
A column or domain
DEFAULTwhose cast on an expression changes is now planned, such asnow()::datebecomingnow(), or::timestamp(0) with time zonebecoming::timestamp(0). The cast was ignored in the comparison. -
A column whose default draws from a sequence it owns no longer plans
SET DEFAULTon every run when the desired schema writes the default out. The column was read as a serial without its default. -
dump --jsonwrites thenextvaldefault of a serial column indefault, where it wrotenull. -
The plan file version is now 2.
apply-fromrefuses a file written by an earlier version, so runplan --outagain.
[1.62.0] - 2026-09-23¶
-
A new table's policy that reads a table or view created later in the same run no longer fails to apply. A new table's policies now run after every table and view.
-
A primary key or unique constraint that repeats another on the same table is no longer lost when the table is created.
CREATE TABLEdrops the repeat, so the plan anddumpnow add it after the table. -
Foreign keys no longer order tables in a plan, so two tables referencing each other no longer push the whole plan into a fixed order that created routines before tables.
-
A string literal spanning lines in a new table's comment, policy, trigger
WHENorNOT VALIDcheck no longer fails the apply. The statement was split at the line break. -
A column or domain
DEFAULTthat needs parentheses,(now() AT TIME ZONE 'utc')for one, keeps them in the plan. Without them the statement failed with a syntax error, and a trailingCOLLATEset the column's collation instead. -
Routines whose signature names a table, as
RETURNS SETOF <table>does, are created after that table even when several of them name different tables. The dependency sort failed on a cycle, and the plan created every routine before the tables.
[1.61.0] - 2026-09-23¶
- BREAKING: Remove
dump --sort-by-deps. It failed on mutual foreign keys, andpg_dump -swrites a filepsqlcan load. To load a dump into an empty database, useapply.
[1.60.1] - 2026-09-22¶
- A table named after the type one of its own columns is written with,
textamong them, no longer breaks the dependency sort, and neither does a domain named after its base type. The name resolved to the object itself, and that self-edge was read as a cycle:dump --sort-by-depsfailed withcycle detected, andplanfell back to ordering by category, which creates a domain before the domain it is built on. A name that matches the object it is written on now carries on along the search path, soCREATE DOMAIN app.rating AS ratingwritten wherepublic.ratingis the base type keeps that dependency.
[1.60.0] - 2026-09-22¶
-
plan --outwrites the plan to a file andapply-fromruns it.apply-fromreads no schema file and diffs nothing: it reads the database to check that the schema is still what the plan was computed against, and stops when it is not.--forceruns the plan anyway and reports the drift as a warning. The file records the options that decide what the plan read, soapply-fromreads the database the same way, and a-- pista:executecheck is evaluated once, when the file is written. -
apply --timingwrites each statement's elapsed time as a comment after it. Every statement it writes out is timed,BEGINandCOMMITincluded. The time is measured on the client, so it covers any wait for a lock.
[1.59.2] - 2026-09-20¶
CREATE UNLOGGED SEQUENCEis ordered by its dependencies, asCREATE SEQUENCEalready was. The plan placed a statement by its prefix and had no prefix for the unlogged spelling, so the statement ran before every other create. The plan now reads the parse tree, and the spelling no longer decides where a statement runs.
[1.59.1] - 2026-09-20¶
- A one-element
INlist compares equal to the comparison PostgreSQL stores it as.CHECK (carrier IN ('own_a'))is stored asCHECK ((carrier = 'own_a'::text)), so the constraint was dropped and added again on every plan, revalidating the whole table. A domain constraint, an index predicate, a policy, a view body and a triggerWHENdrifted the same way, and a generated column failed the run withcannot change GENERATED expression.NOT IN (a)is stored as<> aand folds too.
[1.59.0] - 2026-09-19¶
-
apply --exclusive-waitprints-- Waiting for another exclusive apply to finishwhile it waits. The line was buffered with the rest of the output and came out after the wait it announced. -
fmtwrites through a symlink to the file it points at. It replaced the link with a plain file and left that file unformatted. -
A trigger state naming a trigger, and a storage setting naming a column, that the table does not declare before it are errors, as is
CREATE INDEXon a table or materialized view not declared before it. Each was dropped in silence, so the index or the setting was missing from the desired schema. AnALTER SEQUENCEoption other thanOWNED BYwarns as an unreadALTER TABLEaction does; it was dropped in silence too. -
ALTER TABLEon a table, andALTER SEQUENCEon a sequence, that no statement before it declares is an error. Both were dropped in silence, so a typo in the table name, or a file that sorts before the one declaring the table, left the constraint out of the desired schema and the plan offered to drop it from the database.ALTER TABLEon a view, a materialized view or a sequence warns asALTER VIEWdoes. -
Warn about
CREATE TABLE ... AS, aLIKEclause inCREATE TABLE, andALTER INDEX/ALTER VIEW/ALTER MATERIALIZED VIEW. Each shares a statement type with one the parser reads, so none reached theignored unsupported statementwarning and was dropped in silence: a table created withASwas absent from the desired schema, and the columns aLIKEclause copies planned asDROP COLUMN. -
-- pista:renamed-fromwritten without a name is an error. It matched nothing and raised nothing, so the object was dropped and created instead of renamed. -
Trim the whitespace around each schema name in
-n,$PISTA_SCHEMASandschemasin the config file, and around each side of a-mmapping.-n 'public, billing'read the second entry asbilling, a schema of its own, so the real one was neither read nor written, and-m 'old=new; other=third'mappedother, which matched nothing.-mnow also refuses an empty side,-m 'x=', which left the mapped objects unqualified, and two sources that differ only in whitespace. -
exclusive-wait: 0in the config file is read as--exclusive-wait 0is. It failed withcannot unmarshal number into Go value of type string, since YAML reads a bare0as a number and the flag took only text. -
--schema-mapnow reaches a column's type, collation and default, a partition's parent, a constraint definition, a composite attribute's collation, and a domain's base type, collation, default and constraints.dump -m myschema=publicwrotes myschema.status,DEFAULT nextval('myschema.seq1'::regclass)andPARTITION OF myschema.parentunder apublictable, which does not load, andplanretyped such a column and re-set such a default on every run. The map also no longer rewrites a schema whose name ends in the mapped one: a foreign key tomystaging.refcame out asmypublic.refunder-m staging=public. -
Name an unnamed
UNIQUEorEXCLUDEconstraint the way PostgreSQL does when it holds an expression or anINCLUDElist:EXCLUDE (lower(a) WITH =)ist_lower_excl, nott_expr_excl, andUNIQUE (a) INCLUDE (b)ist_a_b_key, nott_a_key. Every plan added the constraint under the old name and offered to drop the server's. -
DROP NOT NULLnow runs after the table's constraint statements. Dropping a primary key and theNOT NULLon its column in one plan put theDROP NOT NULLfirst, which PostgreSQL refuses withcolumn is in a primary key. -
A constraint trigger whose definition changes is dropped and created again, under
--allow-drop trigger.planwroteCREATE OR REPLACE CONSTRAINT TRIGGER, which PostgreSQL does not have, soapplyfailed. -
serial2,serial4andserial8are read assmallserial,serialandbigserial, likeint2,int4andint8. A column written with one of them plannedSET DATA TYPE serial8, which PostgreSQL refuses, andDROP NOT NULLon every run. -
plannow fails when it would drop a view or materialized view that another object still reads, and names what reads it. PostgreSQL refuses thatDROPinstead of cascading, so the plan read as fine andapplyfailed on it. Views hit this without being dropped on purpose: a definition change becomes a drop and a create wheneverCREATE OR REPLACE VIEWcannot express it, and always for a materialized view. -
Order view drops and creates even when the whole-schema sort fails. Two tables with foreign keys to each other are a cycle that sort cannot resolve, and the fallback emitted view statements in map order, so a chain of views changing shape dropped the base before the view reading it and
applyfailed. Views are now sorted among themselves there, which they always can be: PostgreSQL rejects a view that reads a view reading it back.
[1.58.0] - 2026-09-17¶
- Add
--explaintodump($PISTA_DUMP_EXPLAIN). It writes the size of each table, materialized view and index in a comment, from the same estimatesplan --explainreads.
[1.57.0] - 2026-09-17¶
- BREAKING:
--manage-routineleaves out the constructor functions of a range type.dumpwrote them, andplanproposed dropping them, which PostgreSQL refuses. Delete them from a file an earlierdumpwrote, or every plan creates them.
[1.56.0] - 2026-09-17¶
- Compare an index element's operator class and collation, an exclusion constraint's elements too, the way PostgreSQL writes them: a name in the search_path without its schema, an option value quoted,
siglen='32'. A file that wrote either the other way re-created the index on every plan. - Manage a comment on a view or materialized view column.
dumpdid not write one, and aCOMMENT ON COLUMNnaming one was dropped without a warning. - An index on a partitioned table written without
ONLYis no longer re-created on every plan, and an index attached to it is not dropped on its own. ThatDROP INDEXalways failed.
[1.55.0] - 2026-09-16¶
- Add
--git(-g,$PISTA_GIT) todiff. It reads the files on the command line out of a git repository:A..Bcompares the two revisions,A...Bcompares B against its merge base with A, andAalone compares A against the working tree. Every file is read at both ends of the range, so a side can span more than one file. fmtanddumpkeep anARRAY[...]constructor on one line. The layout counted a parenthesis but not a square bracket, so a comma inside a constructor read as the end of a column and broke the line there.- The Homebrew cask writes the
postflight_stepsstanza instead ofpostflight, which Homebrew 7 deprecates.
[1.54.2] - 2026-09-13¶
diffno longer prints the-- Diff for schema ...header. There is no connection to name and the object counts of a file say little, so the output is the DDL alone.
[1.54.1] - 2026-09-13¶
diffno longer prints-- pista:executestatements. They are not schema state and their check SQL cannot be evaluated without a database;applyruns them as usual.
[1.54.0] - 2026-09-13¶
- Add
pista diff. It compares two schema SQL files and prints the DDL that takes the first to the second, with the same statements and orderingplanproduces, without reading a database.--checkexits with code 2 when the diff contains executable changes.
[1.53.0] - 2026-09-12¶
- BREAKING:
parseanddump --jsonwrite a table'scolumnsas an array rather than an object keyed by name. The order is the column order, which a JSON object does not promise to keep, and the name is already a field of the column. The published JSON Schema atschema-1.0.jsonis updated in place.
[1.52.0] - 2026-09-12¶
- BREAKING: The connection options (
-c/--conn-string,-d/--dbname,--password,-n/--schemas,-m/--schema-map,--search-path) belong to the command that opens a connection, so they are written after it rather than before:pista dump -n myschema, notpista -n myschema dump.fmtreads no database and no longer takes them at all, andparsetakes-nalone, which it uses to qualify unqualified names.-C/--config,--pagerand--versionstay where they were, and the environment variables and the config file are unchanged.
[1.51.0] - 2026-09-12¶
-
Add
--jsontodump($PISTA_DUMP_JSON). It writes JSON in the same shapepista parsewrites, read from the database rather than from files, so the published JSON Schema describes both. -
Write a relation's storage parameters as an empty object rather than null in that document when
--manage-storage-paramis off, the way unread routines are written. The SQL is unaffected. -
Add
pista parse. It parses schema SQL files with the same parserplanandapplyuse and prints the objects as JSON, without reading a database, so other tools can consume the schema. -
Publish the JSON Schema of that document at
https://winebarrel.github.io/pistachio/json/schema-1.0.json. It is reflected off the structs, so it follows what the command writes.
[1.50.0] - 2026-09-10¶
- Read a constraint added with
UNIQUE USING INDEXorPRIMARY KEY USING INDEX. The form never compared equal to the constraint it created, so every plan re-issued DROP and ADD, and the index it takes over was planned as a DROP, which ran first under--allow-drop indexand broke the ADD. The index now stays, the file plans clean, and a constraint written without a name takes the index's name, the way PostgreSQL names it.
[1.49.2] - 2026-09-10¶
- Date the size an
--explaincomment prints,~120,000,000 rows, 9629 MB, as of 2026-09-08. It is when a VACUUM or an ANALYZE last wrote the estimate, so a plan says how stale the numbers are. A size the server can no longer date is printed without it.
[1.49.1] - 2026-09-10¶
- Write the row estimate in an
--explaincomment with a comma between every three digits,~120,000,000 rowsrather than~120000000 rows.
[1.49.0] - 2026-09-10¶
- Add
--explaintoplan($PISTA_EXPLAIN). It writes a comment before each statement that scans or rewrites a table that already holds data: what the statement does to the rows, what its lock blocks, and the table's row and byte estimate frompg_class, with the number of indexes a rewrite builds again and the partitions orINHERITSchildren it recurses into. A foreign key names the referenced table next to the referencing one, and a domain change names every table with a column of the domain. A statement that changes the catalog alone takes no comment, and neither does one on a table the same plan creates. The estimates are what VACUUM and ANALYZE last wrote, the TOAST relation's pages included; a table neither has visited reads as not analyzed. Whether a type change is a relabel or a conversion, and whether a default calls a volatile function, is one catalog read each, made only when the plan holds such a statement, and a plan with no statements makes none.
[1.48.0] - 2026-09-09¶
-
Read a primary key added by
ALTER TABLEas implyingNOT NULL. PostgreSQL sets the flag whichever way the key arrives, and only the inline form was read that way. A file that adds the key in a later statement plannedDROP NOT NULLon every run, and applying it failed withcolumn is in a primary key. -
Read a
serialcolumn asNOT NULL. The type expands to aNOT NULLcolumn, and the catalog reports it as one, so a declaration that left the words off plannedDROP NOT NULLright after the table was created. Applying it took the constraint away. -
Read a
numericwritten with only a precision. PostgreSQL storesnumeric(5)asnumeric(5,0)andformat_typeprints both digits, so the declaration and the catalog never compared equal and every plan re-issued the sameALTER. -
Drop a partition that carries a copy of its parent's foreign key. The copy was dropped first, which PostgreSQL rejects with
cannot drop inherited constraint, and the copy goes with the partition anyway. Dropping the whole partitioned table failed the same way. -
Compare a domain's base type the way a column's type is compared.
format_typewrites a base type on the search path unqualified, so a domain written overpublic.statusread as a base type change, which cannot be altered, and every plan failed. -
BREAKING: Read only the collation a domain or a composite attribute sets itself. One typed over a collated domain inherits that collation without declaring it, and reading the inherited one as declared made the desired side, which writes none, look like a collation change. A domain's collation cannot be altered, so every plan failed.
dumpno longer writes the inherited clause either, which is whatpg_dumpdoes.
A file an earlier pista dump wrote restates the inherited clause, since the old reading wrote it on both sides. Such a file no longer plans: a domain fails with cannot change collation of domain, which stops the whole plan, and a composite attribute drifts on every run. Run pista dump again to rewrite the file; the new output plans clean. Only a domain or an attribute over a collated type is affected.
-
Read a
-- pista:directive on a file written with CRLF line endings. The carriage return was taken as trailing content, soignore,concurrently,bulk-alterandexecuteturned off silently: the name is still a known one, so nothing was reported. -
Read
CREATE INDEX IF NOT EXISTSas the index it describes. The clause was kept in the stored definition, whichpg_get_indexdefnever writes, so an index a schema file spelled that way was dropped and created again on every plan. -
Read an enum that has no labels.
CREATE TYPE ... AS ENUM ()has nopg_enumrow, and the catalog reached the labels through an inner join, so the type read as absent: every plan created it again and the second apply failed withtype already exists.dumpwrites it now as well. -
Fold a routine's
SETname to lower case. GUC names are case-insensitive andpg_get_functiondefwrites the spelling PostgreSQL keeps, so a routine created withSET timezoneread back asSET "TimeZone"and was replaced on every plan.DateStyleandIntervalStylebehaved the same way. -
Widen a
serialcolumn tobigserial. The change went out asSET DATA TYPE bigserial, which fails: PostgreSQL takes a serial pseudo-type only inCREATE TABLEandADD COLUMN. The base type goes out instead, withALTER SEQUENCE ... ASfor the sequence the column owns.bigserialis abigintcolumn and abigintsequence, and widening the column alone left the numbers stopping at 2147483647. A column that owns no sequence takes the one statement it always did. -
Test the
pistaentrypoint.mainnow hands the arguments, the output streams and the exit function to arunfunction, so the argument parsing, the pager hookup and the exit codes are covered bygo test, andcmd/pistais no longer left out of the coverage report. No change in behavior.
[1.47.0] - 2026-09-07¶
-
Add
pista fmt, which lays out schema SQL files in place. It works on the token stream, so only the whitespace moves: the keyword case, the identifier spelling and the line breaks are left as they were written, and no schema name is added. The definition list of aCREATE TABLEand aCREATE TYPEis written one element per line, a line inside parentheses is indented four spaces past the line that opened them, a routine's clauses take one level, and the space before a comma, just inside a parenthesis, and around a subscript or a cast is closed up. A quoted identifier loses its quotes when the statement parses to the same thing without them. A view body and a routine body are left alone. The result is checked to carry the same tokens as the input, and a file that fails the check or does not parse is not written.--checkreports the files that are not formatted and exits with code 2. -
Write
dumpthrough the same formatter, so its output is whatpista fmtwrites. No dump changes today, since the model already renders that layout;--no-formatturns the pass off.
[1.46.0] - 2026-09-06¶
-
Manage a foreign key on a partitioned table.
ALTER TABLE ONLYis rejected there, so adding a key to one failed withcannot use ONLY for foreign key on partitioned table; the word is left off now, and nothing inherits a foreign key anyway. A partition's copy of its parent's key was dropped and added on its own, which PostgreSQL rejects and the parent's statements settle regardless, so the copy now takes none.dumpwrote the copy too, which made its output fail to reload; it is left out, as pg_dump leaves it out. -
Change a foreign key's deferral clause in place.
DEFERRABLEandINITIALLY DEFERREDwere compared only as part of the definition string, so a toggle dropped the key and added it back, rescanning the table to validate it again. When nothing else about the key differs it now goes out asALTER TABLE ... ALTER CONSTRAINT, withVALIDATE CONSTRAINTnext to it when the key is also being validated. PostgreSQL takes the statement on a foreign key alone, so a unique, primary key or exclusion constraint is still dropped and added, and a partition's copy of its parent's key takes no statement at all. -
Manage a comment on an index.
COMMENT ON INDEXwas read by neither side, sodumpdropped the linepg_dumpwrites and a schema file that carried one was warned about and ignored. A change now goes out asCOMMENT ON INDEX ... IS ...orIS NULL, and a recreate writes the comment again, since PostgreSQL drops it with the index. A comment on a constraint, a trigger or a policy, and one on the index a constraint owns, stays unmanaged.
[1.45.0] - 2026-09-06¶
- Manage the storage parameters of a view and a materialized view, the
WITH (...)clause beforeAS. Neither side read them, sodumpdropped the clause and a schema file that wrote one was applied without it. A plain view holdssecurity_barrierandsecurity_invokerand nothing else. Both decide what the view means rather than how it is stored, so they are managed without a flag: a dump that dropped them restored a view that leaks rows, or one that reads the base table with the wrong privileges. A materialized view holds what a table holds, tuning included, so it sits behind--manage-storage-paramwith the table. A change to a view whose definition stays goes out asALTER [MATERIALIZED] VIEW ... SET (...)/RESET (...); a definition change carries the clause on its own statement, which replaces the view's options as a whole. Two things that used to plan nothing now plan something: a view carryingsecurity_barrierthe schema file does not name is reset, and, under the flag, a materialized view's parameters are diffed like a table's.
[1.44.1] - 2026-09-06¶
- Build the macOS release binaries against a deployment target of 13.0. The cross toolchain aimed at 10.9 on amd64 and 11.0 on arm64 while Go stamped its object with 13.0, so the release build warned on every link. Go binaries need macOS 13 either way.
[1.44.0] - 2026-09-06¶
- Manage a view's
WITH [LOCAL | CASCADED] CHECK OPTION. Neither side read it, sodumpdropped the clause and a reload lost the constraint, and a desired schema that wrote it planned nothing.dumpnow writes the clause after the query. A change on a view whose definition stays goes out asALTER VIEW ... SET (check_option=...)orRESET (check_option); a definition change carries the clause on itsCREATE OR REPLACE VIEW.WITH (check_option = ...)beforeASis read too.
[1.43.0] - 2026-09-05¶
- Manage an
INHERITSchild as the columns and constraints it declares itself.dumpwrote such a child as its constraints alone, the parent's included, and dropped every column, so the output did not even reload: the constraint named a column the statement no longer declared. Fed back as the desired schema it planned aDROP COLUMNfor every column and aDROP CONSTRAINTPostgreSQL rejects. Both sides now read the local half alone,attislocalandconislocal, which is what the child'sCREATE TABLEwrites and whatpg_dumpwrites for it. A NOT VALID check on the child is added by its ownALTERrather than written inline, so a reload no longer validates it. A comment on an inherited column and a child of more than one parent stay unmanaged, both symmetric on the two sides.
[1.42.0] - 2026-09-05¶
-
Read the storage parameters of the index a constraint owns.
ALTER TABLE ... ADD CONSTRAINT ... UNIQUE (v) WITH (fillfactor = 70)puts them on the index, whichpg_get_constraintdefdoes not print, so the two sides never matched and the constraint was dropped and re-added on every plan, rebuilding the index under an ACCESS EXCLUSIVE lock.dumpdropped the clause the same way and now writes it. A parameter set on such an index outside the schema file shows up as a diff, the same as on a plain index. A quoted parameter value and the bare spelling,deduplicate_items = 'off'andoff, also compare equal now, on a plain index too. -
Normalize an EXCLUDE constraint's elements and predicate the way a CHECK body is normalized. They were compared as written, so a hand-written exclusion drifted on a schema-qualified function or operator, a cast the catalog adds, a BETWEEN in the predicate, or an explicit ASC, and was dropped and re-added on every plan. A dump fed back was already clean; only a desired schema written another way hit this.
-
Track
UNLOGGEDon a standalone sequence. It was read by neither side, sodumpwrote an unlogged sequence as a plainCREATE SEQUENCEand aLOGGED<->UNLOGGEDchange produced no diff.dumpnow writesCREATE UNLOGGED SEQUENCEand a change goes out asALTER SEQUENCE ... SET LOGGED/UNLOGGED. Temporary sequences stay out of scope. -
Restore a
NOT VALIDcheck constraint unvalidated. A table's checks are written insideCREATE TABLE, where the clause cannot be spelled, sodumpprinted such a constraint as validated and the reload scanned the table to validate it. Such a check is now left out ofCREATE TABLEand added by its ownALTER TABLE ... ADD CONSTRAINT ... NOT VALID, the way a foreign key is. This also stops a plan that created the table from re-emitting the constraint on every later run.
[1.41.1] - 2026-09-04¶
- Wait for the apply exclusion by retrying rather than by blocking in
pg_advisory_lock. A blocked statement holds a snapshot while it waits, and the apply it waits for may runCREATE INDEX CONCURRENTLY, whose build waits for every backend that holds one. The two waits close a cycle, and since advisory locks share a lock manager with table locks the deadlock detector saw it and killed the waiter: a wait died a second in withfailed to acquire the apply exclusion: ERROR: deadlock detected, with the other apply still making progress. The wait now retriespg_try_advisory_lockonce a second, so between attempts the session is idle and holds no snapshot and no cycle can form. The deadline, the unlimited wait and the messages are unchanged; a lock that frees is taken within a second rather than at once, and the queue is no longer first-come-first-served.
[1.41.0] - 2026-09-03¶
-
Read a directive that follows a
/* ... */comment. The scan for the comments before a statement stopped at the first block comment, so a directive written after one was never seen: a-- pista:renamed-fromunder a block comment planned a drop and a create instead of a rename, and a block comment left at the end of the line above did the same. Both comment forms are now skipped, nested block comments included, and a directive inside a block comment stays commented out. The position an error or a warning points at skips them as well, so it lands on the statement rather than on the/*line or on the indent. -
Apply a directive on a file's last statement when it ends without a semicolon. pg_query reports no length for such a statement, so the scans that carve out a statement's text got an empty region:
-- pista:renamed-fromplanned a drop and a create instead of a rename,-- pista:ignoreleft the object managed, and-- pista:executeleft the statement to the ignored-statement warning instead of running it at apply.-- pista:concurrently,-- pista:bulk-alterand the inline column, enum value and composite attribute renames went the same way. The scans now share one helper, which reads such a statement to the end of the input. -
Name the file, line and column in the ignored-statement warning. It carried the statement text alone, which does not say where the statement is when the schema spans several files, or when the same
GRANTappears in more than one of them. It now readspistachio: schema/items.sql:12:1: ignored unsupported statement: DROP TABLE public.items. A parse that came from no file names no position. -
Read the desired schema before connecting.
planandapplyopened the connection first, so a missing file, a syntax error or a duplicate name was reported only once the database answered, and a run against an unreachable database reported the connection rather than the file. Both now read and parse the schema files, and resolve--pre-sql-fileand--concurrently-pre-sql-file, before connecting. A successful run is unchanged; one that cannot read its own files opens no connection, and takes no lock under--exclusive. -
Name the object and the position in a duplicate-name error.
duplicate column: idsaid neither which table the column was on nor where the file repeats it. It now readsduplicate column: id on public.usersand prints the line with a caret under it, as a syntax error does. A column or constraint is pointed at where it is defined, everything else at the statement that repeats the name. The checks that report a clash between two objects, a table and a view of one name for example, are unchanged.
[1.40.0] - 2026-09-03¶
-
Name the file, line and column in parse errors. A syntax error or a bad
-- pista:directive now prints the offending line with a caret under the column, in the style of a compiler, instead of the bare message. With several schema files the line number is local to the file the error is in. -
Stop a self-referencing foreign key from disabling the dependency sort. A key pointing at its own table's primary key, the
parent_idof a tree, was added to the graph as a self-edge and read back as a cycle.planandapplyfall back to category order when the sort fails, so one such table anywhere in the schema dropped the order for the whole run: two new views, one selecting from the other, went out in file order and the apply failed on the view that did not exist yet.dump --sort-by-depstreats a cycle as a hard error and wrote nothing at all. The composite type and view branches already skipped a self-reference; the foreign key branch now does the same. -
Manage an identity column's sequence options, the
( ... )afterAS IDENTITY.START WITH,INCREMENT BY,MINVALUE,MAXVALUE,CACHEandCYCLEwere read by neither side, so a file that wrote them was applied at the defaults anddumpdropped them. A change goes out asALTER TABLE ... ALTER COLUMN ... SET, and an option the desired schema no longer names is reset to its default.dumpwrites only the options that differ from the defaults.
[1.39.0] - 2026-09-02¶
- Read a schema in a fixed number of queries. A table's columns, constraints and policies were read one table at a time, and a domain's constraints and a composite type's attributes one type at a time, so a run cost a round trip per object. The gitlab sample schema took 910 statements for its 1083 tables; it now takes 13. The cost was in the round trips, so the gain grows with the latency to the database. The catalog is unchanged, so no plan or dump differs.
[1.38.1] - 2026-09-01¶
- Make a table's storage parameters opt-in, behind
--manage-storage-param($PISTA_MANAGE_STORAGE_PARAM). Managing them by default reset the autovacuum settings a table was tuned with, which are set on the database more often than written in a schema file. Without the flag they are dropped from both sides of the diff: noSETorRESETis planned, theWITHclause a file writes is left off theCREATE TABLE, anddumpdoes not write one. An index's parameters are part of its definition and are managed either way.
[1.38.0] - 2026-08-31¶
- Add
--exclusive/--exclusive-waittoapply. Opted-in apply runs on one database become mutually exclusive:--exclusivefails at once while another exclusive apply is running,--exclusive-waitwaits for it up to the given duration (0waits without limit). The exclusion is a session-level advisory lock scoped to the database, taken before the catalog read and released when the connection closes;--with-txand CONCURRENTLY index DDL do not affect it, and DDL from any other source is not blocked.
[1.37.0] - 2026-08-31¶
- Stop
BETWEEN,LIKEandNOT INfrom drifting. The grammar rewritesBETWEENinto the comparisons it stands for,LIKE/ILIKE/SIMILAR TOinto their operators,NOT INinto<> ALL (ARRAY[...])and(a, b)intoROW(a, b)before the expression reaches the catalog, so a desired schema that wrote the keyword never matched what came back: aCHECKwas dropped and re-added on every run, which revalidates the whole table, a partial index was rebuilt and a view recreated. A generated column failed the run instead, since a generated expression cannot be altered in place. The folds sit in the shared expression normalization, so a domainCHECK, a columnDEFAULT, a policyUSING/WITH CHECKand a triggerWHENclause get them too.INalready folded against the= ANY (ARRAY[...])it is stored as; that fold now matches the operator as well, sox > ANY (...)andx > ALL (...)stay apart. A typed literal is still re-printed in its type's output form and drifts; TODO.md covers it.
[1.36.0] - 2026-08-29¶
-
Manage a table's storage parameters, the
WITH (...)clause. Neither side read them, sodumpdropped the clause and no plan proposed one: the per-table autovacuum settings a pg_dump file carries went unmanaged without a warning. A change goes out asALTER TABLE ... SET (...)with aRESETfor the parameters the desired schema no longer names. Atoast.parameter is read from the TOAST relation and diffed alongside.WITH (oids = false), which a pg_dump before 12 writes, is not a parameter and is skipped. -
Stop an index's storage parameters from drifting.
pg_get_indexdefquotes a value that does not read as an identifier,fillfactor='80', while a file writes the number bare, so the two never matched and the index was dropped and recreated on every run. The order the catalog keeps them in is not compared either.
[1.35.0] - 2026-08-29¶
-
Stop a schema-qualified function call from drifting.
pg_get_constraintdef,pg_get_expr,pg_get_indexdef,pg_get_viewdefandpg_get_triggerdefall print a call unqualified once the function's schema is on the search_path, while the desired side keeps what the file wrote, soCHECK (public.lower_v(v) <> 'x')and every other call written that way re-emitted its statement on every plan. ACHECKwas dropped and re-added, which revalidates the whole table, an index was rebuilt, and a materialized view was dropped and recreated, discarding the data it held. A generated expression did not drift but failed the run outright, since it cannot be altered in place. The strip is symmetric and carries the tradeoff a view body's table reference already has: two same-named functions in different schemas compare equal. A schema inside anextval('public.counter'::regclass)literal is not a call name and still drifts; TODO.md covers it. -
Name the same pair every run when
-mmaps two schemas onto one destination. The check walked a Go map, so with three sources on one destination the rejection named a different pair each time the same command was run. -
Stop one suppressed constraint rename from taking another with it. A renamed constraint whose definition also changes goes through
DROPandADDrather thanRENAME, and theRENAMEwas suppressed by matching the rendered statement as a substring. Witharenamed tobalongsidexarenamed tobyon one table, the needle for the first matched the statement for the second, soxawas left in the database under its old name and the same plan came back on the next run. Foreign keys were suppressed the same way and had the same hole. -
Evaluate a
-- pista:executecheck under the target schemas inplan, asapplyalready did.planran the check under the connection'ssearch_path, which ispublicby default, so a check reading a table in another target schema failed there and the statement was reported as undetermined, whileapplyanswered the check and skipped it. The plan advertised a statement that never ran. TheSETgoes out only on a run that has a check to evaluate, and after the catalog has been read, so nothing else moves. -
Order a view after every relation its body names. The dependency scan walked a hand-written list of node kinds, so a reference under a cast,
GREATEST, an array or row constructor, a subscript,ORDER BY,GROUP BY,LIMIT, anOVERclause or an aggregateFILTERwent unseen. Two new views in one run, one reading the other from such a position, were created in the wrong order and the apply failed on the relation that did not exist yet. The scan now uses the same walk the view comparison does, which reaches every node kind. -
Leave a sequence the desired schema declares outside the target schemas alone. The desired-side schema filter named every object type but the sequence, so a
CREATE SEQUENCE other.countersitting in a file planned with-n publicwas compared against a current side that never holds it, and the plan created it in a schema the run was not asked to manage. A table, view, enum, domain, composite type or routine in the same file was already dropped before the diff. -
Emit the
ALTER POLICY ... RENAME TOstatements in the desired schema's order. The renames were read out of a Go map, whose iteration order is randomized, so a plan renaming two or more policies on one table printed them in a different order on every run. The statements are independent, so only the output moved, but a plan that is reviewed, or diffed against the previous run, has to be stable.
[1.34.0] - 2026-08-28¶
- Manage a partitioned table without its partitions, behind
--skip-partition-child($PISTA_SKIP_PARTITION_CHILD). Where another tool creates the partitions, pg_partman for example, a schema file that declares the parent alone planned aDROP TABLEfor every partition, and-Eonly reached names that carry a pattern. With the flagdumpwrites the parent alone, andplan/applyskip a partition on both sides of the diff, including the indexes, policies and comments it owns. The parent stays managed, and PostgreSQL carriesADD COLUMNand an index created on it down to the partitions. A partition is skipped whether or not it is partitioned itself, so a sub-partitioned level goes with the leaves under it. AnINHERITSchild carries no partition bound and stays managed.
[1.33.0] - 2026-08-28¶
-
Check the column references in a
DEFAULTorGENERATEDexpression against the table's desired columns. The validator already read the index, constraint and foreign-key definitions on a table, so a stale name left in one of those failed the plan with a message naming it, while the same name in a column expression reached the database and failed there. A generated expression may reference only columns of its own table, so every name it carries is checked. A plainDEFAULTmay reference no column at all, which PostgreSQL rejects on its own; the names it does carry are checked the same way rather than rejected outright. An expression pg_query cannot read is skipped, the way an unreadable index definition already was. -
Carry the triggers, policies and generated columns on a table through a column rename.
-- pista:renamed-fromrewrote the column references in the same table's indexes, constraints and foreign keys before the comparison, and left the other three same-table dependents alone. A triggerUPDATE OFlist orWHENexpression, and a policyUSING/WITH CHECKclause, read as a definition change, so the plan carried aCREATE OR REPLACE TRIGGERor anALTER POLICYnext to the rename. PostgreSQL appliesRENAME COLUMNto all of them, so neither statement changed anything, and the trigger one took aSHARE ROW EXCLUSIVElock to do it. A stored generated expression was worse than noise: it cannot be altered in place, so the run failed withcannot change GENERATED expressionon a rename PostgreSQL would have carried through.
A constraint trigger keeps its kind through the rewrite, so it is not dropped and recreated either. The arguments passed to a trigger function are left alone, as PostgreSQL does not rewrite them, and so is a plain DEFAULT, which cannot reference a column. Views and other tables are still not rewritten; TODO.md records what is left.
[1.32.0] - 2026-08-28¶
-
Stop
IS NOT DISTINCT FROMfrom drifting. PostgreSQL stores the operator as the negation ofIS DISTINCT FROM, so a desired schema that wrote it never matched the catalog: aCHECKwas dropped and re-added on every run, which revalidates the whole table, and an index using it was rebuilt. The fold sits in the shared expression normalization, so a view body, a policyUSING/WITH CHECK, a triggerWHENclause and a domainCHECKget it too. A generated column did not drift but failed the run, since a generated expression cannot be altered in place. -
Stop the
DISTINCToperators againstNULLfrom drifting. Parse analysis rewritesa IS NOT DISTINCT FROM NULLtoa IS NULLbefore the expression is stored, so the catalog handed back a test where the desired side kept the operator, and theCHECKwas dropped and re-added on every run. The rewrite joins the fold above in the shared expression normalization, so an index expression and predicate, a view body, a policyUSING/WITH CHECK, a triggerWHENclause, a domainCHECK, a columnDEFAULTand a generated column get it too. PostgreSQL keeps a negation the definition wrote around the test it rewrote, and stores a cast literal that way too, soNOT (a IS NOT NULL)anda IS NULLcompare as one. A row constructor ortbl.*is left alone, since the catalog keeps the operator and the test apart on a row-typed argument. A composite-typed column reads as neither in the parse tree, so the two forms compare equal on one and a change between them is dropped from the plan. -
Manage column TOAST storage and compression.
pg_attribute.attstorageandattcompressionwere read by neither side, sodumpdropped a column'sSTORAGE EXTERNALand a plan never proposed either setting. Both are read now, from the column definition (body text STORAGE external COMPRESSION lz4) and from theALTER TABLE ... ALTER COLUMN ... SET STORAGE/SET COMPRESSIONapg_dumpfile carries.dumpwrites them as separate statements, which is what PostgreSQL 15 accepts and whatpg_dumpwrites. Neither statement rewrites the table; both decide how values inserted later are stored.
A definition that names neither asks for the defaults, so a column the database carries at EXTERNAL plans back to its type's own strategy, named outright since storage has no unset state in the catalog and SET STORAGE DEFAULT arrived in 16. Spelling the type default out therefore does not drift. Compression goes back to default_toast_compression with SET COMPRESSION default. SET DATA TYPE resets both, even when the type does not change, so a plan writes them out again after a type or collation change. A partition copies both off its parent as it is created and declares no columns of its own, so only the parent carries the statements. SET STORAGE on the parent recurses to the partitions that already exist, SET COMPRESSION does not, so a method added to a partitioned table leaves those partitions on the old one.
[1.31.0] - 2026-08-25¶
-
Accept a regular expression in
--include/--exclude. A pattern wrapped in slashes is one, so-E '/^posts_\d+$/'reaches a numbered partition set that a wildcard cannot; anything else stays a wildcard, and the two forms mix in one invocation. A regular expression matches anywhere in the name unless it is anchored; a wildcard still has to match the whole name. An invalid expression is rejected before the database is read. The flags did not change, so$PISTA_INCLUDE/$PISTA_EXCLUDEand the config file carry both forms. -
Warn about an
ALTER TABLEaction or aCOMMENT ONtarget the parser drops. Both have a parser case of their own, so neither reached theignored unsupported statementwarning: a column added byALTER TABLE ... ADD COLUMNwas absent from the desired schema and the plan proposed dropping it from the database, andALTER COLUMN ... SET DEFAULT/SET NOT NULL/TYPEwent the same way. The warning follows what the parser reads, so an action or target added later warns instead of vanishing. AnALTER TABLEnaming a relation the file does not declare, or one marked-- pista:ignore, is still skipped without one.
[1.30.0] - 2026-08-25¶
- Manage functions and procedures, behind
--manage-routine($PISTA_MANAGE_ROUTINE). Routines are off by default, so a schema maintained with-- pista:executekeeps working and plan output is unchanged. With the flag,pg_procis read,dumpwrites each routine, and the diff compares the body, the language and the attributes (volatility,STRICT,SECURITY DEFINER,LEAKPROOF,PARALLEL,COST,ROWS,SET). A routine is identified by its name and its argument types, so an overload set is several objects. Changes apply withCREATE OR REPLACE; the ones PostgreSQL refuses in place run asDROPandCREATE, gated by--allow-drop routine.routinealso joins the--enable/--disabletype list, and-- pista:ignoreworks on aCREATE FUNCTIONorCREATE PROCEDURE.
A routine is created after the types its signature names and before every table, so a CHECK constraint, an index expression, a policy or a trigger can call one. A LANGUAGE sql body that reads a table created in the same run therefore fails to apply, since PostgreSQL parses a SQL body at creation time. Aggregates, window functions, a BEGIN ATOMIC body, a routine an extension owns, and one carrying an option pistachio does not read such as SUPPORT are not managed; each is warned about and skipped rather than failing the run. A routine with SET ... FROM CURRENT is read back with the value resolved, so it is treated as -- pista:ignore instead. Renaming a routine is not supported.
[1.29.0] - 2026-08-24¶
-
Manage triggers.
CREATE TRIGGER,CREATE CONSTRAINT TRIGGERandINSTEAD OFtriggers on views are read frompg_trigger, written bydump, and diffed. A definition change usesCREATE OR REPLACE TRIGGER; switching a trigger to or from a constraint trigger runs asDROPandCREATE, gated by--allow-drop trigger. The enable state is carried asALTER TABLE ... ENABLE/DISABLE TRIGGER, and-- pista:renamed-fromrenames a trigger. The function a trigger calls stays unmanaged, so it belongs in a-- pista:execute-firststatement. -
Keep a constraint trigger out of the table's constraints.
CREATE CONSTRAINT TRIGGERalso writes apg_constraintrow, and the catalog read it as a table constraint.dumpthen emittedCONSTRAINT x TRIGGER ..., which does not parse, andplanproposed dropping the trigger. Triggers remain unmanaged.
[1.28.0] - 2026-08-23¶
-
Require Go 1.27 to build.
-
Move to
orderedmap/v2. The API is unchanged, so the CLI behaves the same, but the exported models holdorderedmap.Mapand code that imports pistachio as a library has to switch import paths too. -
Run the Docker image as
pistachio(uid 1000) instead of root. A bind mount it writes to has to be writable by that uid, or passdocker run --user. -
Drop
ca-certificatesfrom the Docker image.alpine:3already ships the bundle Go reads its roots from.update-ca-certificatesgoes with it. -
Compare a type modifier without regard to case. PostgreSQL downcases an unquoted keyword in a modifier as it parses the DDL, while
format_typeprints it the way the type writes it back, so a PostGISgeometry(Polygon,4326)column plannedSET DATA TYPE geometry(polygon,4326)on every run, and a domain over the same type failed withcannot change base type.
[1.27.0] - 2026-08-23¶
-
Name an index written without one, the way PostgreSQL names it.
CREATE INDEX ON public.items (qty)reached the diff with an empty name, which matched no index in the database, so every run planned the index again and PostgreSQL numbered each copy: threeapplyruns leftitems_qty_idx,items_qty_idx1anditems_qty_idx2behind, and the plan never came back empty. The name followsChooseRelationName: the table, then one name per index element joined with underscores, then_idx, shortened bymakeObjectNamewhen it does not fit. An element is named after its column, and an expression after the function it calls ((lower(c))givest_lower_idx), a field selection's field, the column under a subscript, a cast's type, orCASE, falling back toexprfor anything with no name of its own; theINCLUDElist joins the name too, and a name repeated within one index takes a number. Two unnamed indexes that produce one name are now rejected as a duplicate index name, which PostgreSQL would have resolved by numbering the second. -
Shorten an auto-generated constraint name the way PostgreSQL does. The table name, the key columns and the label were joined with no length limit, so a name over 63 bytes reached the server in full and was truncated there. PostgreSQL truncates from the right, which cuts into the label: an unnamed
CHECKon a 59-character table went in as{table}_quantity_checkand was stored as{table}_quantit. The next plan did not find that name, so it added the constraint again on every run, and the secondapplyfailed withconstraint "..." already exists. PostgreSQL builds the name withmakeObjectName, which shortens the table and column parts, the longer one first, until the whole name fits, and leaves the label alone. Each part is clipped on a character boundary, so a multibyte name is not cut in half. A domain's unnamedCHECKis named the same way. Shortening can also bring two distinct names together, and the file is then rejected as a duplicate constraint name where it used to reach the server.
[1.26.0] - 2026-08-08¶
-
Diff the logged <-> unlogged transition, as
ALTER TABLE ... SET LOGGED/SET UNLOGGED.model.Table.Unloggedwas read from the catalog and parsed from the desired schema, and used to writeCREATE UNLOGGED TABLE, but never compared, so an existing table changing either way planned-- No changes. PostgreSQL refuses to leave a logged table referencing an unlogged one and checks it on everyALTER, so the statements are ordered by the foreign keys between the tables that change: a table turning unlogged waits for the tables that reference it, and a table turning logged waits for the tables it references, which are opposite orders. They run after the creates, so a table renamed in the same plan is already under its new name, and before the foreign-key adds. A partitioned table is skipped. PostgreSQL changes persistence by rewriting the table, and a partitioned one has nothing to rewrite, so the statement reports success and changes neither the parent nor the partitions under it; emitting it there would replan forever. A partition holds its own storage and takes the transition normally. -
Fix
dumpdropping thePARTITION BYof a partition that is itself partitioned. TheCREATE TABLEwas finished right after thePARTITION OF ... FOR VALUESclause, so the middle level of a multi-level partitioned table was written as a leaf, and reloading that dump failed at the level below it, which still saidPARTITION OFthat table. The same code builds theCREATE TABLEa plan emits, so adding such a partition throughapplycreated it unpartitioned and the partition under it failed withis not partitioned. The partition key is read from the catalog and parsed from the desired schema either way; it was only left out of the output. -
Set
search_pathon every connection, topublicunless--search-path(env$PISTA_SEARCH_PATH) says otherwise. The catalog reads the schema throughpg_get_viewdef,pg_get_constraintdef,pg_get_exprandformat_type, and each drops the schema from an object the session can reach unqualified, so a server-sideALTER ROLE ... SET search_path, or its database-level form, decided whatdumpwrote and what the diff compared. With a target schema on that path,dumpwrotem mood,nextval('counter'::regclass)andFROM users, which reload into whatever schema the next session resolves the bare names to, and a desired schema that qualified its own objects plannedSET DEFAULTandDROP CONSTRAINT/ADD CONSTRAINTon every run. The default ispublicalone rather than PostgreSQL's own"$user", public, since the"$user"entry puts a schema named after the connecting role on the path, and the role that runs migrations is often not the role the application connects as.--search-path=qualifies everything, which makes a dump reload the same way wherever it runs.applystill setssearch_pathto the target schemas pluspublicbefore its DDL, so an unqualified reference there resolves as before. -
Walk the parse tree generically instead of from a list of node kinds.
WalkExprColumnRefs(internal/pgast),stripQualifications(diff/views.go) and the expression normalizations indiff/tables.goeach carried their ownswitch, covering about twenty of pg_query's 268 kinds between them, and anything outside them was skipped in silence. They now share one traversal built on protoreflect, which reaches every kind and needs no upkeep as PostgreSQL adds more. The traversal decides nothing about which nodes matter, so a rename stays off a function name, anA_Indirectionfield name and a type name by acting onColumnRefalone, and the walk stops at a nestedSELECTfor the callers that work within one table. -
Fix the SQL/JSON expressions hiding a column from the expression walker.
JSON_OBJECT,JSON_ARRAY,JSON,JSON_SCALAR,JSON_SERIALIZE,IS JSON,JSON_OBJECTAGG,JSON_ARRAYAGGand the PostgreSQL 17JSON_VALUE,JSON_QUERYandJSON_EXISTSare all reached now. An unnamedCHECKthat reads its only column through one of them was namedt_checkwhere PostgreSQL sayst_a_check, which planned anADD CONSTRAINTnext to aDROP CONSTRAINTon every run; the desired-schema validation missed a column that does not exist; and a column renamed with-- pista:renamed-fromkept its old name inside the expression. AJSON_OBJECTkey is an expression too, soJSON_OBJECT(a: b)reaches two columns and takes{table}_check, matching the server. -
Fix a view keeping its table qualification wherever
stripQualificationsdid not reach.pg_get_viewdefreturns a column reference unqualified where the file writestable.column, so a body holdingCOALESCE,CASE,GREATEST, a subscript, an array or row constructor,IS TRUE,COLLATE, an XML or SQL/JSON expression, an aggregateORDER BYorFILTER, anOVERclause,DISTINCT ON,GROUP BY ROLLUPor a sub-query nested under any of them re-emittedCREATE OR REPLACE VIEWon every plan and keptplan --checkat exit code 2 forever. -
The plan and apply test harnesses take a
min_pgfield, which skips a fixture on a server older than the syntax it needs. The plan harness also takes its mirror,max_pg, which skips a fixture on a server newer than the shape it pins. PostgreSQL 18 rejects an unlogged partitioned table.
[1.25.0] - 2026-08-06¶
-
Error when one name is used for two objects PostgreSQL keeps in the same catalog. Each object kind is tracked in its own map, so a duplicate was caught only within a kind:
CREATE TABLE xnext toCREATE VIEW xpassed the parser and failed partway through the apply with"x" is not a view. pg_class holds tables, views, materialized views, sequences, composite types and indexes. pg_type holds a row for every table, view, materialized view and composite type, plus domains and enums, and none for a sequence or an index. The indexes PostgreSQL builds forPRIMARY KEY,UNIQUEandEXCLUDEconstraints take the constraint name, so those names are registered in pg_class as well. An index written without a name is skipped, since PostgreSQL picks that name at create time. Objects marked-- pista:ignoreare included, since an unmanaged relation still occupies its name. A clash reads asduplicate relation name: public.x (table and view)orduplicate type name: public.x (table and domain), one line per object. A duplicate index name on two tables in one schema is now an error as well; index names were unique per table before. -
Fix an unnamed constraint being auto-named after its first key column alone. PostgreSQL's
ChooseConstraintNamejoins every column, soUNIQUE (a, b)ist_a_b_keythere and wast_a_keyhere; multi-columnEXCLUDEand foreign keys were off the same way, and anEXCLUDEon an expression now takes PostgreSQL'sexprin place of a column. ACHECKis named after the one column its expression references, or{table}_checkwhen it references none or several, which is what PostgreSQL does even for a constraint written on a column. Applying to an empty database hid the difference, since pistachio wrote its own name into the DDL, but adopting a table that already existed planned anADD CONSTRAINTunder pistachio's name together with aDROP CONSTRAINTfor PostgreSQL's, on every run. A database built with the old names gets the constraint added under the new name on the next run, and dropping the old one needs--allow-drop constraint; until then the table carries both and every plan repeats the skipped drop. Writing the old name as an explicitCONSTRAINTclause keeps it instead. -
Fix
GREATEST/LEAST, a subscript or field selection (a[1],(a).f), a row constructor,IS TRUE/IS NOT FALSE,COLLATE, named-argument notation (f(x => a)) and the XML constructors (xmlelement,xmlattributes,xmlserialize) hiding a column reference from the expression walker. Three things read columns out of an expression through it, and all three were wrong for those node kinds: an unnamedCHECKreaching its column that way lost the column from its generated name, givingt_checkwhere PostgreSQL sayst_a_check; the desired-schema validation missed a column that does not exist; and a column renamed with-- pista:renamed-fromkept its old name inside the expression, so the plan carried a needlessDROP CONSTRAINTandADD CONSTRAINTnext to theRENAME COLUMN. -
Fix an unnamed
ALTER TABLE ... ADD FOREIGN KEYreaching the plan with no constraint name, asADD CONSTRAINT FOREIGN KEY (a) REFERENCES ..., which PostgreSQL rejects. That path never auto-named the constraint. -
Error when a name that belongs to one object is used twice inside it. pg_constraint scopes a constraint name to the table or the domain that owns it, and pg_attribute and pg_enum scope a composite type's attributes and an enum's labels to that type.
ConstraintsandForeignKeysare separate maps, and a domain's constraints, a composite type's attributes and an enum's labels are plain slices, so none of these were compared:CONSTRAINT c CHECK (...)next toCONSTRAINT c FOREIGN KEY ...on one table, twoCHECKconstraints under one name on a domain,CREATE TYPE ct AS (n integer, n integer)andCREATE TYPE et AS ENUM ('a', 'a')all passed the parser and failed at apply. A clash reads asduplicate constraint name: c on public.t (CHECK constraint and FOREIGN KEY constraint),duplicate attribute: n on public.ct, orduplicate enum value: a on public.et. Note that two unnamedCHECKconstraints on one domain now error as well: PostgreSQL names themd_checkandd_check1, a suffix pistachio does not predict, so both come out asd_check. Unnamed table constraints already behaved this way.
[1.24.0] - 2026-08-05¶
- Add
dump --sort-by-depsto order the dump by object dependency instead of by name. Each object is written after the objects it depends on, so a table follows the types it uses and the tables its foreign keys reference, and a view follows the tables and views it selects from. The output loads from top to bottom withpsqlwithout forward references. When the dependency graph has a cycle, such as two tables with mutual foreign keys, the dump cannot be ordered and errors. The flag cannot be combined with--split, which writes each object to a separate file.
[1.23.1] - 2026-08-04¶
- Warn when the parser ignores an unsupported statement. A schema file may hold a statement pistachio does not manage, such as
SET,GRANT, orCREATE EXTENSION. Before, it was dropped from the desired schema without any notice. pistachio now printspistachio: ignored unsupported statement: <sql>to standard error for each one. The statement is shown as a single canonical line, without the surrounding comments, and truncated when long. Mark a statement with-- pista:executeto keep it and silence the warning. ABEGIN/COMMITwarning points at--with-txand--try-tx.
[1.23.0] - 2026-08-01¶
-
BREAKING:
plannow evaluates-- pista:executecheck SQL and leaves out the statementsapplywould skip. Before, every marked statement was printed regardless, so a file with a false check produced a plan full of SQL whileapplyon the same file reported-- No changes. Three consequences: -
plan --checkexits 0 instead of 2 when the only pending statements have a false check. - A check
plancannot evaluate is undetermined rather than fatal.planruns before the managed DDL and on a read-only connection, so a check that reads a table the same run creates, or that writes, fails there while answering fine duringapply. The statement stays in the plan with the reason recorded as a comment, andplan --checkstill reports a diff. Behavior duringapplyis unchanged: the check runs at its proper moment, and a failure there stops the run. -
planruns the check SQL, which it did not do before. A check with side effects now runs once perplanas well as duringapply. -
Add
-- pista:execute-first, which runs non-managed SQL before the managed DDL instead of after it. Use it when the managed DDL calls a function pistachio does not manage, as aCHECKconstraint, aGENERATEDexpression, an index expression, or a policy can.-- pista:executeis unchanged and still runs last. The new directive's check SQL is evaluated before the change, so a check that tests for a table or column the same run creates belongs on-- pista:execute.
[1.22.0] - 2026-07-31¶
-
Fix every
ADD CONSTRAINTbut the first being dropped from anALTER TABLEthat adds several in one statement, as--bulk-alteroutput does. The rest were discarded without an error, soplanoffered to drop them from the database. A-- pista:renamed-fromdirective on such a statement is now an error, because it names one old object. -
Fix
dumpemitting a partition's parent, or anINHERITSparent, without its schema, asPARTITION OF events. The statement failed when the parent was not onsearch_path, and attached the partition to a same-named table in another schema when one existed. The same missing schema hid the parent from the dependency graph, so dropping a partitioned table and its partitions in one run could drop the parent first and fail on the partition's ownDROP. Note thatdumpstill emits objects in name order, so a parent that sorts after its child is written after it. -
Fix
COMMENT ON TABLEandCOMMENT ON COLUMNnever being applied to a partition. Comments belong to the individual relation and PostgreSQL accepts them on a partition, but they were skipped along with the inherited columns and constraints. Removing such a comment is diffed as well, even though the partition declares no columns of its own. -
Fix a column being dumped as
serialwhen a sequence is tied to it by a plainALTER SEQUENCE ... OWNED BYwhile its default is something else. The real default was dropped from the dumped definition and no drift was reported. A column now counts asserialonly when its default draws from the sequence it owns. -
Fix
ALTER SEQUENCE ... OWNED BYbeing ignored in the desired schema. The statement now marks the sequence unmanaged, asCREATE SEQUENCE ... OWNED BYalready did. Before, every plan proposed creating a sequence that already existed andapplyfailed withrelation "..." already exists. Detaching withOWNED BY NONEis still not supported.
[1.21.1] - 2026-07-31¶
-
Fix broken DDL for a collation whose name contains a dot, such as the
C.utf8anden_US.utf8family. The schema-qualified name was split on every dot, so a domain or composite attribute was emitted asCOLLATE pg_catalog."C".utf8, which PostgreSQL rejects as a cross-database reference. Collation names are now split on the quoting instead. -
Fix
dumpdropping the schema from a column collation.COLLATE mycollresolved only when the collation's schema was onsearch_path; the dump now emitsCOLLATE public.mycoll, matching what domains and composite types already did. A column collation written schema-qualified in the desired schema also no longer produces a no-opSET DATA TYPEon every plan. -
Fix
planfailing withcannot change collation of domain ...when the desired schema writes a domain collation unqualified, such asCOLLATE "C"against the catalog'spg_catalog."C". The two forms are now recognized as the same collation, as they already were for composite attributes. -
Fix a no-op
SET DATA TYPEon every plan for a column written withCOLLATE "default". The default collation is what a column gets implicitly and the catalog never reports it, so it is now ignored on columns as it already was on domains and composite attributes. -
Fix a domain
CHECKdefinition being truncated when the expression contains the textCONSTRAINT, as inCHECK (VALUE <> ' CONSTRAINT '). The definition was read out of the deparsed statement and cut at the next occurrence of that text, which also matches inside a string literal. It is now built from the parse tree, the same path a definition that did not match the marker already took. -
Fix broken DDL for an identifier that is one of PostgreSQL's 23
type_func_namekeywords (left,right,full,inner,binary,natural, and so on). They are valid as a type or function name but not as a table, column, index, constraint, sequence, type, or policy name. Only reserved keywords were quoted, so these were emitted bare:dumpproduced SQL that could not be parsed again, andplan/applyproduced DDL that PostgreSQL rejected with a syntax error. Quoting now follows the grammar'sColIdrule, which accepts a bare identifier, an unreserved keyword, or acol_namekeyword.
[1.21.0] - 2026-07-31¶
- Add
--try-tx(env$PISTA_TRY_TX) toapply. It works like--with-tx, except that a diff containingCREATE/DROP INDEX CONCURRENTLYruns without a transaction instead of failing, and the output records-- Transaction skipped: plan contains CONCURRENTLY index DDL. The decision follows the generated diff, so a-- pista:concurrentlyindex that is not being changed still gets a transaction.--with-txis unchanged and the two flags are mutually exclusive.--try-txcan be combined with--force-index-concurrently.
[1.20.0] - 2026-07-30¶
-
Manage composite types.
plan,apply, anddumphandleCREATE TYPE ... AS (...),ALTER TYPE ... ADD/DROP/ALTER ATTRIBUTE,RENAME TO,RENAME ATTRIBUTE, and comments on the type and its attributes. A composite type is created after the enums, domains, and composite types its attributes reference, and before tables that use it.--enable,--disable,--include,--exclude, and--allow-dropacceptcomposite_type;--allow-drop=composite_typealso gatesDROP ATTRIBUTE. Attribute reordering is not diffed (PostgreSQL cannot reorder attributes), andALTER ATTRIBUTE ... TYPEfails at apply while a table column uses the type. (#331) -
Fix a schema-qualified user-defined type (enum, domain, or composite) used as a column or attribute type producing a no-op
SET DATA TYPEon every plan. The column type is now compared with the type's own schema stripped. Also setsearch_pathto the target schemas pluspublicbefore applying managed DDL, so an unqualified reference to a user type or to apublicobject (such as an extension type) resolves for a non-public target schema instead of failing at apply. (#331)
[1.19.0] - 2026-07-29¶
-
Add
--assume-validated(env$PISTA_ASSUME_VALIDATED) to treat every constraint as validated. Pistachio ignoresNOT VALIDin the desired schema and never emitsNOT VALIDorVALIDATE CONSTRAINT. Constraint additions and definition changes still apply, withoutNOT VALID. Use it whenNOT VALIDis a one-off migration step you do not want in the desired state. -
Track the validation state of domain CHECK constraints. A constraint that is
NOT VALIDin the database no longer produces a spuriousDROP+ADDon every plan. When its definition matches the desired schema, it is validated withALTER DOMAIN ... VALIDATE CONSTRAINTinstead.--assume-validatedalso covers domain constraints.
[1.18.0] - 2026-07-27¶
- Add
--config(-C, env$PISTA_CONFIG) to load options from a YAML file. Keys are flag names (e.g.conn-string); an unknown key is an error. One file works for every command, and keys the running command does not use are ignored. Precedence is command-line flag > environment variable > config file > default.
[1.17.0] - 2026-07-21¶
- Manage standalone sequences.
plan,apply, anddumphandleCREATE SEQUENCE,ALTER SEQUENCE,DROP SEQUENCE,COMMENT ON SEQUENCE, and rename via-- pista:renamed-from. Only standalone sequences are managed. A sequence owned by aserialor identity column stays with that column and is excluded from the diff, classified bypg_depend.deptype. A sequence referenced by a column default vianextvalis created before that table and dropped after.--enable,--disable,--include,--exclude, and--allow-dropacceptsequence. TheUNLOGGEDattribute is not tracked. (#296)
[1.16.1] - 2026-07-18¶
- Fix invalid DDL for a single-column
UNIQUE/PRIMARY KEYconstraint on a column namedvalue. libpg_query dropped the column from the deparsed definition, soplan/applyproduced a brokenADD CONSTRAINT ... UNIQUEand reported false drift. The key columns are now restored from the parse tree. (#291)
[1.16.0] - 2026-07-13¶
- Open the
plananddumpconnections in read-only mode, so those commands cannot write to the database even by accident.applystill applies DDL. Pass--no-read-only(env$PISTA_NO_READ_ONLY) for a read-write connection.
[1.15.0] - 2026-07-13¶
- Add the
-- pista:ignoredirective. Put it before aCREATE TABLE/CREATE TYPE ... AS ENUM/CREATE DOMAIN/CREATE VIEWstatement to leave that object unmanaged: pistachio does not create, alter, or drop it. The object is dropped from both the desired and current state before diffing, so it is the in-file equivalent of--excludefor a single object. Each ignored object is reported as an-- ignored: <name>comment inplan/applyoutput. The directive takes no arguments.
[1.14.0] - 2026-07-13¶
-
Add a Windows (amd64) release binary, cross-built with mingw-w64.
$PISTA_PAGERis interpreted bycmd /con Windows. CI builds the binary on windows-latest and runsmake testagainst the runner's preinstalled PostgreSQL. -
Support enum value renames. Put
-- pista:renamed-from <old_value>on the line before a value insideCREATE TYPE ... AS ENUMto emitALTER TYPE ... RENAME VALUEinstead of failing with a value-removal error. The old value may be quoted or bare. Already-applied renames are skipped. Renaming to an existing value or from a missing value is an error. -
Add
--check(env$PISTA_CHECK) toplanfor drift detection in CI. The exit code is 2 if the plan contains executable changes, 0 if not, and 1 on error. The output does not change. Suppressed drops alone exit 0 because they generate no executable DDL.PlanResultgains aHasChangesfield, and the CLI uses it instead of checkingSQLfor emptiness.
[1.13.0] - 2026-07-03¶
- Add the
-- pista:bulk-alterdirective. Put it before aCREATE TABLEstatement to combine that table's consecutiveALTER TABLEactions into a single statement. Other tables keep one statement per action. The--bulk-alterflag (env$PISTA_BULK_ALTER) is unchanged and merges every table. Like-- pista:concurrently, the directive takes no arguments and is ignored on statements other thanCREATE TABLE.
[1.12.1] - 2026-06-27¶
- Fix
dump --omit-schemaleaving the schema name in indexes on partitioned tables.pg_get_indexdefemitsON ONLY <schema>.<table>for such indexes, which the schema-stripping replacement did not match, sopublic.stayed in the output.
[1.12.0] - 2026-06-24¶
- Print the apply phase duration at the end of
pista applyas a-- Apply finished in <duration>comment. It covers SQL execution and output, excludes connection setup and diff computation, and is omitted when there are no changes. (#260)
[1.11.0] - 2026-05-31¶
- Add
--force-index-concurrently(env$PISTA_FORCE_INDEX_CONCURRENTLY) toplanandapply. Forces CONCURRENTLY on everyCREATE INDEX/DROP INDEXthe diff emits, including pure drops (indexes removed from desired) that the-- pista:concurrentlydirective cannot reach because catalog-derived indexes do not carry the flag. Conflicts with--disable-index-concurrentlyon both subcommands and with--with-txon apply; both are enforced as kong xor groups. (#254)
[1.10.6] - 2026-05-28¶
- Replace the em dash in two GENERATED column error messages with a semicolon:
cannot toggle GENERATED; DROP COLUMN + ADD COLUMN is requiredandcannot change GENERATED expression; DROP COLUMN + ADD COLUMN is required. (#242)
[1.10.5] - 2026-05-22¶
- Trim leading / trailing whitespace from each
--include/--excludepattern inFilterOptions.AfterApply. Multi-value env vars likePISTA_INCLUDE="user*, posts"were parsed by kong into["user*", " posts"], and the leading space madepath.Matchtreat the second pattern asposts, silently matching nothing. The trim runs beforeValidatePatterns, so the post-trim values are also what gets validated and whatMatchNamecompares against.
[1.10.4] - 2026-05-17¶
- Bind
apply --with-txto the$PISTA_WITH_TXenvironment variable so it can be enabled from CI / shell config without passing the flag on every invocation. Aligns with the existing$PISTA_BULK_ALTER/$PISTA_DISABLE_INDEX_CONCURRENTLYenv-tag pattern on the other apply bool flags.
[1.10.2] - 2026-05-17¶
- Emit
-- Transaction started/-- Transaction committed/-- Transaction rolled backcomments inapply --with-txoutput so the transaction boundary is visible in the SQL stream. The CLI now also flushes buffered apply output whenclient.Applyreturns an error, so partial DDL that ran before the failure plus the-- Transaction rolled backmarker are surfaced to the user instead of being discarded with the error. Plain SQL keywords (BEGIN/COMMIT/ROLLBACK) were considered but rejected because they look like skipped statements; the comments use sentence form to distinguish them from emitted DDL. (#229)
[1.10.1] - 2026-05-17¶
- Make
--no-pagernegatable: the flag is now--[no-]pager, and--pagerforces paging on even when stdout is not a TTY. This lets paging work when stdout is piped into a tool that itself expects pager-style output (syntax highlighter, etc.) where the auto TTY check would otherwise short-circuit.PISTA_PAGERstill determines which command to run, so--pagerwith an unsetPISTA_PAGERremains a no-op. Internallycommand.StartPagernow takes*bool(nil= auto / TTY check,true= force,false= disable) instead of a plainbool.
[1.10.0] - 2026-05-17¶
- Fix
DROP COLUMNordering when dependent objects (UNIQUE / CHECK constraints, dependent indexes, RLS policies, stored-generated columns) are dropped in the same plan. PostgreSQL silently cascades single-column UNIQUE/CHECK constraints and dependent indexes duringDROP COLUMN, so a subsequent explicitDROP CONSTRAINT/DROP INDEXfails with... does not exist; RLS policies and same-table stored-generated columns blockDROP COLUMNoutright.diff.diffColumnsnow returns column drops in a separate slice (emitting stored-generated columns before non-generated ones so a pairedgenerated_col -> source_coldrop succeeds withoutCASCADE) anddiff.diffTableappendscolDropStmtsat the tail ofresult.Stmts, after policy / index / constraint / comment / RLS handling. FK drops continue to emit first viaFKDropStmts, so dependents are gone before the column itself goes. The bulk-alter merge naturally inherits the new order, so a singleALTER TABLElistsDROP CONSTRAINTbeforeDROP COLUMN. (#223) - Use
DROP VIEW+CREATE VIEWfor regular view definition changes that remove, rename, or reorder output columns. PostgreSQL'sCREATE OR REPLACE VIEWrequires the new query to produce the same column names in the same order as the existing view; appending columns at the end is allowed, but removing / renaming / reordering raisescannot drop columns from view. The same definition change also blocks an accompanyingDROP COLUMNon a referenced table because the view's dependency on that column hasn't been cleared yet.diff.canCreateOrReplaceViewparses both view bodies viapg_query, extracts the output column names from the top-level target list (recursing into the left arm forUNION/INTERSECT/EXCEPTset ops since PostgreSQL inherits result names from the first SELECT), and returns false when the desired column list is not a prefix-extension of the current one, falling back to the existingviewDiff.DropStmts+CreateStmtspre-drop sequencing so the subsequentDROP COLUMNsucceeds. Views whose output columns can't be determined statically (SELECT *, target-list expressions without an alias, parse error) are conservatively routed throughDROP + CREATE, which avoids the rejection that the priorCREATE OR REPLACEwould have triggered. Known limitation: type-only changes on a same-named column (e.g.SELECT n FROM t->SELECT n::bigint AS n FROM t) are reported asCREATE OR REPLACE-able because the names line up, but PostgreSQL still rejects them withcannot change data type of view column;pg_querydoesn't perform type inference, so we can't detect this statically. (#224) - Handle the rename + recreate combination correctly for views. When a view is both renamed (
-- pista:renamed-from) and routed throughDROP + CREATEbecause its column list is changing,detectViewRenameshas already rewritten the current map to the new key, so the previous code emittedDROP VIEW <new-name>(which doesn't exist yet in the database) and scheduled theALTER VIEW ... RENAMEwith creates; drops run before creates perorderStatements, so the plan failed before the rename could move the row.detectViewRenamesnow also returns arenamedFrom(new-key -> old-key) map;DiffViewsbuilds aneedsRecreateRenamedset, filters those renames out ofCreateStmts, and emits theDROPagainst the old name. The subsequentCREATEunder the new name materialises the view in its target form. The denied-drop branch (--allow-dropforbids the view drop) uses the same rename-aware target so the-- skipped: DROP VIEW <old-name>;comment points at the relation that exists. (#224)
[1.9.1] - 2026-05-16¶
- Add
$PISTA_PAGERto forwardplan/apply/dumpoutput through an external command when stdout is a TTY. The command is interpreted bysh -c, so quoting and shell metacharacters ('less -R','source-highlight -s sql -f esc | less -R') work as expected. Pipes and redirects (pista dump > file.sql,pista dump | grep ...) are unaffected because the pager only activates on a real terminal. A new--no-pagerflag suppresses paging for a single invocation. Behavior matches thePAGERconvention used bygit,psql,man, etc.
[1.9.0] - 2026-05-15¶
- Apply the same cast-stripping /
IN<->ANY(ARRAY[...])fix to view body comparisons.equalViewDefpreviously only normalised whitespace and stripped schema / column qualifications, so every view containing anIN (...)clause or comparing a typed (notably enum-typed) column against a bare literal showed false drift on everypista apply:pg_get_viewdefstoresWHERE x IN ('a','b')asWHERE x = ANY (ARRAY['a','b']), and storesWHERE status = 'published'against an enum column asWHERE status = 'published'::post_status.equalViewDefnow descends into every expression-bearing position of a view'sSELECTbody: target list,WHERE,HAVING,GROUP BY,ORDER BY,LIMIT/OFFSET,JOIN ... ON, CTE bodies,UNIONarms, sub-SELECTs inFROM, and SubLinks (EXISTS/ IN-subquery / ANY-subquery). It runsnormalizeCheckExprfor symmetric= ANY(ARRAY[...])->IN (...)and text-cast cleanup, plusalignCurrentCaststo strip TypeCasts present only on the current side. Casts the user explicitly wrote in desired are preserved (so an intentional'x'::ethat disagrees with the DB still surfaces).normalizeCheckExprandalignCurrentCaststhemselves gained aSubLinkcase so semantically-equivalent RLS policyUSING (id IN (SELECT ...))forms also no longer surface as drift; CHECK / DEFAULT / generated-column / index-predicate callers cannot emit SubLinks in PostgreSQL, so behavior there is unchanged.
[1.8.0] - 2026-05-15¶
- Surface expression changes on
GENERATED ALWAYS AS (<expr>) STOREDcolumns as a plan-time error. The existing toggle check (cannot toggle GENERATED; DROP COLUMN + ADD COLUMN is required) already errored when one side was generated and the other wasn't; an expression-only change on a column that's generated on both sides was previously silently skipped because catalog renders the expression with pg_get_expr-added casts (e.g.price * (quantity)::numeric) that didn't reliably compare with the user's raw expression (price * quantity). The new check usesequalSelectExpr: same asymmetric strip +Sval->numeric coercion as the cast-stripping series, without DEFAULT's symmetric top-level cast strip that would otherwise hide a top-level cast or cast-target-type change on the generated expression. (The same helper now backs the RLS USING / WITH CHECK comparison too; the previousequalPolicyExprwas renamed toequalSelectExprbecause the body was never policy-specific and only the call sites differed.) On a real difference the diff errors withcannot change GENERATED expression; DROP COLUMN + ADD COLUMN is required(PostgreSQL has noALTER COLUMN ... SET GENERATED AS (expr)in-place form). Drift on a column that's generated on both sides with the same expression (including the common cast-asymmetric case) continues to produce no plan. - Apply the same cast-stripping fix to index definitions.
equalIndexDef's normaliser only canonicalised sort / nulls / schema; partial-indexWHEREclauses and expression-index bodies received explicit casts frompg_get_indexdef(e.g.WHERE (occurred_at >= '2020-01-01'::date),(lower((name)::text))) that the user-written form typically omits, so everypista applyre-emitted the index DROP + CREATE.equalIndexDefnow parses both sides into anIndexStmt, runsnormalizeCheckExprover theWhereClauseand eachIndexElem.Exprfor symmetric paren / text-cast /IN<->ANYcleanup, and runsalignCurrentCastsat the same positions for asymmetric non-text cast strip (with theSval->numeric coercion from #203 when the desired side is a bare numeric literal). The pre-existing schema-clearing and sort / nulls canonicalisation continues to apply. - Apply the same cast-stripping fix to RLS policy
USING/WITH CHECKexpressions.equalSelectExpralready reusednormalizeCheckExprfor symmetric paren and text-cast cleanup, but bare literals on non-text columns (e.g.USING (start_at >= '00:00:00')on atimecolumn) still diffed against the DB'sstart_at >= '00:00:00'::time without time zoneform and emitted a redundantALTER POLICYon everypista apply.equalSelectExprnow feeds both parsed sides throughalignCurrentCastsafternormalizeCheckExpr(same pipeline asequalConstraintDef), so non-text temporal / numeric casts present only on the current side are stripped (and stringSvalcoerced back to numericIval/Fvalwhen the desired side is a bare numeric literal). Negative integer literals like'-40'::integerand arbitrary-precision numeric'1e400'::numericwork the same as in CHECK and DEFAULT contexts. - Apply the same cast-stripping fix to column / domain
DEFAULTcomparisons.equalDefaultpreviously calledproto.Equalon parsed default expressions and the bundledparseDefaultonly stripped a top-levelTypeCast, sufficient forDEFAULT 'unknown'=='unknown'::textbut broken for any nested cast (e.g.DEFAULT (now() - '18 years'::interval)) and for negative numeric literals wherepg_get_expremits'-100'::integeragainst the user's bare-100. Existing fixtures had been pre-casting in init SQL (DEFAULT 'unknown'::text) to dodge the bug.equalDefaultnow parses both sides, strips top-levelTypeCaston each (with aSval->numeric coercion when the peer is a numericA_Const, so0::integer==0keeps round-tripping), runsnormalizeCheckExprfor symmetric text-cast / paren /INnormalisation, and runsalignCurrentCastsfor nested asymmetric strips. Verified end-to-end against AdventureWorkshumanresources.employeewhere a desired form with bothCHECKandDEFAULTcasts stripped produces no plan. - Replace the test-helper
ptr[T any](v T) *Twith Go 1.26's expression-formnew(value)acrossdiff/*_test.go. golangci-lint'snewexprcheck had been flagging every call site; dropping the helper removes the wrapper noise without changing any behavior. - Extend the CHECK constraint cast-stripping fix below to numeric literals on the current side.
pg_get_constraintdefemits negative numeric literals as a string-cast pair ('-40'::integer) to dodge the unary-minus precedence trap that bare-40::integerwould hit, while users write the bare form-40(which pg_query parses toA_Const{Ival: -40}, notTypeCast(Sval, integer)). Stripping the cast on the current side previously left anA_Const{Sval "-40"}that still deparsed to'-40'and diffed against the user's-40.alignCurrentCastsnow also coerces the surviving string A_Const back to a numeric A_Const (Ival within int32, Fval otherwise), but only when the desired side at the same position is itself a numeric (non-string) A_Const, so users who explicitly wrote'0'in a CHECK still match a quoted current form. Covers the AdventureWorkshumanresources.employee.CK_Employee_VacationHoursshape (vacation_hours >= '-40'::integer) and similar bigint / numeric / real / double precision cases. - Fix CHECK constraint idempotency when the live DB definition carries type casts the user didn't write.
pg_get_constraintdefadds explicit casts (e.g.'00:00:00'::time without time zone,'0'::integer) to literals and expressions whose target type is inferable from the column; user-written CHECK clauses typically omit them.equalConstraintDefpreviously parsed both sides and compared the deparsed strings independently, so a desiredCHECK (start_at >= '00:00:00')against a currentCHECK (start_at >= '00:00:00'::time without time zone)never compared equal and a redundantDROP CONSTRAINT+ADD CONSTRAINTpair was emitted on everypista apply. The comparison is now asymmetric with respect to casts: a newalignCurrentCastswalks the desired and current expression ASTs in parallel and strips anyTypeCaston the current side at a position where desired has no corresponding cast. Casts the user explicitly wrote in desired are preserved (so an intentional'0'::integerthat disagrees with the DB still surfaces), and casts present on both sides compare normally; only the "current has extra cast" asymmetry introduced bypg_get_constraintdefis collapsed. Applies to all CHECK contexts: column-level, table-level, andDOMAINconstraints, and acrossAND/OR,CASE,COALESCE,IN/= ANY(ARRAY[...]), and bareARRAY[...]subexpressions.
[1.7.3] - 2026-05-13¶
- Print a
-- Connected to ...comment above the existing header inplan/apply/dumpoutput so it's clear which database the command ran against. TCP connections render as a libpq URI (postgres://<user>@<host>:<port>/<dbname>, with IPv6 hosts bracketed and user/dbname URL-escaped); unix-socket connections render as keyword/value form (host=<path> dbname=<dbname> user=<user>) to keep the socket path readable. The password (whether embedded in--conn-stringor passed via--password/PISTA_PASSWORD) is never included.
[1.7.2] - 2026-05-13¶
- Add
-d/--dbnameflag (envPISTA_DBNAME) that overrides the dbname embedded in--conn-string. Lets users keep host/user/port constant in-c postgres://user@host:port/while selecting the database name ad hoc per invocation, mirroring thepsql -dconvention. If-dis omitted, existing behavior is unchanged: pgx parses dbname from--conn-string, falling back to the connecting user name when absent. (#188)
[1.7.1] - 2026-05-13¶
- Reject
dump --splitfilenames that would escape the target directory or alias other entries on disk. PostgreSQL quoted identifiers may contain/,.., or absolute paths (CREATE TABLE "../escape" (...)parses), and the existingfileNameReplaceronly stripped"and substituted_for spaces before passing the result tofilepath.Join, which does not prevent traversal. A hostile DB object name could therefore land a file outside the directory passed to--split, and even a non-escaping but non-canonical name likefoo/../bar.sqlcould silently overwrite a siblingbar.sqlsinceDumpResult.Files()' dedup runs on the original map key. Each name is now required to be a flat basename: any path separator (/or\) is refused outright,filepath.IsLocalrejects..path elements / absolute paths / empty strings / Windows-reserved names, and a canonical-form check (name == filepath.Clean(name)) refuses any./,foo/../bar, double-slash, or trailing-slash shapes. Identifiers that legitimately contain literal dots (foo..bar,..leading,public.v1.0) still write through. Realistic exploit conditions are narrow (requiresCREATEprivilege on the target database) but the guard closes the traversal and symlink-redirect paths and keeps the on-disk filename equal to the map key. (#186)
[1.7.0] - 2026-05-12¶
- BREAKING: Rename the CLI binary from
pisttopista, environment variable prefix fromPIST_toPISTA_(includingPISTA_CONN_STR,PISTA_PASSWORD,PISTA_SCHEMAS,PISTA_PRE_SQL,PISTA_PRE_SQL_FILE,PISTA_CONCURRENTLY_PRE_SQL,PISTA_CONCURRENTLY_PRE_SQL_FILE,PISTA_DISABLE_INDEX_CONCURRENTLY,PISTA_BULK_ALTER,PISTA_ALLOW_DROP,PISTA_INCLUDE,PISTA_EXCLUDE,PISTA_ENABLE,PISTA_DISABLE), and SQL comment directives from-- pist:to-- pista:(the three directives-- pista:renamed-from,-- pista:execute,-- pista:concurrently). Old names are no longer recognized. Existing user SQL files and environment / CI configurations must be updated to the new names before upgrading.
[1.6.2] - 2026-05-12¶
- Fix typmod placement when deparsing
timestamp(N)/timestamptz(N)/time(N)/timetz(N)column types and DOMAIN base types.pg_query's deparse emitstimestamp without time zone(6)fortimestamp(6) without time zone(and the equivalent misorder for the other threetimestamp/timevariants), which is invalid SQL:pist planagainst any schema with a precision-bearingtimestamp/timecolumn produced statements PostgreSQL rejects, and falsely reported drift against catalog state sincepg_catalog.format_type()returns the canonical order. Pistachio now formats these fourtimestamp/timevariants directly from theTypeNameAST (Names/Typmods/ArrayBounds), so the precision lands between the bare type and thewith/without time zonequalifier and explicit array bounds liketimestamp(6)[3]are preserved instead of being collapsed to[]. (#177)
[1.6.1] - 2026-05-10¶
- Fix
toposortto honor PostgreSQL's defaultsearch_pathfallback topublicwhen resolving unqualified references from a non-public schema. Previously aCREATE TABLEwhose column referenced an unqualified DOMAIN/ENUM inpublic, an FK pointing at an unqualifiedpublictable, or a view body selecting from an unqualifiedpublictable was treated as having no dependency edge, so the dependent statement could be ordered before its prerequisite andpist applywould fail withtype "..." does not exist(and similar). The schema component is also now run throughmodel.Identso quote-requiring schemas (e.g."MySchema") match the keys used by the dependency graph. (#172)
[1.6.0] - 2026-05-09¶
- Internal cleanup with no user-visible behavior change: shrink the public API surface of
parser,toposort,diff, andinternal/pgastby unexporting helpers only reachable from same-package tests; delete dead code (ParseSQL*wrappers,ExtractColumnDirectives,normalizeDirectiveValue, the raw-SQL toposort path); and add amake deadcode/ CI check (golang.org/x/tools/cmd/deadcode) with//deadcode:keepmarker support to prevent regressions. (#160, #161, #162, #163, #164, #165, #166, #167, #168, #169) - Round-trip named NOT NULL constraints on PG18. Inline
CONSTRAINT <name> NOT NULLis now captured by the parser, read frompg_constraint(contype='n') bycatalog.ListColumnsByTable, rendered inCREATE TABLE/ADD COLUMN, and renamed viaALTER TABLE ... RENAME CONSTRAINTwhen both sides are NOT NULL with explicit but different names. PG18's auto-generated<table>_<col>_not_nullnames are stripped on read so unnamed declarations round-trip cleanly across column and table renames. Adding or removing a name on an already-NOT-NULL column requires PG18's standaloneALTER ... ADD CONSTRAINT NOT NULLsyntax (not yet supported bypg_query_go) and is a documented no-op in v1; PG<18 silently drops user-supplied names at apply time. (#157)
[1.5.2] - 2026-05-09¶
- Fix
Constraint.Columnsreturned bycatalog.ListConstraintsByTableto contain only the columns owned by each constraint. Thecolumn_tCTE previously grouped byattrelid, so every constraint on a table received the union of all sibling constraints'conkeycolumns; on PG18 the additionalcontype='n'rows compounded this with duplicates. Latent since the first commit because no diff/dump path consumed the field, but the catalog now reports the correct per-constraint column list. (#158)
[1.5.1] - 2026-05-06¶
- Add PostgreSQL 18 support. PG18 represents per-column NOT NULL as first-class
pg_constraintrows (contype='n'); pistachio now filters them incatalog.ListConstraintsByTablesince NOT NULL is already surfaced viapg_attribute.attnotnull. Without this filter the diff produced spuriousALTER TABLE ... DROP CONSTRAINT <col>_not_nullstatements that PG18 rejects withcolumn "X" is in a primary key (SQLSTATE 42P16). CI now exercises PG15/16/17/18. (#151, #152)
[1.5.0] - 2026-05-05¶
- Add
--bulk-alter(also$PIST_BULK_ALTER) to combine consecutiveALTER TABLEactions on the same table into a single multi-line statement, reducing metadata-lock churn. Foreign keys,RENAME,VALIDATE CONSTRAINT, RLS toggles, and skipped DROPs are kept as separate statements so semantically distinct operations preserve their independence (NOT VALIDexists to defer the validation step). (#147) - Change
dump --splitstdout shape: replace the per-file path listing with a-- Dump of <schema> (<summary>)header followed by a-- Wrote N file(s) to <dir>footer, matching the non-split mode header style. Empty schemas now produce visible output instead of being silent. This is a behavior change: anything piping the previous per-path listing intoxargs(or similar) needs to enumerate the directory itself. (#148)
[1.4.0] - 2026-05-05¶
- Propagate the caller's
context.ContextthroughClient.connect, so timeout / cancellation on thectxpassed toPlan/Apply/Dumpis honored at the connection establishment phase instead of being silently discarded. (#139) - Detect and emit DDL for
GENERATED ... AS IDENTITYcolumn transitions.none -> identity,identity -> none, andALWAYS <-> BY DEFAULTnow produce the appropriateALTER TABLE ... ALTER COLUMN ADD/DROP/SET GENERATEDstatements with required preconditions (DROP DEFAULTfor columns with an explicit default orserial/bigserial/smallserialtype,SET NOT NULLbeforeADD IDENTITY,DROP NOT NULLafterDROP IDENTITYwhen desired is nullable). Previously these toggles were silently ignored, leaving the schema drifted. (#140) - Match foreign key dependency lookups against quoted map keys in
toposort, so tables whose name or schema requires quoting (uppercase, reserved words, special characters) get their FK edges registered and DDL is emitted in the correct order. (#141) - Drop the raw-form fallback in the schema-replacer pair list. Only the canonical
model.Identform is added, so a--schema-mapentry for a schema literally nameda.bno longer collides with three-part column references likea.b.col(schemaa, tableb, columncol). (#142) - Use unqualified keys in
OmitSchemadump helpers, so the in-memorytables()/views()/enums()/domains()maps used to renderString()/Files()are self-consistent (key matches the value's schema-stripped state). (#143) - Validate that
Options.Schemasis non-empty and contains no empty / whitespace-only entries at the top ofPlan/Apply/Dump, returning a clearpistachio:-prefixed error to library callers instead of relying on a downstream catalog ormodel.Identfailure. (#144)
[1.3.0] - 2026-05-01¶
- Add row-level security (RLS) and policy support. Parser, catalog, diff, and dump now handle
ALTER TABLE ... ENABLE/DISABLE/FORCE/NO FORCE ROW LEVEL SECURITYandCREATE/ALTER/DROP POLICY. Policies are modeled as table-subordinate so they share the table's lifecycle, dump ordering, and--allow-dropsemantics. Diff emitsALTER POLICYfor in-placeTO/USING/WITH CHECKchanges,DROP+CREATEforCommand/Permissivechanges or clause removals, andALTER POLICY ... RENAME TOvia the existing-- pist:renamed-fromdirective.--allow-dropnow acceptspolicy. Schema map and--omit-schemarewrite policy schema and expression references. (#136)
[1.2.0] - 2026-04-29¶
- Rewrite column references in same-table indexes, constraints, and foreign keys when a column is renamed via
-- pist:renamed-from, so a singleALTER TABLE ... RENAME COLUMNis emitted without redundantDROP/CREATEon dependents. Covers regular / composite / partial / expression /INCLUDE/ GiST indexes,UNIQUE/PRIMARY KEY/CHECK/EXCLUDEconstraints, and same-table FKs. (#123) - Track column comments across
-- pist:renamed-fromrenames: comment changes (including drops) on a renamed column are now detected, and unchanged comments no longer emit a redundantCOMMENT ON COLUMNstatement. (#123) - Validate column references in desired schema at plan time: indexes, constraints (CHECK / UNIQUE / PK / EXCLUDE), and foreign keys (local side) whose definitions reference columns absent from the owning table's desired column set are reported as a single aggregated error before any DDL is executed. Catches the common mistake of renaming a column via
-- pist:renamed-fromwhile forgetting to update the dependent definition. (#124) - Fix
GENERATED ALWAYS AS (<expr>) STOREDcolumn handling: parsed desired columns now correctly retain the GENERATED form (previously emitted asDEFAULT <expr>), and no-diff plans on generated columns no longer produce a spuriousALTER COLUMN ... SET DEFAULT(which PostgreSQL rejects on generated columns). (#125) - Reject GENERATED toggles at plan time: changing a column between generated and non-generated now errors with
cannot toggle GENERATED; DROP COLUMN + ADD COLUMN is requiredinstead of silently emitting no DDL. (#125)
[1.1.0] - 2026-04-28¶
- Add
--concurrently-pre-sql/--concurrently-pre-sql-fileoption to run SQL (e.g.SET lock_timeout) before CONCURRENTLY index DDL. (#121)
[1.0.0] - 2026-04-28¶
- Initial release.