Skip to content

Changelog

[1.89.0] - 2026-10-20

  • Add -- pista:backfill <value>. It sets the value of the existing rows when a NOT NULL column is added, so ADD COLUMN does not fail on a table with rows.

  • -- pista:retype-using now fails the parse when its argument is not one expression, and drops a trailing comment. The plan writes the expression in pistachio's form, such as code::int for code::integer.

[1.88.1] - 2026-10-19

  • dump no longer writes a serial column widened to bigint as bigserial while its sequence is still integer. It now writes the sequence and the nextval() default.

  • dump --omit-schema now drops the schema from an index on a table, or in a schema, named after a keyword such as position. --schema-map now also maps a schema named after such a keyword.

  • An inline directive followed by a /* ... */ comment before its column or constraint now applies to that element. Before, the directive was ignored.

  • A trigger whose REFERENCING clause names NEW TABLE before OLD TABLE no longer plans a CREATE OR REPLACE TRIGGER on every run.

[1.88.0] - 2026-10-18

  • Add -- pista:refuse-hazard. Written before CREATE TABLE, it does what --refuse-hazard does, for that table and its partitions or INHERITS children only.

[1.87.0] - 2026-10-17

  • Add --refuse-hazard to plan and apply. It refuses a plan that contains a statement of the given kind: rewrite, scan, blocks-reads or blocks-writes. The kinds are those of --explain. A refused plan exits with 3.

[1.86.0] - 2026-10-16

  • Add -- pista:not-null-via-check and --not-null-via-check. They add NOT NULL to an existing column through a temporary CHECK constraint, so the table scan does not block reads and writes. apply --with-tx refuses such a plan. Plan files written by earlier versions are refused.

  • plan now drops a removed CHECK constraint after SET NOT NULL on the same table. Before, the CHECK was dropped first, so a validated CHECK (col IS NOT NULL) could not keep SET NOT NULL from reading every row. --explain no longer shows a scan for such a SET NOT NULL.

[1.85.1] - 2026-10-15

  • diff without --git now exits with 80, the usage error status, when it is not given exactly two files. Before, it exited with 1.

  • apply-from now prints the invalid index warning that plan and apply print.

[1.85.0] - 2026-10-14

  • BREAKING: plan, apply, diff and the other commands that read desired schema files now reject a file whose last statement has no semicolon. Before, such a file was read when it was the last file, and when another file followed it, the statement ran into the first statement of that file and the error pointed at the next file.

  • plan is faster. At 1,000 tables it takes 10 to 25% less time than 1.84.0. The inline directive checks skip a statement with no -- pista: directive, and the line numbers recorded for lint are no longer counted from the start of the file for every object.

  • dump and plan now quote a routine parameter or RETURNS TABLE column named after a keyword such as time, position or values. Before, they wrote the name bare, which is a syntax error there, so the routine could not be created and a dump of it could not be read back.

[1.84.0] - 2026-10-14

  • apply and apply-from take --lock-timeout and --statement-timeout. They set lock_timeout and statement_timeout before the pre-SQL.

  • apply and apply-from take --lock-retries. It runs a schema change again when the change fails because lock_timeout ran out. Only the failed statement runs again. The pre-SQL, -- pista:execute statements and CONCURRENTLY index statements do not run again. --lock-retry-delay sets the wait before each retry. The default is 1 second. --lock-retries cannot be used with --with-tx or --try-tx.

[1.83.1] - 2026-10-12

  • plan and apply now reject a desired table that PARTITION OF or INHERITS makes its own ancestor, such as CREATE TABLE a PARTITION OF a .... Before, plan never finished.

[1.83.0] - 2026-10-11

  • BREAKING: Remove --dsql-ignore-async and PISTA_DSQL_IGNORE_ASYNC. With --engine dsql, plan and apply now always ignore ASYNC in CREATE INDEX ASYNC in the desired schema.

[1.82.1] - 2026-10-11

  • dump now writes the sequence of a serial column with its options when they are not the defaults. Before, it wrote the column as serial, so the restored sequence lost options such as INCREMENT BY and START WITH.

  • A domain is now created after the routines it may call. Before, a domain whose CHECK called a routine created in the same run was created first and failed, as did applying a dump of it to an empty database with --manage-routine.

  • plan now drops a removed or changed constraint before it changes the type of a column. PostgreSQL rebuilds the CHECK constraints on a column whose type changes, so a CHECK the new type rejects failed the change, as when a text column with a CHECK on its values became an enum.

  • DEFAULT NULL on a column or a domain, with or without a cast, no longer appears in the plan on every run where PostgreSQL stores no default for it.

[1.82.0] - 2026-10-11

  • BREAKING: Rename --dsql-no-wait-index-build to --dsql-no-wait-job, and PISTA_DSQL_NO_WAIT_INDEX_BUILD to PISTA_DSQL_NO_WAIT_JOB. It now also skips the wait for a constraint validation.

  • With --engine dsql, plan no longer rejects DROP COLUMN or adding a check constraint. A check constraint or a foreign key is added NOT VALID, then validated with ALTER TABLE ASYNC ... VALIDATE CONSTRAINT, and apply waits for the job.

[1.81.0] - 2026-10-11

  • Amazon Aurora DSQL support: plan, apply, apply-from and dump take --engine dsql for Amazon Aurora DSQL. See the Aurora DSQL guide.

  • pista fmt --dsql-strip-async removes ASYNC from CREATE INDEX ASYNC. plan and apply take --dsql-ignore-async to ignore it in the desired schema under --engine dsql.

  • plan and apply warn about each invalid index, such as one left by a failed CREATE INDEX CONCURRENTLY.

