Skip to content

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 run CREATE 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 with failed to acquire the apply exclusion: ERROR: deadlock detected, with the other apply still making progress. The wait now retries pg_try_advisory_lock once 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-from under 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-from planned a drop and a create instead of a rename, -- pista:ignore left the object managed, and -- pista:execute left the statement to the ignored-statement warning instead of running it at apply. -- pista:concurrently, -- pista:bulk-alter and 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 GRANT appears in more than one of them. It now reads pistachio: 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. plan and apply opened 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-file and --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: id said neither which table the column was on nor where the file repeats it. It now reads duplicate column: id on public.users and 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_id of a tree, was added to the graph as a self-edge and read back as a cycle. plan and apply fall 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-deps treats 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 ( ... ) after AS IDENTITY. START WITH, INCREMENT BY, MINVALUE, MAXVALUE, CACHE and CYCLE were read by neither side, so a file that wrote them was applied at the defaults and dump dropped them. A change goes out as ALTER TABLE ... ALTER COLUMN ... SET, and an option the desired schema no longer names is reset to its default. dump writes 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: no SET or RESET is planned, the WITH clause a file writes is left off the CREATE TABLE, and dump does 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-wait to apply. Opted-in apply runs on one database become mutually exclusive: --exclusive fails at once while another exclusive apply is running, --exclusive-wait waits for it up to the given duration (0 waits 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-tx and CONCURRENTLY index DDL do not affect it, and DDL from any other source is not blocked.

[1.37.0] - 2026-08-31

  • Stop BETWEEN, LIKE and NOT IN from drifting. The grammar rewrites BETWEEN into the comparisons it stands for, LIKE / ILIKE / SIMILAR TO into their operators, NOT IN into <> ALL (ARRAY[...]) and (a, b) into ROW(a, b) before the expression reaches the catalog, so a desired schema that wrote the keyword never matched what came back: a CHECK was 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 domain CHECK, a column DEFAULT, a policy USING / WITH CHECK and a trigger WHEN clause get them too. IN already folded against the = ANY (ARRAY[...]) it is stored as; that fold now matches the operator as well, so x > ANY (...) and x > 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, so dump dropped 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 as ALTER TABLE ... SET (...) with a RESET for the parameters the desired schema no longer names. A toast. 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_indexdef quotes 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_viewdef and pg_get_triggerdef all print a call unqualified once the function's schema is on the search_path, while the desired side keeps what the file wrote, so CHECK (public.lower_v(v) <> 'x') and every other call written that way re-emitted its statement on every plan. A CHECK was 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 a nextval('public.counter'::regclass) literal is not a call name and still drifts; TODO.md covers it.

  • Name the same pair every run when -m maps 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 DROP and ADD rather than RENAME, and the RENAME was suppressed by matching the rendered statement as a substring. With a renamed to b alongside xa renamed to by on one table, the needle for the first matched the statement for the second, so xa was 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:execute check under the target schemas in plan, as apply already did. plan ran the check under the connection's search_path, which is public by default, so a check reading a table in another target schema failed there and the statement was reported as undetermined, while apply answered the check and skipped it. The plan advertised a statement that never ran. The SET goes 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, an OVER clause or an aggregate FILTER went 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.counter sitting in a file planned with -n public was 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 TO statements 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 a DROP TABLE for every partition, and -E only reached names that carry a pattern. With the flag dump writes the parent alone, and plan / apply skip a partition on both sides of the diff, including the indexes, policies and comments it owns. The parent stays managed, and PostgreSQL carries ADD COLUMN and 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. An INHERITS child carries no partition bound and stays managed.

[1.33.0] - 2026-08-28

  • Check the column references in a DEFAULT or GENERATED expression 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 plain DEFAULT may 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-from rewrote 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 trigger UPDATE OF list or WHEN expression, and a policy USING / WITH CHECK clause, read as a definition change, so the plan carried a CREATE OR REPLACE TRIGGER or an ALTER POLICY next to the rename. PostgreSQL applies RENAME COLUMN to all of them, so neither statement changed anything, and the trigger one took a SHARE ROW EXCLUSIVE lock to do it. A stored generated expression was worse than noise: it cannot be altered in place, so the run failed with cannot change GENERATED expression on 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 FROM from drifting. PostgreSQL stores the operator as the negation of IS DISTINCT FROM, so a desired schema that wrote it never matched the catalog: a CHECK was 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 policy USING / WITH CHECK, a trigger WHEN clause and a domain CHECK get it too. A generated column did not drift but failed the run, since a generated expression cannot be altered in place.

  • Stop the DISTINCT operators against NULL from drifting. Parse analysis rewrites a IS NOT DISTINCT FROM NULL to a IS NULL before the expression is stored, so the catalog handed back a test where the desired side kept the operator, and the CHECK was 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 policy USING / WITH CHECK, a trigger WHEN clause, a domain CHECK, a column DEFAULT and 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, so NOT (a IS NOT NULL) and a IS NULL compare as one. A row constructor or tbl.* 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.attstorage and attcompression were read by neither side, so dump dropped a column's STORAGE EXTERNAL and a plan never proposed either setting. Both are read now, from the column definition (body text STORAGE external COMPRESSION lz4) and from the ALTER TABLE ... ALTER COLUMN ... SET STORAGE / SET COMPRESSION a pg_dump file carries. dump writes them as separate statements, which is what PostgreSQL 15 accepts and what pg_dump writes. 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_EXCLUDE and the config file carry both forms.

  • Warn about an ALTER TABLE action or a COMMENT ON target the parser drops. Both have a parser case of their own, so neither reached the ignored unsupported statement warning: a column added by ALTER TABLE ... ADD COLUMN was absent from the desired schema and the plan proposed dropping it from the database, and ALTER COLUMN ... SET DEFAULT / SET NOT NULL / TYPE went the same way. The warning follows what the parser reads, so an action or target added later warns instead of vanishing. An ALTER TABLE naming 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:execute keeps working and plan output is unchanged. With the flag, pg_proc is read, dump writes 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 with CREATE OR REPLACE; the ones PostgreSQL refuses in place run as DROP and CREATE, gated by --allow-drop routine. routine also joins the --enable / --disable type list, and -- pista:ignore works on a CREATE FUNCTION or CREATE 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 TRIGGER and INSTEAD OF triggers on views are read from pg_trigger, written by dump, and diffed. A definition change uses CREATE OR REPLACE TRIGGER; switching a trigger to or from a constraint trigger runs as DROP and CREATE, gated by --allow-drop trigger. The enable state is carried as ALTER TABLE ... ENABLE/DISABLE TRIGGER, and -- pista:renamed-from renames a trigger. The function a trigger calls stays unmanaged, so it belongs in a -- pista:execute-first statement.

  • Keep a constraint trigger out of the table's constraints. CREATE CONSTRAINT TRIGGER also writes a pg_constraint row, and the catalog read it as a table constraint. dump then emitted CONSTRAINT x TRIGGER ..., which does not parse, and plan proposed 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 hold orderedmap.Map and 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 pass docker run --user.

  • Drop ca-certificates from the Docker image. alpine:3 already ships the bundle Go reads its roots from. update-ca-certificates goes 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_type prints it the way the type writes it back, so a PostGIS geometry(Polygon,4326) column planned SET DATA TYPE geometry(polygon,4326) on every run, and a domain over the same type failed with cannot 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: three apply runs left items_qty_idx, items_qty_idx1 and items_qty_idx2 behind, and the plan never came back empty. The name follows ChooseRelationName: the table, then one name per index element joined with underscores, then _idx, shortened by makeObjectName when it does not fit. An element is named after its column, and an expression after the function it calls ((lower(c)) gives t_lower_idx), a field selection's field, the column under a subscript, a cast's type, or CASE, falling back to expr for anything with no name of its own; the INCLUDE list 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 CHECK on a 59-character table went in as {table}_quantity_check and was stored as {table}_quantit. The next plan did not find that name, so it added the constraint again on every run, and the second apply failed with constraint "..." already exists. PostgreSQL builds the name with makeObjectName, 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 unnamed CHECK is 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.Unlogged was read from the catalog and parsed from the desired schema, and used to write CREATE 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 every ALTER, 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 dump dropping the PARTITION BY of a partition that is itself partitioned. The CREATE TABLE was finished right after the PARTITION OF ... FOR VALUES clause, 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 said PARTITION OF that table. The same code builds the CREATE TABLE a plan emits, so adding such a partition through apply created it unpartitioned and the partition under it failed with is 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_path on every connection, to public unless --search-path (env $PISTA_SEARCH_PATH) says otherwise. The catalog reads the schema through pg_get_viewdef, pg_get_constraintdef, pg_get_expr and format_type, and each drops the schema from an object the session can reach unqualified, so a server-side ALTER ROLE ... SET search_path, or its database-level form, decided what dump wrote and what the diff compared. With a target schema on that path, dump wrote m mood, nextval('counter'::regclass) and FROM users, which reload into whatever schema the next session resolves the bare names to, and a desired schema that qualified its own objects planned SET DEFAULT and DROP CONSTRAINT / ADD CONSTRAINT on every run. The default is public alone 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. apply still sets search_path to the target schemas plus public before 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 in diff/tables.go each carried their own switch, 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, an A_Indirection field name and a type name by acting on ColumnRef alone, and the walk stops at a nested SELECT for 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_ARRAYAGG and the PostgreSQL 17 JSON_VALUE, JSON_QUERY and JSON_EXISTS are all reached now. An unnamed CHECK that reads its only column through one of them was named t_check where PostgreSQL says t_a_check, which planned an ADD CONSTRAINT next to a DROP CONSTRAINT on every run; the desired-schema validation missed a column that does not exist; and a column renamed with -- pista:renamed-from kept its old name inside the expression. A JSON_OBJECT key is an expression too, so JSON_OBJECT(a: b) reaches two columns and takes {table}_check, matching the server.

  • Fix a view keeping its table qualification wherever stripQualifications did not reach. pg_get_viewdef returns a column reference unqualified where the file writes table.column, so a body holding COALESCE, CASE, GREATEST, a subscript, an array or row constructor, IS TRUE, COLLATE, an XML or SQL/JSON expression, an aggregate ORDER BY or FILTER, an OVER clause, DISTINCT ON, GROUP BY ROLLUP or a sub-query nested under any of them re-emitted CREATE OR REPLACE VIEW on every plan and kept plan --check at exit code 2 forever.

  • The plan and apply test harnesses take a min_pg field, 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 x next to CREATE VIEW x passed 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 for PRIMARY KEY, UNIQUE and EXCLUDE constraints 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:ignore are included, since an unmanaged relation still occupies its name. A clash reads as duplicate relation name: public.x (table and view) or duplicate 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 ChooseConstraintName joins every column, so UNIQUE (a, b) is t_a_b_key there and was t_a_key here; multi-column EXCLUDE and foreign keys were off the same way, and an EXCLUDE on an expression now takes PostgreSQL's expr in place of a column. A CHECK is named after the one column its expression references, or {table}_check when 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 an ADD CONSTRAINT under pistachio's name together with a DROP CONSTRAINT for 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 explicit CONSTRAINT clause 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 unnamed CHECK reaching its column that way lost the column from its generated name, giving t_check where PostgreSQL says t_a_check; the desired-schema validation missed a column that does not exist; and a column renamed with -- pista:renamed-from kept its old name inside the expression, so the plan carried a needless DROP CONSTRAINT and ADD CONSTRAINT next to the RENAME COLUMN.

  • Fix an unnamed ALTER TABLE ... ADD FOREIGN KEY reaching the plan with no constraint name, as ADD 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. Constraints and ForeignKeys are 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 to CONSTRAINT c FOREIGN KEY ... on one table, two CHECK constraints under one name on a domain, CREATE TYPE ct AS (n integer, n integer) and CREATE TYPE et AS ENUM ('a', 'a') all passed the parser and failed at apply. A clash reads as duplicate constraint name: c on public.t (CHECK constraint and FOREIGN KEY constraint), duplicate attribute: n on public.ct, or duplicate enum value: a on public.et. Note that two unnamed CHECK constraints on one domain now error as well: PostgreSQL names them d_check and d_check1, a suffix pistachio does not predict, so both come out as d_check. Unnamed table constraints already behaved this way.

[1.24.0] - 2026-08-05

  • Add dump --sort-by-deps to 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 with psql without 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, or CREATE EXTENSION. Before, it was dropped from the desired schema without any notice. pistachio now prints pistachio: 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:execute to keep it and silence the warning. A BEGIN/COMMIT warning points at --with-tx and --try-tx.

[1.23.0] - 2026-08-01

  • BREAKING: plan now evaluates -- pista:execute check SQL and leaves out the statements apply would skip. Before, every marked statement was printed regardless, so a file with a false check produced a plan full of SQL while apply on the same file reported -- No changes. Three consequences:

  • plan --check exits 0 instead of 2 when the only pending statements have a false check.

  • A check plan cannot evaluate is undetermined rather than fatal. plan runs 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 during apply. The statement stays in the plan with the reason recorded as a comment, and plan --check still reports a diff. Behavior during apply is unchanged: the check runs at its proper moment, and a failure there stops the run.
  • plan runs the check SQL, which it did not do before. A check with side effects now runs once per plan as well as during apply.

  • 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 a CHECK constraint, a GENERATED expression, an index expression, or a policy can. -- pista:execute is 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 CONSTRAINT but the first being dropped from an ALTER TABLE that adds several in one statement, as --bulk-alter output does. The rest were discarded without an error, so plan offered to drop them from the database. A -- pista:renamed-from directive on such a statement is now an error, because it names one old object.

  • Fix dump emitting a partition's parent, or an INHERITS parent, without its schema, as PARTITION OF events. The statement failed when the parent was not on search_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 own DROP. Note that dump still emits objects in name order, so a parent that sorts after its child is written after it.

  • Fix COMMENT ON TABLE and COMMENT ON COLUMN never 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 serial when a sequence is tied to it by a plain ALTER SEQUENCE ... OWNED BY while its default is something else. The real default was dropped from the dumped definition and no drift was reported. A column now counts as serial only when its default draws from the sequence it owns.

  • Fix ALTER SEQUENCE ... OWNED BY being ignored in the desired schema. The statement now marks the sequence unmanaged, as CREATE SEQUENCE ... OWNED BY already did. Before, every plan proposed creating a sequence that already existed and apply failed with relation "..." already exists. Detaching with OWNED BY NONE is still not supported.

[1.21.1] - 2026-07-31

  • Fix broken DDL for a collation whose name contains a dot, such as the C.utf8 and en_US.utf8 family. The schema-qualified name was split on every dot, so a domain or composite attribute was emitted as COLLATE pg_catalog."C".utf8, which PostgreSQL rejects as a cross-database reference. Collation names are now split on the quoting instead.

  • Fix dump dropping the schema from a column collation. COLLATE mycoll resolved only when the collation's schema was on search_path; the dump now emits COLLATE 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-op SET DATA TYPE on every plan.

  • Fix plan failing with cannot change collation of domain ... when the desired schema writes a domain collation unqualified, such as COLLATE "C" against the catalog's pg_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 TYPE on every plan for a column written with COLLATE "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 CHECK definition being truncated when the expression contains the text CONSTRAINT, as in CHECK (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_name keywords (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: dump produced SQL that could not be parsed again, and plan / apply produced DDL that PostgreSQL rejected with a syntax error. Quoting now follows the grammar's ColId rule, which accepts a bare identifier, an unreserved keyword, or a col_name keyword.

[1.21.0] - 2026-07-31

  • Add --try-tx (env $PISTA_TRY_TX) to apply. It works like --with-tx, except that a diff containing CREATE/DROP INDEX CONCURRENTLY runs 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:concurrently index that is not being changed still gets a transaction. --with-tx is unchanged and the two flags are mutually exclusive. --try-tx can be combined with --force-index-concurrently.

[1.20.0] - 2026-07-30

  • Manage composite types. plan, apply, and dump handle CREATE 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-drop accept composite_type; --allow-drop=composite_type also gates DROP ATTRIBUTE. Attribute reordering is not diffed (PostgreSQL cannot reorder attributes), and ALTER ATTRIBUTE ... TYPE fails 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 TYPE on every plan. The column type is now compared with the type's own schema stripped. Also set search_path to the target schemas plus public before applying managed DDL, so an unqualified reference to a user type or to a public object (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 ignores NOT VALID in the desired schema and never emits NOT VALID or VALIDATE CONSTRAINT. Constraint additions and definition changes still apply, without NOT VALID. Use it when NOT VALID is 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 VALID in the database no longer produces a spurious DROP + ADD on every plan. When its definition matches the desired schema, it is validated with ALTER DOMAIN ... VALIDATE CONSTRAINT instead. --assume-validated also 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, and dump handle CREATE SEQUENCE, ALTER SEQUENCE, DROP SEQUENCE, COMMENT ON SEQUENCE, and rename via -- pista:renamed-from. Only standalone sequences are managed. A sequence owned by a serial or identity column stays with that column and is excluded from the diff, classified by pg_depend.deptype. A sequence referenced by a column default via nextval is created before that table and dropped after. --enable, --disable, --include, --exclude, and --allow-drop accept sequence. The UNLOGGED attribute is not tracked. (#296)

[1.16.1] - 2026-07-18

  • Fix invalid DDL for a single-column UNIQUE / PRIMARY KEY constraint on a column named value. libpg_query dropped the column from the deparsed definition, so plan / apply produced a broken ADD CONSTRAINT ... UNIQUE and reported false drift. The key columns are now restored from the parse tree. (#291)

[1.16.0] - 2026-07-13

  • Open the plan and dump connections in read-only mode, so those commands cannot write to the database even by accident. apply still 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:ignore directive. Put it before a CREATE TABLE / CREATE TYPE ... AS ENUM / CREATE DOMAIN / CREATE VIEW statement 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 --exclude for a single object. Each ignored object is reported as an -- ignored: <name> comment in plan / apply output. The directive takes no arguments.

[1.14.0] - 2026-07-13

  • Add a Windows (amd64) release binary, cross-built with mingw-w64. $PISTA_PAGER is interpreted by cmd /c on Windows. CI builds the binary on windows-latest and runs make test against the runner's preinstalled PostgreSQL.

  • Support enum value renames. Put -- pista:renamed-from <old_value> on the line before a value inside CREATE TYPE ... AS ENUM to emit ALTER TYPE ... RENAME VALUE instead 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) to plan for 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. PlanResult gains a HasChanges field, and the CLI uses it instead of checking SQL for emptiness.

[1.13.0] - 2026-07-03

  • Add the -- pista:bulk-alter directive. Put it before a CREATE TABLE statement to combine that table's consecutive ALTER TABLE actions into a single statement. Other tables keep one statement per action. The --bulk-alter flag (env $PISTA_BULK_ALTER) is unchanged and merges every table. Like -- pista:concurrently, the directive takes no arguments and is ignored on statements other than CREATE TABLE.

[1.12.1] - 2026-06-27

  • Fix dump --omit-schema leaving the schema name in indexes on partitioned tables. pg_get_indexdef emits ON ONLY <schema>.<table> for such indexes, which the schema-stripping replacement did not match, so public. stayed in the output.

[1.12.0] - 2026-06-24

  • Print the apply phase duration at the end of pista apply as 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) to plan and apply. Forces CONCURRENTLY on every CREATE INDEX / DROP INDEX the diff emits, including pure drops (indexes removed from desired) that the -- pista:concurrently directive cannot reach because catalog-derived indexes do not carry the flag. Conflicts with --disable-index-concurrently on both subcommands and with --with-tx on 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 required and cannot change GENERATED expression; DROP COLUMN + ADD COLUMN is required. (#242)

[1.10.5] - 2026-05-22

  • Trim leading / trailing whitespace from each --include / --exclude pattern in FilterOptions.AfterApply. Multi-value env vars like PISTA_INCLUDE="user*, posts" were parsed by kong into ["user*", " posts"], and the leading space made path.Match treat the second pattern as posts, silently matching nothing. The trim runs before ValidatePatterns, so the post-trim values are also what gets validated and what MatchName compares against.

[1.10.4] - 2026-05-17

  • Bind apply --with-tx to the $PISTA_WITH_TX environment 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_CONCURRENTLY env-tag pattern on the other apply bool flags.

[1.10.2] - 2026-05-17

  • Emit -- Transaction started / -- Transaction committed / -- Transaction rolled back comments in apply --with-tx output so the transaction boundary is visible in the SQL stream. The CLI now also flushes buffered apply output when client.Apply returns an error, so partial DDL that ran before the failure plus the -- Transaction rolled back marker 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-pager negatable: the flag is now --[no-]pager, and --pager forces 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_PAGER still determines which command to run, so --pager with an unset PISTA_PAGER remains a no-op. Internally command.StartPager now takes *bool (nil = auto / TTY check, true = force, false = disable) instead of a plain bool.

[1.10.0] - 2026-05-17

  • Fix DROP COLUMN ordering 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 during DROP COLUMN, so a subsequent explicit DROP CONSTRAINT / DROP INDEX fails with ... does not exist; RLS policies and same-table stored-generated columns block DROP COLUMN outright. diff.diffColumns now returns column drops in a separate slice (emitting stored-generated columns before non-generated ones so a paired generated_col -> source_col drop succeeds without CASCADE) and diff.diffTable appends colDropStmts at the tail of result.Stmts, after policy / index / constraint / comment / RLS handling. FK drops continue to emit first via FKDropStmts, so dependents are gone before the column itself goes. The bulk-alter merge naturally inherits the new order, so a single ALTER TABLE lists DROP CONSTRAINT before DROP COLUMN. (#223)
  • Use DROP VIEW + CREATE VIEW for regular view definition changes that remove, rename, or reorder output columns. PostgreSQL's CREATE OR REPLACE VIEW requires 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 raises cannot drop columns from view. The same definition change also blocks an accompanying DROP COLUMN on a referenced table because the view's dependency on that column hasn't been cleared yet. diff.canCreateOrReplaceView parses both view bodies via pg_query, extracts the output column names from the top-level target list (recursing into the left arm for UNION / INTERSECT / EXCEPT set 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 existing viewDiff.DropStmts + CreateStmts pre-drop sequencing so the subsequent DROP COLUMN succeeds. Views whose output columns can't be determined statically (SELECT *, target-list expressions without an alias, parse error) are conservatively routed through DROP + CREATE, which avoids the rejection that the prior CREATE OR REPLACE would 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 as CREATE OR REPLACE-able because the names line up, but PostgreSQL still rejects them with cannot change data type of view column; pg_query doesn'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 through DROP + CREATE because its column list is changing, detectViewRenames has already rewritten the current map to the new key, so the previous code emitted DROP VIEW <new-name> (which doesn't exist yet in the database) and scheduled the ALTER VIEW ... RENAME with creates; drops run before creates per orderStatements, so the plan failed before the rename could move the row. detectViewRenames now also returns a renamedFrom (new-key -> old-key) map; DiffViews builds a needsRecreateRenamed set, filters those renames out of CreateStmts, and emits the DROP against the old name. The subsequent CREATE under the new name materialises the view in its target form. The denied-drop branch (--allow-drop forbids 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_PAGER to forward plan / apply / dump output through an external command when stdout is a TTY. The command is interpreted by sh -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-pager flag suppresses paging for a single invocation. Behavior matches the PAGER convention used by git, psql, man, etc.

[1.9.0] - 2026-05-15

  • Apply the same cast-stripping / IN<->ANY(ARRAY[...]) fix to view body comparisons. equalViewDef previously only normalised whitespace and stripped schema / column qualifications, so every view containing an IN (...) clause or comparing a typed (notably enum-typed) column against a bare literal showed false drift on every pista apply: pg_get_viewdef stores WHERE x IN ('a','b') as WHERE x = ANY (ARRAY['a','b']), and stores WHERE status = 'published' against an enum column as WHERE status = 'published'::post_status. equalViewDef now descends into every expression-bearing position of a view's SELECT body: target list, WHERE, HAVING, GROUP BY, ORDER BY, LIMIT / OFFSET, JOIN ... ON, CTE bodies, UNION arms, sub-SELECTs in FROM, and SubLinks (EXISTS / IN-subquery / ANY-subquery). It runs normalizeCheckExpr for symmetric = ANY(ARRAY[...]) -> IN (...) and text-cast cleanup, plus alignCurrentCasts to strip TypeCasts present only on the current side. Casts the user explicitly wrote in desired are preserved (so an intentional 'x'::e that disagrees with the DB still surfaces). normalizeCheckExpr and alignCurrentCasts themselves gained a SubLink case so semantically-equivalent RLS policy USING (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>) STORED columns 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 uses equalSelectExpr: 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 previous equalPolicyExpr was renamed to equalSelectExpr because the body was never policy-specific and only the call sites differed.) On a real difference the diff errors with cannot change GENERATED expression; DROP COLUMN + ADD COLUMN is required (PostgreSQL has no ALTER 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-index WHERE clauses and expression-index bodies received explicit casts from pg_get_indexdef (e.g. WHERE (occurred_at >= '2020-01-01'::date), (lower((name)::text))) that the user-written form typically omits, so every pista apply re-emitted the index DROP + CREATE. equalIndexDef now parses both sides into an IndexStmt, runs normalizeCheckExpr over the WhereClause and each IndexElem.Expr for symmetric paren / text-cast / IN<->ANY cleanup, and runs alignCurrentCasts at the same positions for asymmetric non-text cast strip (with the Sval->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 CHECK expressions. equalSelectExpr already reused normalizeCheckExpr for symmetric paren and text-cast cleanup, but bare literals on non-text columns (e.g. USING (start_at >= '00:00:00') on a time column) still diffed against the DB's start_at >= '00:00:00'::time without time zone form and emitted a redundant ALTER POLICY on every pista apply. equalSelectExpr now feeds both parsed sides through alignCurrentCasts after normalizeCheckExpr (same pipeline as equalConstraintDef), so non-text temporal / numeric casts present only on the current side are stripped (and string Sval coerced back to numeric Ival / Fval when the desired side is a bare numeric literal). Negative integer literals like '-40'::integer and arbitrary-precision numeric '1e400'::numeric work the same as in CHECK and DEFAULT contexts.
  • Apply the same cast-stripping fix to column / domain DEFAULT comparisons. equalDefault previously called proto.Equal on parsed default expressions and the bundled parseDefault only stripped a top-level TypeCast, sufficient for DEFAULT 'unknown' == 'unknown'::text but broken for any nested cast (e.g. DEFAULT (now() - '18 years'::interval)) and for negative numeric literals where pg_get_expr emits '-100'::integer against the user's bare -100. Existing fixtures had been pre-casting in init SQL (DEFAULT 'unknown'::text) to dodge the bug. equalDefault now parses both sides, strips top-level TypeCast on each (with a Sval->numeric coercion when the peer is a numeric A_Const, so 0::integer == 0 keeps round-tripping), runs normalizeCheckExpr for symmetric text-cast / paren / IN normalisation, and runs alignCurrentCasts for nested asymmetric strips. Verified end-to-end against AdventureWorks humanresources.employee where a desired form with both CHECK and DEFAULT casts stripped produces no plan.
  • Replace the test-helper ptr[T any](v T) *T with Go 1.26's expression-form new(value) across diff/*_test.go. golangci-lint's newexpr check 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_constraintdef emits negative numeric literals as a string-cast pair ('-40'::integer) to dodge the unary-minus precedence trap that bare -40::integer would hit, while users write the bare form -40 (which pg_query parses to A_Const{Ival: -40}, not TypeCast(Sval, integer)). Stripping the cast on the current side previously left an A_Const{Sval "-40"} that still deparsed to '-40' and diffed against the user's -40. alignCurrentCasts now 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 AdventureWorks humanresources.employee.CK_Employee_VacationHours shape (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_constraintdef adds 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. equalConstraintDef previously parsed both sides and compared the deparsed strings independently, so a desired CHECK (start_at >= '00:00:00') against a current CHECK (start_at >= '00:00:00'::time without time zone) never compared equal and a redundant DROP CONSTRAINT + ADD CONSTRAINT pair was emitted on every pista apply. The comparison is now asymmetric with respect to casts: a new alignCurrentCasts walks the desired and current expression ASTs in parallel and strips any TypeCast on 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'::integer that disagrees with the DB still surfaces), and casts present on both sides compare normally; only the "current has extra cast" asymmetry introduced by pg_get_constraintdef is collapsed. Applies to all CHECK contexts: column-level, table-level, and DOMAIN constraints, and across AND/OR, CASE, COALESCE, IN / = ANY(ARRAY[...]), and bare ARRAY[...] subexpressions.

[1.7.3] - 2026-05-13

  • Print a -- Connected to ... comment above the existing header in plan / apply / dump output 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-string or passed via --password / PISTA_PASSWORD) is never included.

