Changelog¶
[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.