[1.80.1] - 2026-10-09

  • A lint rule can have let, a list of named values. Each item has name and expr, a CEL expression. assert and later items can use the names. The items are evaluated in order when when is true. The standard rules prefer-bigint-key, fk-needs-index and redundant-index in rules/ now use it.

[1.80.0] - 2026-10-09

  • The documentation site no longer publishes the JSON Schema schema-1.3.json. Use schema-1.4.json.

  • parse and dump --json write keys and where for an index. keys lists the key elements as SQL, with their operator classes, collations and sort order. where is the condition of the WHERE clause, or null. The JSON Schema is now schema-1.4.json.

  • The standard rules duplicate-index and redundant-index in rules/ now read keys and where instead of the index definition. duplicate-index now also reports two unique indexes that differ only in NULLS NOT DISTINCT.

[1.79.1] - 2026-10-09

  • plan now finds a change to the table prefix of a column in a view over a join. Before, pistachio removed every prefix before it compared two views. So changing u.id to o.id in a join, or o.user_id = u.id to o.user_id = o.id in a sub-query, planned no change. This applies to materialized views too. A view over a join that leaves a column without its prefix is now replaced on every run. Write the prefix, as dump does.

[1.79.0] - 2026-10-09

  • plan and apply take --evaluate-literals. It compares a string literal with a cast by value, not by text, in the DEFAULT of a column or domain, a generated column and a CHECK constraint. For example, DEFAULT '1 hour' on an interval column no longer plans SET DEFAULT on every run. The literals whose text differs are sent to the server in one query. The option is off by default.

[1.78.1] - 2026-10-07

  • A lint rule can have when, a CEL expression that limits the rule to some objects. The rule checks an object only if when is true.

[1.78.0] - 2026-10-07

  • The documentation site no longer publishes the JSON Schemas schema-1.0.json, schema-1.1.json and schema-1.2.json. Use schema-1.3.json.

  • parse and dump --json write new fields that repeat part of another field in a form that needs no SQL parsing. A column has base_type and is_array. An index has columns, include, unique, method and partial. A foreign key has ref_columns, on_delete, on_update and match. An index, a constraint and a foreign key have auto_named, which is true when the file declares the object without a name. The JSON Schema is now schema-1.3.json.

  • The new command pista lint checks the tables, columns, indexes and foreign keys in schema files against rules written in CEL. --rules names rule files or directories of them. A rule reads the objects as parse writes them. A rule can carry a description that says what it is for. Each object that breaks a rule is printed with its file, line and column, and the exit status is 2. pistachio has no built-in rules. The repository has standard rules in rules/ to copy.

  • The new directive -- pista:lint-ignore [<rule>...] [-- <reason>] turns lint rules off for the table, column, index or foreign key it is written before. With no rule name, it turns every rule off. pista lint warns about a name that no rule has.

[1.77.0] - 2026-10-04

  • pistachio now reads the column names in CREATE VIEW v (x, y) AS ..., and in CREATE MATERIALIZED VIEW. Before, it ignored them, so the view got the column names of its query and appeared in the plan on every run.

  • A call that spreads its arguments over a VARIADIC parameter, such as jsonb_extract_path_text(d, 'a', 'b'), no longer appears in the plan on every run. PostgreSQL stores it as VARIADIC ARRAY['a', 'b']. Before, an index, a constraint, a default, a policy, a trigger or a view that used one was planned again on every run, and a generated column that used one failed the plan.

  • A CASE written without ELSE no longer appears in the plan on every run. PostgreSQL stores it with an ELSE NULL cast to the result type. Before, an index, a constraint, a default, a policy or a view that used one was planned again on every run, and a generated column that used one failed the plan.

  • The new directive -- pista:retype-using <expression> gives the USING expression for a column type change. If the column has a default, pistachio drops it before the change and sets it again after. Before, a type change with no implicit or assignment cast, such as text to an enum, failed at apply.

[1.76.0] - 2026-10-04

  • fmt --strip-renamed-from removes every -- pista:renamed-from directive.

  • With --manage-routine, a routine in a schema whose name needs quoting, such as "App", no longer plans a CREATE OR REPLACE and a DROP on every run when the file qualifies a type in that schema.

  • dump --split now expands a leading ~, as the other path options do.

  • plan --explain now writes may rewrite for a column type change to a type that the same plan creates. Before, the change got no comment.

  • plan --explain now resolves a type in a schema whose name needs quoting, such as "App". Before, a change to such a type got no comment, and a column added with such a constrained domain was not reported as a rewrite.

[1.75.0] - 2026-10-04

  • On PostgreSQL 18, dump now keeps a NOT NULL constraint name after a table or column rename. Before, it dropped every name ending in _not_null, so the restored constraint got a different name.

  • BREAKING: Sequences owned by a column are now managed. Before, pistachio ignored them. With --allow-drop sequence, an owned sequence that is not in the desired schema is now dropped, unless the column uses it as its default. Dumps from earlier versions do not include such sequences.

  • plan and apply now emit ALTER SEQUENCE ... OWNED BY or OWNED BY NONE when the owner of a sequence changes.

  • dump now writes an owned sequence with a custom name, with its options and OWNED BY. Before, it wrote the column as serial, so the restored sequence got the default name and options.

  • dump now keeps the nextval default of a column that is not smallint, integer or bigint.

  • A desired sequence with no owner now detaches a sequence of the same name that a column owns. Before, the plan created it again and apply failed.

  • When a serial column becomes an identity column or gets another default, its old sequence is now dropped. This needs --allow-drop sequence.