[1.7.2] - 2026-05-13

  • Add -d / --dbname flag (env PISTA_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 the psql -d convention. If -d is 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 --split filenames 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 existing fileNameReplacer only stripped " and substituted _ for spaces before passing the result to filepath.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 like foo/../bar.sql could silently overwrite a sibling bar.sql since DumpResult.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.IsLocal rejects .. 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 (requires CREATE privilege 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 pist to pista, environment variable prefix from PIST_ to PISTA_ (including PISTA_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 emits timestamp without time zone(6) for timestamp(6) without time zone (and the equivalent misorder for the other three timestamp / time variants), which is invalid SQL: pist plan against any schema with a precision-bearing timestamp / time column produced statements PostgreSQL rejects, and falsely reported drift against catalog state since pg_catalog.format_type() returns the canonical order. Pistachio now formats these four timestamp / time variants directly from the TypeName AST (Names / Typmods / ArrayBounds), so the precision lands between the bare type and the with/without time zone qualifier and explicit array bounds like timestamp(6)[3] are preserved instead of being collapsed to []. (#177)

[1.6.1] - 2026-05-10

  • Fix toposort to honor PostgreSQL's default search_path fallback to public when resolving unqualified references from a non-public schema. Previously a CREATE TABLE whose column referenced an unqualified DOMAIN/ENUM in public, an FK pointing at an unqualified public table, or a view body selecting from an unqualified public table was treated as having no dependency edge, so the dependent statement could be ordered before its prerequisite and pist apply would fail with type "..." does not exist (and similar). The schema component is also now run through model.Ident so 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, and internal/pgast by unexporting helpers only reachable from same-package tests; delete dead code (ParseSQL* wrappers, ExtractColumnDirectives, normalizeDirectiveValue, the raw-SQL toposort path); and add a make deadcode / CI check (golang.org/x/tools/cmd/deadcode) with //deadcode:keep marker 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 NULL is now captured by the parser, read from pg_constraint (contype='n') by catalog.ListColumnsByTable, rendered in CREATE TABLE / ADD COLUMN, and renamed via ALTER TABLE ... RENAME CONSTRAINT when both sides are NOT NULL with explicit but different names. PG18's auto-generated <table>_<col>_not_null names 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 standalone ALTER ... ADD CONSTRAINT NOT NULL syntax (not yet supported by pg_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.Columns returned by catalog.ListConstraintsByTable to contain only the columns owned by each constraint. The column_t CTE previously grouped by attrelid, so every constraint on a table received the union of all sibling constraints' conkey columns; on PG18 the additional contype='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_constraint rows (contype='n'); pistachio now filters them in catalog.ListConstraintsByTable since NOT NULL is already surfaced via pg_attribute.attnotnull. Without this filter the diff produced spurious ALTER TABLE ... DROP CONSTRAINT <col>_not_null statements that PG18 rejects with column "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 consecutive ALTER TABLE actions 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 VALID exists to defer the validation step). (#147)
  • Change dump --split stdout 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 into xargs (or similar) needs to enumerate the directory itself. (#148)

[1.4.0] - 2026-05-05

  • Propagate the caller's context.Context through Client.connect, so timeout / cancellation on the ctx passed to Plan / Apply / Dump is honored at the connection establishment phase instead of being silently discarded. (#139)
  • Detect and emit DDL for GENERATED ... AS IDENTITY column transitions. none -> identity, identity -> none, and ALWAYS <-> BY DEFAULT now produce the appropriate ALTER TABLE ... ALTER COLUMN ADD/DROP/SET GENERATED statements with required preconditions (DROP DEFAULT for columns with an explicit default or serial/bigserial/smallserial type, SET NOT NULL before ADD IDENTITY, DROP NOT NULL after DROP IDENTITY when 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.Ident form is added, so a --schema-map entry for a schema literally named a.b no longer collides with three-part column references like a.b.col (schema a, table b, column col). (#142)
  • Use unqualified keys in OmitSchema dump helpers, so the in-memory tables() / views() / enums() / domains() maps used to render String() / Files() are self-consistent (key matches the value's schema-stripped state). (#143)
  • Validate that Options.Schemas is non-empty and contains no empty / whitespace-only entries at the top of Plan / Apply / Dump, returning a clear pistachio:-prefixed error to library callers instead of relying on a downstream catalog or model.Ident failure. (#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 SECURITY and CREATE/ALTER/DROP POLICY. Policies are modeled as table-subordinate so they share the table's lifecycle, dump ordering, and --allow-drop semantics. Diff emits ALTER POLICY for in-place TO / USING / WITH CHECK changes, DROP+CREATE for Command / Permissive changes or clause removals, and ALTER POLICY ... RENAME TO via the existing -- pist:renamed-from directive. --allow-drop now accepts policy. Schema map and --omit-schema rewrite 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 single ALTER TABLE ... RENAME COLUMN is emitted without redundant DROP/CREATE on dependents. Covers regular / composite / partial / expression / INCLUDE / GiST indexes, UNIQUE / PRIMARY KEY / CHECK / EXCLUDE constraints, and same-table FKs. (#123)
  • Track column comments across -- pist:renamed-from renames: comment changes (including drops) on a renamed column are now detected, and unchanged comments no longer emit a redundant COMMENT ON COLUMN statement. (#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-from while forgetting to update the dependent definition. (#124)
  • Fix GENERATED ALWAYS AS (<expr>) STORED column handling: parsed desired columns now correctly retain the GENERATED form (previously emitted as DEFAULT <expr>), and no-diff plans on generated columns no longer produce a spurious ALTER 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 required instead of silently emitting no DDL. (#125)

[1.1.0] - 2026-04-28

  • Add --concurrently-pre-sql / --concurrently-pre-sql-file option to run SQL (e.g. SET lock_timeout) before CONCURRENTLY index DDL. (#121)

[1.0.0] - 2026-04-28

  • Initial release.