[1.74.0] - 2026-10-04

  • BREAKING: A primary key written USING INDEX now needs a CREATE INDEX for its index in the schema files. Without one, reading the files fails with an error.

  • The columns of a primary key written USING INDEX are now read as NOT NULL. Before, a column the file declared nullable got a DROP NOT NULL on every plan, and PostgreSQL rejected it.

  • For a new table, a constraint written USING INDEX is now added after the table's indexes are created. Before, it was written inside CREATE TABLE, which PostgreSQL rejects.

  • Adding an index and a USING INDEX constraint on it to an existing table in one run now creates the index first. Before, the plan had no CREATE INDEX, so the ADD CONSTRAINT failed.

  • dump now writes an index comment right after its CREATE INDEX, rather than with the table's other comments at the end.

[1.73.2] - 2026-10-03

  • --explain now shows the size of a table that a foreign key references without a schema name. Before, it showed only the table name.

[1.73.1] - 2026-10-01

  • LIKE in CREATE TABLE is now an error. Before, pistachio ignored it with a warning, so the plan dropped every column it would copy. A table with only LIKE was created with no columns.

[1.73.0] - 2026-10-01

  • Update pg_query_go to 6.2.5.

  • plan and apply now keep the parentheses in (ARRAY[a, b])[1]. They wrote ARRAY[a, b][1], which PostgreSQL rejects. This affected check constraints, defaults, generated columns, indexes, policies, triggers and views.

  • Renaming a column to value no longer drops and re-creates the unique or primary key constraint on it.

  • ADD CONSTRAINT now quotes a key column named after a keyword such as time, as PostgreSQL does.

  • --version now prints the version of pg_query_go on a second line.

[1.72.1] - 2026-09-30

  • A routine whose body ends in $ is now written in a form that parses. Before, dump, plan and apply wrote its body as $$...$$$.

  • plan no longer fails with cannot change base type of domain for a domain whose base type is in the same schema, when the schema name needs quoting and --search-path includes the schema.

  • An index on a column declared COLLATE "C" that is written as (name COLLATE "C") is no longer dropped and created on every plan.

[1.72.0] - 2026-09-30

  • Add dump --omit-partition-child-index. It leaves out a partition's copy of its parent's index, which PostgreSQL creates again when the dump is loaded. A partition is then added to such a file with CREATE TABLE ... PARTITION OF alone.

  • Adding a partition to a table that has an index no longer fails with relation ... already exists when the file also writes the partition's copy of the index, as dump does. CREATE TABLE ... PARTITION OF creates the copy, so the plan leaves out a CREATE INDEX that has the name PostgreSQL gives the copy and the definition of the parent's index.

[1.71.0] - 2026-09-29

  • Comments on constraints, foreign keys, triggers, policies and domain constraints are now managed. Before, COMMENT ON CONSTRAINT, COMMENT ON TRIGGER and COMMENT ON POLICY in a schema file were ignored with a warning, and dump did not write them.

  • The plan file version is now 5. Run plan --out again for a plan file an older pista wrote.

  • The JSON Schema is now schema-1.2.json. It adds comment to a constraint, a foreign key, a trigger, a policy and a domain constraint.

  • A USING INDEX constraint that keeps the index's own name, or names none, no longer fails with duplicate relation name.

  • The comment on a table, view or column is now read only from comments on relations. After OID wraparound, a comment on another kind of object that shared the OID could be read as the table's.

  • The error for a --pre-sql-file or --concurrently-pre-sql-file that cannot be read, and for a dump --split file that cannot be written, no longer names the file twice.

  • The -- Connected to line for a Unix-socket connection now includes the port, which picks the socket file.

  • A materialized view written with WITH NO DATA is now created with it. apply dropped the clause and ran the view's query over its source tables. Whether a view is populated is not compared, and dump does not write the clause.

  • fmt no longer refuses a file with trailing whitespace after a -- comment. The whitespace is removed.

  • A policy whose role list names PUBLIC alongside other roles no longer plans an ALTER POLICY on every run. PostgreSQL keeps only PUBLIC, and the desired side now does too.

  • A -- pista: directive written after code on the same line, as in CREATE TABLE a (...); -- pista:ignore, is now an error. It used to apply to the next statement instead of the one before it, and one after a column or an enum value was silently dropped. Move the directive to its own line above its statement.

  • A -- pista: directive with no statement after it in the same file is now an error. It used to apply to the first statement of the next schema file.

  • dump now writes SET STORAGE and SET COMPRESSION for the columns an INHERITS child declares, and a new INHERITS child gets them when it is created. Before, the next plan showed them as drift.

  • COMMENT ON TYPE on a domain is now read. Before, it was ignored and every plan dropped the comment with COMMENT ON DOMAIN ... IS NULL.

[1.70.1] - 2026-09-28

  • An index written with COLLATE "default" on a column of the default collation is no longer dropped and created on every plan.

[1.70.0] - 2026-09-28

  • --manage-routine now manages routines with a BEGIN ATOMIC or RETURN body. They are created after the tables and views they read, so a CHECK, default or index that calls one cannot be created in the same run. An existing one that the desired schema does not define now shows up as a drop.

  • The plan file version is now 4. Run plan --out again for a plan file an older pista wrote.

  • The JSON Schema is now schema-1.1.json. It adds sql_body to a routine.

[1.69.1] - 2026-09-27

  • plan is faster. At 1,000 tables it takes about a quarter less time. The keyword lookup that decides whether an identifier needs quotes and the checks for whether a column default needs parentheses and which columns it references now run once per distinct name or expression, and a file with no -- pista: directive skips the directive checks.

[1.69.0] - 2026-09-27

  • A constraint named with a keyword such as time or values now plans correctly. plan wrote ADD CONSTRAINT time CONSTRAINT "time" CHECK ..., which PostgreSQL rejects.

  • Renaming a column used in the WHERE clause of an exclusion constraint no longer drops and re-adds the constraint. The rename is carried into the clause, as it already is for a partial index.

  • The sizes --explain prints now match pg_size_pretty. A size just under a half unit could come out one unit too high, such as 11 MB for 11009536 bytes, which pg_size_pretty shows as 10 MB.

[1.68.0] - 2026-09-27

  • --schema-map now maps the indexes of a materialized view. dump wrote an index and its comment under the unmapped schema, so planning the dump failed, and plan created the index in the mapped schema.

  • --schema-map no longer rewrites a string literal in an index, constraint, policy, trigger or view definition. It still rewrites one that names an object, such as the argument of nextval or a literal cast to regclass, as it does in a default. A table alias named like a mapped schema, as in SELECT staging.id FROM staging.users staging, is no longer rewritten either.

  • plan now fails when an index on a partitioned table would be created or dropped CONCURRENTLY, through --force-index-concurrently or -- pista:concurrently. PostgreSQL rejects both, so apply used to stop at that statement.

  • force in a config file is now an error. It turned off the drift check of every apply-from that read the file. Pass --force on the command line.

  • A foreign key written without the referenced columns, such as REFERENCES users, no longer plans a drop and an add on every run. The columns are taken from the referenced table's primary key in the desired schema, so a key to a table the desired schema does not declare still does.

  • A domain constraint can be kept NOT VALID. Write it as ALTER DOMAIN ... ADD CONSTRAINT ... NOT VALID after CREATE DOMAIN. dump writes it the same way. Before, dump wrote it inside CREATE DOMAIN, and planning the dump validated the constraint.

[1.67.1] - 2026-09-27

  • Internal refactoring in the CLI commands, the object filters and the trigger diff, with no change in behavior.

  • More test cases for the parser and the diff.

[1.67.0] - 2026-09-27

  • A directive with a space after -- pista:, such as -- pista: ignore, is now an error. It used to be ignored silently.

  • The error for a CREATE POLICY or CREATE TRIGGER on a table that is not declared before it now shows the file and line, as CREATE INDEX does.

  • The ignored-statement warning and the per-file errors of fmt start with pista: instead of pistachio:, the same program name as the other errors.

  • A -- pista:renamed-from before a column or constraint written with a leading comma, such as , name text, now renames it. It used to be ignored, and the plan added a new column or constraint.

  • Internal refactoring in the parser, catalog, diff and dump code, with no change in behavior.

[1.66.1] - 2026-09-26

  • fmt starts a statement at the first column instead of keeping its indentation.

[1.66.0] - 2026-09-26

  • Creating a partitioned table, its partitions and their indexes in one run no longer fails with relation ... already exists. This is what apply of a dump does on an empty database. The index on the partitioned table is now created after the partitions' indexes and attaches them.

  • An index added to or changed on an existing partitioned table no longer leaves each partition with two copies when the file also declares the partition's copy.

  • dump writes an index on a partitioned table without ONLY. With ONLY, the index was left invalid.

  • The plan file version is now 3. apply-from refuses a file written by an earlier version, so run plan --out again.

  • plan fails, naming the dependents, when it would change the type of a column that a view, rule, trigger, policy, BEGIN ATOMIC routine or generated column depends on. PostgreSQL refuses the change, so apply failed partway.

  • plan fails, naming the dependents, when it would drop or change a key or an index that a foreign key or a view depends on. PostgreSQL refuses the drop, so apply failed partway.

  • With --manage-routine, plan fails when a routine it has to drop and recreate is used by a constraint, default, index, view, policy or trigger, and names them. PostgreSQL refuses that drop, so apply used to fail partway.

[1.65.0] - 2026-09-25

  • Add --explain to diff. It writes the comment plan --explain does, without sizes, and marks a type change or a default that calls a function may rewrite.

[1.64.0] - 2026-09-24

  • A view that selects a bare string or NULL constant, such as SELECT id, 'i' AS src FROM items, no longer plans CREATE OR REPLACE VIEW on every run. The catalog prints the constant as 'i'::text.

  • Renaming a table or view with -- pista:renamed-from no longer re-creates its unchanged triggers. PostgreSQL moves the triggers with the rename.

  • A view or materialized view over UNION, INTERSECT or EXCEPT no longer plans a replacement on every run. The output names PostgreSQL writes onto the later SELECTs are ignored in the comparison.

  • -- pista:renamed-from on an index of a materialized view now plans ALTER INDEX ... RENAME TO. The directive was ignored, so the index was dropped and created again.

  • Renaming a materialized view with -- pista:renamed-from no longer drops and re-creates its indexes.

  • With --manage-routine, a routine parameter default written without a cast, such as a text DEFAULT 'x', no longer plans CREATE OR REPLACE FUNCTION on every run. The catalog reads it back as 'x'::text, which was compared as text.

  • Renaming an enum, domain, composite type or sequence with -- pista:renamed-from no longer retypes the columns that use it or resets their nextval() default. The extra statements failed the apply when a view read such a column, and a domain over a renamed type stopped the plan with a base type error.

  • A DEFERRABLE or INITIALLY DEFERRED written on a column constraint, such as REFERENCES users (id) DEFERRABLE INITIALLY DEFERRED, is now read. It was dropped, so the constraint was created not deferrable. A combination PostgreSQL rejects, such as NOT DEFERRABLE INITIALLY DEFERRED, is an error.

  • A foreign key that references a table in another schema by its qualified name, such as REFERENCES public.base (id) on a table in app, no longer drops and re-adds itself on every plan.

  • Error messages no longer repeat the program name, as in pista: error: pistachio: .... The schema errors name --schemas instead of Options.Schemas, and a failed connection reads failed to connect to database.

[1.63.0] - 2026-09-24

  • A column or domain DEFAULT whose cast on an expression changes is now planned, such as now()::date becoming now(), or ::timestamp(0) with time zone becoming ::timestamp(0). The cast was ignored in the comparison.

  • A column whose default draws from a sequence it owns no longer plans SET DEFAULT on every run when the desired schema writes the default out. The column was read as a serial without its default.

  • dump --json writes the nextval default of a serial column in default, where it wrote null.

  • The plan file version is now 2. apply-from refuses a file written by an earlier version, so run plan --out again.

[1.62.0] - 2026-09-23

  • A new table's policy that reads a table or view created later in the same run no longer fails to apply. A new table's policies now run after every table and view.

  • A primary key or unique constraint that repeats another on the same table is no longer lost when the table is created. CREATE TABLE drops the repeat, so the plan and dump now add it after the table.

  • Foreign keys no longer order tables in a plan, so two tables referencing each other no longer push the whole plan into a fixed order that created routines before tables.

  • A string literal spanning lines in a new table's comment, policy, trigger WHEN or NOT VALID check no longer fails the apply. The statement was split at the line break.

  • A column or domain DEFAULT that needs parentheses, (now() AT TIME ZONE 'utc') for one, keeps them in the plan. Without them the statement failed with a syntax error, and a trailing COLLATE set the column's collation instead.

  • Routines whose signature names a table, as RETURNS SETOF <table> does, are created after that table even when several of them name different tables. The dependency sort failed on a cycle, and the plan created every routine before the tables.

[1.61.0] - 2026-09-23

  • BREAKING: Remove dump --sort-by-deps. It failed on mutual foreign keys, and pg_dump -s writes a file psql can load. To load a dump into an empty database, use apply.

[1.60.1] - 2026-09-22

  • A table named after the type one of its own columns is written with, text among them, no longer breaks the dependency sort, and neither does a domain named after its base type. The name resolved to the object itself, and that self-edge was read as a cycle: dump --sort-by-deps failed with cycle detected, and plan fell back to ordering by category, which creates a domain before the domain it is built on. A name that matches the object it is written on now carries on along the search path, so CREATE DOMAIN app.rating AS rating written where public.rating is the base type keeps that dependency.

[1.60.0] - 2026-09-22

  • plan --out writes the plan to a file and apply-from runs it. apply-from reads no schema file and diffs nothing: it reads the database to check that the schema is still what the plan was computed against, and stops when it is not. --force runs the plan anyway and reports the drift as a warning. The file records the options that decide what the plan read, so apply-from reads the database the same way, and a -- pista:execute check is evaluated once, when the file is written.

  • apply --timing writes each statement's elapsed time as a comment after it. Every statement it writes out is timed, BEGIN and COMMIT included. The time is measured on the client, so it covers any wait for a lock.

[1.59.2] - 2026-09-20

  • CREATE UNLOGGED SEQUENCE is ordered by its dependencies, as CREATE SEQUENCE already was. The plan placed a statement by its prefix and had no prefix for the unlogged spelling, so the statement ran before every other create. The plan now reads the parse tree, and the spelling no longer decides where a statement runs.

[1.59.1] - 2026-09-20

  • A one-element IN list compares equal to the comparison PostgreSQL stores it as. CHECK (carrier IN ('own_a')) is stored as CHECK ((carrier = 'own_a'::text)), so the constraint was dropped and added again on every plan, revalidating the whole table. A domain constraint, an index predicate, a policy, a view body and a trigger WHEN drifted the same way, and a generated column failed the run with cannot change GENERATED expression. NOT IN (a) is stored as <> a and folds too.

[1.59.0] - 2026-09-19

  • apply --exclusive-wait prints -- Waiting for another exclusive apply to finish while it waits. The line was buffered with the rest of the output and came out after the wait it announced.

  • fmt writes through a symlink to the file it points at. It replaced the link with a plain file and left that file unformatted.

  • A trigger state naming a trigger, and a storage setting naming a column, that the table does not declare before it are errors, as is CREATE INDEX on a table or materialized view not declared before it. Each was dropped in silence, so the index or the setting was missing from the desired schema. An ALTER SEQUENCE option other than OWNED BY warns as an unread ALTER TABLE action does; it was dropped in silence too.

  • ALTER TABLE on a table, and ALTER SEQUENCE on a sequence, that no statement before it declares is an error. Both were dropped in silence, so a typo in the table name, or a file that sorts before the one declaring the table, left the constraint out of the desired schema and the plan offered to drop it from the database. ALTER TABLE on a view, a materialized view or a sequence warns as ALTER VIEW does.

  • Warn about CREATE TABLE ... AS, a LIKE clause in CREATE TABLE, and ALTER INDEX / ALTER VIEW / ALTER MATERIALIZED VIEW. Each shares a statement type with one the parser reads, so none reached the ignored unsupported statement warning and was dropped in silence: a table created with AS was absent from the desired schema, and the columns a LIKE clause copies planned as DROP COLUMN.

  • -- pista:renamed-from written without a name is an error. It matched nothing and raised nothing, so the object was dropped and created instead of renamed.

  • Trim the whitespace around each schema name in -n, $PISTA_SCHEMAS and schemas in the config file, and around each side of a -m mapping. -n 'public, billing' read the second entry as billing, a schema of its own, so the real one was neither read nor written, and -m 'old=new; other=third' mapped other, which matched nothing. -m now also refuses an empty side, -m 'x=', which left the mapped objects unqualified, and two sources that differ only in whitespace.

  • exclusive-wait: 0 in the config file is read as --exclusive-wait 0 is. It failed with cannot unmarshal number into Go value of type string, since YAML reads a bare 0 as a number and the flag took only text.

  • --schema-map now reaches a column's type, collation and default, a partition's parent, a constraint definition, a composite attribute's collation, and a domain's base type, collation, default and constraints. dump -m myschema=public wrote s myschema.status, DEFAULT nextval('myschema.seq1'::regclass) and PARTITION OF myschema.parent under a public table, which does not load, and plan retyped such a column and re-set such a default on every run. The map also no longer rewrites a schema whose name ends in the mapped one: a foreign key to mystaging.ref came out as mypublic.ref under -m staging=public.

  • Name an unnamed UNIQUE or EXCLUDE constraint the way PostgreSQL does when it holds an expression or an INCLUDE list: EXCLUDE (lower(a) WITH =) is t_lower_excl, not t_expr_excl, and UNIQUE (a) INCLUDE (b) is t_a_b_key, not t_a_key. Every plan added the constraint under the old name and offered to drop the server's.

  • DROP NOT NULL now runs after the table's constraint statements. Dropping a primary key and the NOT NULL on its column in one plan put the DROP NOT NULL first, which PostgreSQL refuses with column is in a primary key.

  • A constraint trigger whose definition changes is dropped and created again, under --allow-drop trigger. plan wrote CREATE OR REPLACE CONSTRAINT TRIGGER, which PostgreSQL does not have, so apply failed.

  • serial2, serial4 and serial8 are read as smallserial, serial and bigserial, like int2, int4 and int8. A column written with one of them planned SET DATA TYPE serial8, which PostgreSQL refuses, and DROP NOT NULL on every run.

  • plan now fails when it would drop a view or materialized view that another object still reads, and names what reads it. PostgreSQL refuses that DROP instead of cascading, so the plan read as fine and apply failed on it. Views hit this without being dropped on purpose: a definition change becomes a drop and a create whenever CREATE OR REPLACE VIEW cannot express it, and always for a materialized view.

  • Order view drops and creates even when the whole-schema sort fails. Two tables with foreign keys to each other are a cycle that sort cannot resolve, and the fallback emitted view statements in map order, so a chain of views changing shape dropped the base before the view reading it and apply failed. Views are now sorted among themselves there, which they always can be: PostgreSQL rejects a view that reads a view reading it back.

[1.58.0] - 2026-09-17

  • Add --explain to dump ($PISTA_DUMP_EXPLAIN). It writes the size of each table, materialized view and index in a comment, from the same estimates plan --explain reads.

[1.57.0] - 2026-09-17

  • BREAKING: --manage-routine leaves out the constructor functions of a range type. dump wrote them, and plan proposed dropping them, which PostgreSQL refuses. Delete them from a file an earlier dump wrote, or every plan creates them.

[1.56.0] - 2026-09-17

  • Compare an index element's operator class and collation, an exclusion constraint's elements too, the way PostgreSQL writes them: a name in the search_path without its schema, an option value quoted, siglen='32'. A file that wrote either the other way re-created the index on every plan.
  • Manage a comment on a view or materialized view column. dump did not write one, and a COMMENT ON COLUMN naming one was dropped without a warning.
  • An index on a partitioned table written without ONLY is no longer re-created on every plan, and an index attached to it is not dropped on its own. That DROP INDEX always failed.

[1.55.0] - 2026-09-16

  • Add --git (-g, $PISTA_GIT) to diff. It reads the files on the command line out of a git repository: A..B compares the two revisions, A...B compares B against its merge base with A, and A alone compares A against the working tree. Every file is read at both ends of the range, so a side can span more than one file.
  • fmt and dump keep an ARRAY[...] constructor on one line. The layout counted a parenthesis but not a square bracket, so a comma inside a constructor read as the end of a column and broke the line there.
  • The Homebrew cask writes the postflight_steps stanza instead of postflight, which Homebrew 7 deprecates.

[1.54.2] - 2026-09-13

  • diff no longer prints the -- Diff for schema ... header. There is no connection to name and the object counts of a file say little, so the output is the DDL alone.

[1.54.1] - 2026-09-13

  • diff no longer prints -- pista:execute statements. They are not schema state and their check SQL cannot be evaluated without a database; apply runs them as usual.

[1.54.0] - 2026-09-13

  • Add pista diff. It compares two schema SQL files and prints the DDL that takes the first to the second, with the same statements and ordering plan produces, without reading a database. --check exits with code 2 when the diff contains executable changes.

[1.53.0] - 2026-09-12

  • BREAKING: parse and dump --json write a table's columns as an array rather than an object keyed by name. The order is the column order, which a JSON object does not promise to keep, and the name is already a field of the column. The published JSON Schema at schema-1.0.json is updated in place.

[1.52.0] - 2026-09-12

  • BREAKING: The connection options (-c / --conn-string, -d / --dbname, --password, -n / --schemas, -m / --schema-map, --search-path) belong to the command that opens a connection, so they are written after it rather than before: pista dump -n myschema, not pista -n myschema dump. fmt reads no database and no longer takes them at all, and parse takes -n alone, which it uses to qualify unqualified names. -C / --config, --pager and --version stay where they were, and the environment variables and the config file are unchanged.

[1.51.0] - 2026-09-12

  • Add --json to dump ($PISTA_DUMP_JSON). It writes JSON in the same shape pista parse writes, read from the database rather than from files, so the published JSON Schema describes both.

  • Write a relation's storage parameters as an empty object rather than null in that document when --manage-storage-param is off, the way unread routines are written. The SQL is unaffected.

  • Add pista parse. It parses schema SQL files with the same parser plan and apply use and prints the objects as JSON, without reading a database, so other tools can consume the schema.

  • Publish the JSON Schema of that document at https://winebarrel.github.io/pistachio/json/schema-1.0.json. It is reflected off the structs, so it follows what the command writes.

[1.50.0] - 2026-09-10

  • Read a constraint added with UNIQUE USING INDEX or PRIMARY KEY USING INDEX. The form never compared equal to the constraint it created, so every plan re-issued DROP and ADD, and the index it takes over was planned as a DROP, which ran first under --allow-drop index and broke the ADD. The index now stays, the file plans clean, and a constraint written without a name takes the index's name, the way PostgreSQL names it.

[1.49.2] - 2026-09-10

  • Date the size an --explain comment prints, ~120,000,000 rows, 9629 MB, as of 2026-09-08. It is when a VACUUM or an ANALYZE last wrote the estimate, so a plan says how stale the numbers are. A size the server can no longer date is printed without it.

[1.49.1] - 2026-09-10

  • Write the row estimate in an --explain comment with a comma between every three digits, ~120,000,000 rows rather than ~120000000 rows.

[1.49.0] - 2026-09-10

  • Add --explain to plan ($PISTA_EXPLAIN). It writes a comment before each statement that scans or rewrites a table that already holds data: what the statement does to the rows, what its lock blocks, and the table's row and byte estimate from pg_class, with the number of indexes a rewrite builds again and the partitions or INHERITS children it recurses into. A foreign key names the referenced table next to the referencing one, and a domain change names every table with a column of the domain. A statement that changes the catalog alone takes no comment, and neither does one on a table the same plan creates. The estimates are what VACUUM and ANALYZE last wrote, the TOAST relation's pages included; a table neither has visited reads as not analyzed. Whether a type change is a relabel or a conversion, and whether a default calls a volatile function, is one catalog read each, made only when the plan holds such a statement, and a plan with no statements makes none.

[1.48.0] - 2026-09-09

  • Read a primary key added by ALTER TABLE as implying NOT NULL. PostgreSQL sets the flag whichever way the key arrives, and only the inline form was read that way. A file that adds the key in a later statement planned DROP NOT NULL on every run, and applying it failed with column is in a primary key.

  • Read a serial column as NOT NULL. The type expands to a NOT NULL column, and the catalog reports it as one, so a declaration that left the words off planned DROP NOT NULL right after the table was created. Applying it took the constraint away.

  • Read a numeric written with only a precision. PostgreSQL stores numeric(5) as numeric(5,0) and format_type prints both digits, so the declaration and the catalog never compared equal and every plan re-issued the same ALTER.

  • Drop a partition that carries a copy of its parent's foreign key. The copy was dropped first, which PostgreSQL rejects with cannot drop inherited constraint, and the copy goes with the partition anyway. Dropping the whole partitioned table failed the same way.

  • Compare a domain's base type the way a column's type is compared. format_type writes a base type on the search path unqualified, so a domain written over public.status read as a base type change, which cannot be altered, and every plan failed.

  • BREAKING: Read only the collation a domain or a composite attribute sets itself. One typed over a collated domain inherits that collation without declaring it, and reading the inherited one as declared made the desired side, which writes none, look like a collation change. A domain's collation cannot be altered, so every plan failed. dump no longer writes the inherited clause either, which is what pg_dump does.

A file an earlier pista dump wrote restates the inherited clause, since the old reading wrote it on both sides. Such a file no longer plans: a domain fails with cannot change collation of domain, which stops the whole plan, and a composite attribute drifts on every run. Run pista dump again to rewrite the file; the new output plans clean. Only a domain or an attribute over a collated type is affected.

  • Read a -- pista: directive on a file written with CRLF line endings. The carriage return was taken as trailing content, so ignore, concurrently, bulk-alter and execute turned off silently: the name is still a known one, so nothing was reported.

  • Read CREATE INDEX IF NOT EXISTS as the index it describes. The clause was kept in the stored definition, which pg_get_indexdef never writes, so an index a schema file spelled that way was dropped and created again on every plan.

  • Read an enum that has no labels. CREATE TYPE ... AS ENUM () has no pg_enum row, and the catalog reached the labels through an inner join, so the type read as absent: every plan created it again and the second apply failed with type already exists. dump writes it now as well.

  • Fold a routine's SET name to lower case. GUC names are case-insensitive and pg_get_functiondef writes the spelling PostgreSQL keeps, so a routine created with SET timezone read back as SET "TimeZone" and was replaced on every plan. DateStyle and IntervalStyle behaved the same way.

  • Widen a serial column to bigserial. The change went out as SET DATA TYPE bigserial, which fails: PostgreSQL takes a serial pseudo-type only in CREATE TABLE and ADD COLUMN. The base type goes out instead, with ALTER SEQUENCE ... AS for the sequence the column owns. bigserial is a bigint column and a bigint sequence, and widening the column alone left the numbers stopping at 2147483647. A column that owns no sequence takes the one statement it always did.

  • Test the pista entrypoint. main now hands the arguments, the output streams and the exit function to a run function, so the argument parsing, the pager hookup and the exit codes are covered by go test, and cmd/pista is no longer left out of the coverage report. No change in behavior.

[1.47.0] - 2026-09-07

  • Add pista fmt, which lays out schema SQL files in place. It works on the token stream, so only the whitespace moves: the keyword case, the identifier spelling and the line breaks are left as they were written, and no schema name is added. The definition list of a CREATE TABLE and a CREATE TYPE is written one element per line, a line inside parentheses is indented four spaces past the line that opened them, a routine's clauses take one level, and the space before a comma, just inside a parenthesis, and around a subscript or a cast is closed up. A quoted identifier loses its quotes when the statement parses to the same thing without them. A view body and a routine body are left alone. The result is checked to carry the same tokens as the input, and a file that fails the check or does not parse is not written. --check reports the files that are not formatted and exits with code 2.

  • Write dump through the same formatter, so its output is what pista fmt writes. No dump changes today, since the model already renders that layout; --no-format turns the pass off.

[1.46.0] - 2026-09-06

  • Manage a foreign key on a partitioned table. ALTER TABLE ONLY is rejected there, so adding a key to one failed with cannot use ONLY for foreign key on partitioned table; the word is left off now, and nothing inherits a foreign key anyway. A partition's copy of its parent's key was dropped and added on its own, which PostgreSQL rejects and the parent's statements settle regardless, so the copy now takes none. dump wrote the copy too, which made its output fail to reload; it is left out, as pg_dump leaves it out.

  • Change a foreign key's deferral clause in place. DEFERRABLE and INITIALLY DEFERRED were compared only as part of the definition string, so a toggle dropped the key and added it back, rescanning the table to validate it again. When nothing else about the key differs it now goes out as ALTER TABLE ... ALTER CONSTRAINT, with VALIDATE CONSTRAINT next to it when the key is also being validated. PostgreSQL takes the statement on a foreign key alone, so a unique, primary key or exclusion constraint is still dropped and added, and a partition's copy of its parent's key takes no statement at all.

  • Manage a comment on an index. COMMENT ON INDEX was read by neither side, so dump dropped the line pg_dump writes and a schema file that carried one was warned about and ignored. A change now goes out as COMMENT ON INDEX ... IS ... or IS NULL, and a recreate writes the comment again, since PostgreSQL drops it with the index. A comment on a constraint, a trigger or a policy, and one on the index a constraint owns, stays unmanaged.

[1.45.0] - 2026-09-06

  • Manage the storage parameters of a view and a materialized view, the WITH (...) clause before AS. Neither side read them, so dump dropped the clause and a schema file that wrote one was applied without it. A plain view holds security_barrier and security_invoker and nothing else. Both decide what the view means rather than how it is stored, so they are managed without a flag: a dump that dropped them restored a view that leaks rows, or one that reads the base table with the wrong privileges. A materialized view holds what a table holds, tuning included, so it sits behind --manage-storage-param with the table. A change to a view whose definition stays goes out as ALTER [MATERIALIZED] VIEW ... SET (...) / RESET (...); a definition change carries the clause on its own statement, which replaces the view's options as a whole. Two things that used to plan nothing now plan something: a view carrying security_barrier the schema file does not name is reset, and, under the flag, a materialized view's parameters are diffed like a table's.

[1.44.1] - 2026-09-06

  • Build the macOS release binaries against a deployment target of 13.0. The cross toolchain aimed at 10.9 on amd64 and 11.0 on arm64 while Go stamped its object with 13.0, so the release build warned on every link. Go binaries need macOS 13 either way.

[1.44.0] - 2026-09-06

  • Manage a view's WITH [LOCAL | CASCADED] CHECK OPTION. Neither side read it, so dump dropped the clause and a reload lost the constraint, and a desired schema that wrote it planned nothing. dump now writes the clause after the query. A change on a view whose definition stays goes out as ALTER VIEW ... SET (check_option=...) or RESET (check_option); a definition change carries the clause on its CREATE OR REPLACE VIEW. WITH (check_option = ...) before AS is read too.

[1.43.0] - 2026-09-05

  • Manage an INHERITS child as the columns and constraints it declares itself. dump wrote such a child as its constraints alone, the parent's included, and dropped every column, so the output did not even reload: the constraint named a column the statement no longer declared. Fed back as the desired schema it planned a DROP COLUMN for every column and a DROP CONSTRAINT PostgreSQL rejects. Both sides now read the local half alone, attislocal and conislocal, which is what the child's CREATE TABLE writes and what pg_dump writes for it. A NOT VALID check on the child is added by its own ALTER rather than written inline, so a reload no longer validates it. A comment on an inherited column and a child of more than one parent stay unmanaged, both symmetric on the two sides.

[1.42.0] - 2026-09-05

  • Read the storage parameters of the index a constraint owns. ALTER TABLE ... ADD CONSTRAINT ... UNIQUE (v) WITH (fillfactor = 70) puts them on the index, which pg_get_constraintdef does not print, so the two sides never matched and the constraint was dropped and re-added on every plan, rebuilding the index under an ACCESS EXCLUSIVE lock. dump dropped the clause the same way and now writes it. A parameter set on such an index outside the schema file shows up as a diff, the same as on a plain index. A quoted parameter value and the bare spelling, deduplicate_items = 'off' and off, also compare equal now, on a plain index too.

  • Normalize an EXCLUDE constraint's elements and predicate the way a CHECK body is normalized. They were compared as written, so a hand-written exclusion drifted on a schema-qualified function or operator, a cast the catalog adds, a BETWEEN in the predicate, or an explicit ASC, and was dropped and re-added on every plan. A dump fed back was already clean; only a desired schema written another way hit this.

  • Track UNLOGGED on a standalone sequence. It was read by neither side, so dump wrote an unlogged sequence as a plain CREATE SEQUENCE and a LOGGED <-> UNLOGGED change produced no diff. dump now writes CREATE UNLOGGED SEQUENCE and a change goes out as ALTER SEQUENCE ... SET LOGGED/UNLOGGED. Temporary sequences stay out of scope.

  • Restore a NOT VALID check constraint unvalidated. A table's checks are written inside CREATE TABLE, where the clause cannot be spelled, so dump printed such a constraint as validated and the reload scanned the table to validate it. Such a check is now left out of CREATE TABLE and added by its own ALTER TABLE ... ADD CONSTRAINT ... NOT VALID, the way a foreign key is. This also stops a plan that created the table from re-emitting the constraint on every later run.

[1.41.1] - 2026-09-04

  • Wait for the apply exclusion by retrying rather than by blocking in pg_advisory_lock. A blocked statement holds a snapshot while it waits, and the apply it waits for may 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.