PostgreSQL 19 adds several practical DDL and administration features. This post covers four of them, each run side by side on PostgreSQL 18 and PostgreSQL 19 RC1:
- GRANT / REVOKE … GRANTED BY records a privilege change as made by another role, not only the current user.
- ALTER TABLE … ALTER CONSTRAINT … [NOT] ENFORCED now works for CHECK constraints, not only foreign keys.
- ALTER TABLE … ALTER COLUMN … SET EXPRESSION now succeeds on virtual generated columns that have CHECK constraints.
- CHECKPOINT now accepts options: MODE (FAST or SPREAD) and FLUSH_UNLOGGED.
Test environment. Both servers were built from source on Ubuntu 24.04: the REL_18_STABLE branch (PostgreSQL 18.6) and the REL_19_RC1 tag (19rc1).
PostgreSQL 18 vs PostgreSQL 19 at a glance
| Feature | PostgreSQL 18 | PostgreSQL 19 |
| GRANTED BY on object privileges | Syntax accepted, but only the current user is allowed: grantor must be the current user | Grant is recorded as made by the named role, if you inherit that role’s privileges |
| ALTER CONSTRAINT … [NOT] ENFORCED | Foreign keys only; a CHECK fails with cannot alter enforceability | Works for FOREIGN KEY and CHECK |
| SET EXPRESSION on a virtual column in a table with a CHECK | Error: not supported in tables with check constraints | Allowed; existing rows are checked, no table rewrite |
| CHECKPOINT options | None; CHECKPOINT (MODE SPREAD) is a syntax error | MODE (FAST | SPREAD) and FLUSH_UNLOGGED |
1. GRANT / REVOKE … GRANTED BY
1.1 What was missing in PostgreSQL 18
Every privilege stored on an object has a grantor, the role the grant is attributed to. A later REVOKE removes only the grants made by the grantor it acts as, so the recorded grantor matters in delegated administration.
PostgreSQL 18 parses GRANTED BY on object privileges, but only for SQL compatibility: the named grantor must be the current user. You could not choose whom a grant is attributed to.
1.2 What changes in PostgreSQL 19
Every GRANT and REVOKE form now ends with [ GRANTED BY role_specification ]. The grant is recorded as made by the named role. The role running the command may do this only if it inherits the privileges of the named role. Role-membership GRANT role TO role GRANTED BY … already worked on both versions. The new part is the effective role for object privileges.
1.3 Setup
CREATE ROLE app_owner;
CREATE ROLE app_admin;
CREATE ROLE team_lead;
CREATE ROLE reader1;
CREATE ROLE reader2;
CREATE ROLE reader3;
CREATE ROLE outsider;
CREATE TABLE orders (id int);
ALTER TABLE orders OWNER TO app_owner;
GRANT app_owner TO app_admin; -- app_admin inherits app_owner
GRANT team_lead TO app_admin; -- app_admin inherits team_lead
SET ROLE app_owner;
GRANT SELECT ON orders TO team_lead WITH GRANT OPTION;
RESET ROLE;
The starting state is identical on both versions. In the access privileges column, the role after the slash is the grantor.
postgres=# \dp orders
Schema | Name | Type | Access privileges
--------+--------+-------+------------------------------
public | orders | table | app_owner=arwdDxtm/app_owner+
| | | team_lead=r*/app_owner
1.4 Examples
Example 1: attribute a grant to the table owner (run as a superuser, who is not the owner):
GRANT SELECT ON orders TO reader1 GRANTED BY app_owner;
PostgreSQL 18:
ERROR: grantor must be current user
PostgreSQL 19 accepts it and records reader1=r/app_owner.
Example 2: attribute a grant to a role you inherit
SET ROLE app_admin;
GRANT SELECT ON orders TO reader2 GRANTED BY team_lead;
RESET ROLE;
PostgreSQL 18 again returns grantor must be current user. On PostgreSQL 19 the entry is attributed to team_lead, not to app_admin:
postgres=# \dp orders
Schema | Name | Type | Access privileges
--------+--------+-------+------------------------------
public | orders | table | app_owner=arwdDxtm/app_owner+
| | | team_lead=r*/app_owner +
| | | reader1=r/app_owner +
| | | reader2=r/team_lead
Example 3: a role that does not inherit the grantor is refused
SET ROLE outsider;
GRANT SELECT ON orders TO reader3 GRANTED BY team_lead;
PostgreSQL 19 gives a specific error (PostgreSQL 18 gives the generic one):
ERROR: must inherit privileges of role "team_lead"
Example 4: REVOKE … GRANTED BY removes only that grantor’s entry
REVOKE SELECT ON orders FROM reader2 GRANTED BY team_lead;
On PostgreSQL 19, reader2 disappears, and the other entries stay. On PostgreSQL 18 the statement fails with the message “grantor must be current user”.
Example 5: attribute a grant to a role you do not inherit (invoker is a member of other roles, but not of the named one)
SET ROLE app_admin;
GRANT SELECT ON orders TO reader3 GRANTED BY outsider;
app_admin inherits app_owner and team_lead, but it is not a member of outsider. PostgreSQL 19 refuses the grant and names the role it could not inherit:
ERROR: must inherit privileges of role "outsider"
PostgreSQL 18 refuses it too, but with the generic message:
ERROR: grantor must be current user
Nothing is granted on either version: \dp orders is unchanged and reader3 does not appear. This differs from Example 3 only in who runs the command: there the invoker was outsider itself, here it is a role that inherits other grantors but not the one named.
1.5 How this was handled in PostgreSQL 18
The only way to get a grant attributed to team_lead was to become that role first:
-- connected as app_admin
SET ROLE team_lead; -- whole session now acts as team_lead
GRANT SELECT ON orders TO reader3;
RESET ROLE;
The result is reader3=r/team_lead on both versions. GRANTED BY States the intent inside the statement itself.
1.6 Compatibility check: GRANTED BY CURRENT_USER
Because PostgreSQL 19 now honors the grantor you name, one old habit changes. Run as a superuser who is not the owner of orders:
GRANT SELECT ON orders TO reader3 GRANTED BY CURRENT_USER;
- PostgreSQL 18: the grant succeeds and is recorded as made by the owner (reader3=r/app_owner).
- PostgreSQL 19: nothing is granted.
WARNING: no privileges were granted for “orders”
A plain GRANT SELECT ON orders TO reader3; as the same superuser still records the owner as grantor on both versions.
Review scripts that add GRANTED BY CURRENT_USER to grants issued by a superuser before you upgrade.
1.7 Practical guidance
- GRANTED BY changes only who the grant is attributed to. It does not give anyone new privileges.
- The command issuer must inherit the privileges of the named role, otherwise, PostgreSQL 19 requires that the issuer inherit privileges of the role.
- For role memberships, the recorded grantor must still hold ADMIN OPTION on the target role, unless it is the bootstrap superuser. This rule is unchanged.
- Use the same grantor on REVOKE that you used on GRANT, since REVOKE acts on one grantor’s entries.
| Command / Invoker | PostgreSQL 18 | PostgreSQL 19 |
| GRANT … GRANTED BY other_role | ERROR: grantor must be current user | Recorded as made by other_role |
| REVOKE … GRANTED BY other_role | ERROR: grantor must be current user | Removes only that grantor’s entry |
| Invoker does not inherit the named role | ERROR: grantor must be current user | ERROR: must inherit privileges of role |
| Superuser runs GRANT … GRANTED BY CURRENT_USER | Succeeds, recorded as owner | WARNING: no privileges were granted |
| SET ROLE workaround | Works | Works |
2. ALTER CONSTRAINT … [NOT] ENFORCED for CHECK constraints
2.1 What was missing in PostgreSQL 18
PostgreSQL 18 lets you create a CHECK constraint as NOT ENFORCED, which is handy for bulk loads and migrations. The gap could only be noticed afterwards: only foreign keys could change enforceability with ALTER CONSTRAINT, so a CHECK could never be switched to ENFORCED again.
2.2 What changes in PostgreSQL 19
ALTER TABLE … ALTER CONSTRAINT … [NOT] ENFORCED now works for FOREIGN KEY and CHECK constraints.
- NOT ENFORCED stops checking the constraint for new and updated rows.
- ENFORCED first verifies that existing rows satisfy the constraint, then checks new and updated rows.
- Deferrability can still be altered only for foreign keys.
2.3 Setup: a NOT ENFORCED CHECK with one bad row
CREATE TABLE emp (id int, salary numeric,
CONSTRAINT sal_pos CHECK (salary > 0) NOT ENFORCED);
INSERT INTO emp VALUES (1, 100), (2, -5); -- accepted: constraint not enforced
SELECT conname, conenforced, convalidated
FROM pg_constraint WHERE conrelid = 'emp'::regclass;
conname | conenforced | convalidated
---------+-------------+--------------
sal_pos | f | f
This part is identical on both versions.
2.4 Examples
Example 1: enforce while a bad row exists
ALTER TABLE emp ALTER CONSTRAINT sal_pos ENFORCED;
PostgreSQL 18 refuses any CHECK:
ERROR: cannot alter enforceability of constraint “sal_pos” of relation “emp”
PostgreSQL 19 checks the existing rows and reports the violation. The constraint stays NOT ENFORCED:
ERROR: check constraint “sal_pos” of relation “emp” is violated by some row
Example 2: clean the data, then enforce
UPDATE emp SET salary = 50 WHERE id = 2;
ALTER TABLE emp ALTER CONSTRAINT sal_pos ENFORCED;
INSERT INTO emp VALUES (3, -1);
On PostgreSQL 18 the ALTER fails again, even though the data is clean, and the INSERT of -1 succeeds silently. On PostgreSQL 19 the constraint becomes enforced and validated, and the bad insert is rejected:
conname | conenforced | convalidated
---------+-------------+--------------
sal_pos | t | t
ERROR: new row for relation “emp” violates check constraint “sal_pos”
DETAIL: Failing row contains (3, -1).
What the output looks like once the constraint is enforced (PostgreSQL 19)
The command itself prints only its tag, and psql shows the change in \d. Before enforcing, the constraint is marked in the table definition:
postgres=# \d emp
Table "public.emp"
Column | Type | Collation | Nullable | Default
--------+---------+-----------+----------+---------
id | integer | | |
salary | numeric | | |
Check constraints:
"sal_pos" CHECK (salary > 0::numeric) NOT ENFORCED
After the data is clean and the constraint is enforced:
postgres=# ALTER TABLE emp ALTER CONSTRAINT sal_pos ENFORCED;
ALTER TABLE
postgres=# \d emp
Table "public.emp"
Column | Type | Collation | Nullable | Default
--------+---------+-----------+----------+---------
id | integer | | |
salary | numeric | | |
Check constraints:
"sal_pos" CHECK (salary > 0::numeric)
The NOT ENFORCED suffix is gone, pg_constraint shows conenforced = t and convalidated = t (the table above), and a violating insert is rejected from then on. On PostgreSQL 18 the same ALTER fails, and \d emp still ends with NOT ENFORCED.
Example 3: switch enforcement off again (PostgreSQL 19)
ALTER TABLE emp ALTER CONSTRAINT sal_pos NOT ENFORCED;
INSERT INTO emp VALUES (3, -1); -- succeeds again
Turning enforcement off also clears convalidated (f | f), so a later ENFORCED always re-checks the table.
Example 4: what did not change
ALTER TABLE emp VALIDATE CONSTRAINT sal_pos;
ALTER TABLE emp ALTER CONSTRAINT sal_pos DEFERRABLE;
ERROR: cannot validate NOT ENFORCED constraint
ERROR: constraint “sal_pos” of relation “emp” is not a foreign key constraint.
Both errors are the same on PostgreSQL 18 and 19. One small improvement: for a constraint type that cannot change enforceability, such as NOT NULL, PostgreSQL 19 adds a hint to the same error:
ERROR: cannot alter enforceability of constraint “nn_a_not_null” of relation “nn”
HINT: Only foreign key and check constraints can change enforceability.
2.5 How this was handled in PostgreSQL 18
Drop the constraint, add it back as NOT VALID (enforced for new rows, no scan of existing rows), then validate:
BEGIN;
ALTER TABLE emp2 DROP CONSTRAINT sal_pos;
ALTER TABLE emp2 ADD CONSTRAINT sal_pos CHECK (salary > 0) NOT VALID;
COMMIT;
ALTER TABLE emp2 VALIDATE CONSTRAINT sal_pos;
The end state is the same (conenforced = t, convalidated = t). Lock modes observed in pg_locks:
| Step | Lock mode |
| DROP CONSTRAINT and ADD CONSTRAINT … NOT VALID | AccessExclusiveLock |
| VALIDATE CONSTRAINT | ShareUpdateExclusiveLock |
| PostgreSQL 19: ALTER CONSTRAINT … ENFORCED | AccessExclusiveLock |
2.6 Practical guidance
- ENFORCED verifies existing rows while holding an ACCESS EXCLUSIVE lock. On large, busy tables, ADD CONSTRAINT … NOT VALID followed by VALIDATE CONSTRAINT is still the low-impact route, and it still works on PostgreSQL 19.
- While a constraint is NOT ENFORCED, bad rows can accumulate, and ENFORCED will fail until they are fixed.
- Only FOREIGN KEY and CHECK constraints can change enforceability.
3. ALTER COLUMN … SET EXPRESSION on virtual columns with CHECK constraints
3.1 What was missing in PostgreSQL 18
Virtual generated columns, introduced in PostgreSQL 18, store no data. The value is computed when the column is read, so changing the expression should be cheap. Yet PostgreSQL 18 refused SET EXPRESSION on a virtual generated column whenever the table had a CHECK constraint, even if the new expression clearly satisfied it.
3.2 What changes in PostgreSQL 19
SET EXPRESSION now succeeds on virtual generated columns used in CHECK constraints. Per the 19 documentation, replacing the expression of a virtual column needs no table rewrite, but if the column is used in a constraint, the table is scanned to check that existing rows still meet it.
3.3 Setup
CREATE TABLE gtest20 (
a int PRIMARY KEY,
b int GENERATED ALWAYS AS (a * 2) VIRTUAL CHECK (b < 50)
);
INSERT INTO gtest20 (a) VALUES (10); -- ok, b = 20
INSERT INTO gtest20 (a) VALUES (30); -- rejected on both versions, b would be 60
ERROR: new row for relation "gtest20" violates check constraint "gtest20_b_check"
DETAIL: Failing row contains (30, virtual).
3.4 Examples
Example 1: a new expression that violates the constraint
ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 100);
PostgreSQL 18 refuses before looking at any data:
ERROR: ALTER TABLE / SET EXPRESSION is not supported for virtual generated columns in tables with check constraints
DETAIL: Column “b” of relation “gtest20” is a virtual generated column.
PostgreSQL 19 checks the rows and rejects the change because of the data:
ERROR: check constraint “gtest20_b_check” of relation “gtest20” is violated by some row
Example 2: a new expression that satisfies the constraint
SELECT pg_relation_filenode('gtest20') AS fb \gset
ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 3);
SELECT pg_relation_filenode('gtest20') = :fb AS same_file_no_rewrite;
SELECT a, b FROM gtest20;
PostgreSQL 18 returns the same “not supported” error as in Example 1, and b stays a * 2. PostgreSQL 19 succeeds, the file is unchanged (no rewrite), and the row reads with the new expression:
same_file_no_rewrite
----------------------
t
a | b
----+----
10 | 30
The CHECK keeps protecting the column under the new expression. With a * 3, inserting a = 20 would give b = 60:
ERROR: new row for relation “gtest20” violates check constraint “gtest20_b_check”
DETAIL: Failing row contains (20, virtual).
Example 3: without a CHECK constraint, nothing changes
CREATE TABLE gtest_nochk (a int PRIMARY KEY, b int GENERATED ALWAYS AS (a * 2) VIRTUAL);
INSERT INTO gtest_nochk VALUES (10);
ALTER TABLE gtest_nochk ALTER COLUMN b SET EXPRESSION AS (a * 3); -- works on both
Example 4: a restriction that remains
CREATE TABLE gtest_pub (a int PRIMARY KEY, b int GENERATED ALWAYS AS (a * 2) VIRTUAL);
CREATE PUBLICATION pub_t FOR TABLE gtest_pub;
ALTER TABLE gtest_pub ALTER COLUMN b SET EXPRESSION AS (a * 3);
Both versions reject it when the table is part of a publication:
ERROR: ALTER TABLE / SET EXPRESSION is not supported for virtual generated columns in tables that are part of a publication
3.5 How this was handled in PostgreSQL 18
Drop the constraint, change the expression, add the constraint back, all in one transaction:
BEGIN;
ALTER TABLE g2 DROP CONSTRAINT g2_b_check;
ALTER TABLE g2 ALTER COLUMN b SET EXPRESSION AS (a * 3);
ALTER TABLE g2 ADD CONSTRAINT g2_b_check CHECK (b < 50);
COMMIT;
This works, but you must know and repeat the constraint definition.
3.6 Practical guidance
- On both versions the transaction held an AccessExclusiveLock on the table (observed in pg_locks), so scheduled it like any other table-level DDL.
- No table rewrite happens: the relation file stays the same.
- Tables that are part of a publication are still not supported.
- Per the 19 documentation, ANALYZE is not needed after changing a virtual column’s expression unless extended statistics are defined on it. PostgreSQL 19 also allows extended statistics on virtual generated columns.
| Scenario | PostgreSQL 18 | PostgreSQL 19 |
| SET EXPRESSION on a virtual column, table has a CHECK | ERROR: not supported … with check constraints | Supported; existing rows checked |
| New expression violates the CHECK for an existing row | ERROR: not supported … with check constraints | ERROR: check constraint … is violated by some row |
| Table file after a successful change | Not applicable (refused) | Unchanged, no rewrite |
| Table without CHECK constraints | Supported | Supported |
| Table in a publication | ERROR: not supported … part of a publication | ERROR: not supported … part of a publication |
| Drop CHECK, change, re-add CHECK | Works | Works |
4. CHECKPOINT options: MODE and FLUSH_UNLOGGED
4.1 What was missing in PostgreSQL 18
In PostgreSQL 18, CHECKPOINT takes no options. It always forces a fast checkpoint, which can mean a burst of I/O on a busy system. A plain checkpoint also leaves dirty buffers of unlogged tables alone. Any option is a syntax error:
CHECKPOINT [ ( option [, ...] ) ]
-- where option can be one of:
-- FLUSH_UNLOGGED [ boolean ]
-- MODE { FAST | SPREAD }
- MODE FAST is the default and behaves like the old command: finish as quickly as possible, at the cost of higher I/O.
- MODE SPREAD spreads the checkpoint over a longer interval controlled by the usual checkpoint settings, like a scheduled checkpoint.
- FLUSH_UNLOGGED also flushes dirty buffers of unlogged relations. It is off by default.
Bad input gets a clear error:
CHECKPOINT (MODE FAST);
CHECKPOINT (MODE SPREAD);
CHECKPOINT (FLUSH_UNLOGGED);
CHECKPOINT (MODE SPREAD, FLUSH_UNLOGGED true);
CHECKPOINT (MODE SLOW);
ERROR: unrecognized value for CHECKPOINT option “mode”: “slow”
Two more invalid forms: the option list must be in parentheses, and option names are checked.
CHECKPOINT FAST;
CHECKPOINT (MODE SLOW);
PostgreSQL 19:
ERROR: syntax error at or near "FAST"
LINE 1: CHECKPOINT FAST;
^
ERROR: unrecognized CHECKPOINT option "node"
LINE 1: CHECKPOINT (MODE SLOW);
^
The first fails because FAST is not a bare keyword; write CHECKPOINT (MODE FAST);. The second has a valid option name but an invalid value: MODE accepts only FAST or SPREAD, so SLOW is rejected. PostgreSQL 18 gives a syntax error for both, at FAST and at the opening parenthesis.
With log_checkpoints = on, the options show up in the server log. The same plain CHECKPOINT; is logged as immediate force wait on PostgreSQL 18 and as fast force wait on PostgreSQL 19, which matters if you parse these lines:
-- PostgreSQL 19
checkpoint starting: fast force wait
checkpoint starting: force wait -- MODE SPREAD
checkpoint starting: fast force wait flush-unlogged -- FLUSH_UNLOGGED
PostgreSQL 18 logs the same plain CHECKPOINT; with different wording. The start line says immediate force wait, and the completion line carries no flags:
-- PostgreSQL 18
LOG: checkpoint starting: immediate force wait
LOG: checkpoint complete: wrote 55 buffers (0.3%), wrote 3 SLRU buffers; 0 WAL file(s) added, 0 removed, 0 recycled; write=0.003 s, sync=0.005 s, total=0.010 s; sync files=49, longest=0.001 s, average=0.001 s; distance=248 kB, estimate=248 kB; lsn=0/172D588, redo lsn=0/172D530
PostgreSQL 19 repeats the flags on the completion line, right after checkpoint complete:
-- PostgreSQL 19
LOG: checkpoint starting: fast force wait
LOG: checkpoint complete: fast force wait: wrote 52 buffers (0.3%), wrote 3 SLRU buffers; 0 WAL file(s) added, 0 removed, 0 recycled; write=0.003 s, sync=0.005 s, total=0.010 s; sync files=49, longest=0.002 s, average=0.001 s; distance=234 kB, estimate=234 kB; lsn=0/01726510, redo lsn=0/017264B8
The shutdown checkpoint is renamed the same way: shutdown immediate on PostgreSQL 18 and shutdown fast on PostgreSQL 19. Update any log-parsing rules that match the old wording.
4.3 Example 1: fast versus spread
Settings used: checkpoint_timeout = 30s, shared_buffers = 512MB, max_wal_size = 8GB, default checkpoint_completion_target (0.9). We created a 1,500,000-row table, ran a checkpoint, then updated every row to dirty the buffers and timed the command.
\timing on
CHECKPOINT (MODE FAST); -- Time: 219.408 ms
CHECKPOINT (MODE SPREAD); -- Time: 26197.207 ms (00:26.197)
| Command | PostgreSQL 18 | PostgreSQL 19 RC1 |
| CHECKPOINT (MODE FAST) | Syntax error | 0.22 s |
| CHECKPOINT (MODE SPREAD) | Syntax error | 26.2 s |
The spread checkpoint took roughly checkpoint_timeout times checkpoint_completion_target (30 s x 0.9 = 27 s). CHECKPOINT waits for completion, so the session is blocked for that whole time.
4.4 Example 2: flush the dirty buffers of an unlogged table
Settings used: shared_buffers = 128MB, bgwriter_lru_maxpages = 0, so dirty pages stay in memory. We used pg_buffercache to count the dirty buffers of a 14 MB unlogged table.
CREATE EXTENSION pg_buffercache;
CREATE UNLOGGED TABLE u_tbl AS
SELECT g, md5(g::text) AS pad FROM generate_series(1, 200000) g;
SELECT count(*) FILTER (WHERE is dirty) AS dirty, count(*) AS cached
FROM pg_buffercache
WHERE reldatabase = (SELECT oid FROM pg_database WHERE datname = current_database())
AND relfilenode = pg_relation_filenode('u_tbl');
| Moment | PostgreSQL 18 (dirty/cached) | PostgreSQL 19 RC1 (dirty/cached) |
| After creating the table | 1667 / 1728 | 1667 / 1728 |
| After CHECKPOINT; | 1667 / 1728 | 1667 / 1728 |
| After CHECKPOINT (FLUSH_UNLOGGED); | Syntax error, still 1667 / 1728 | 0 / 1728 |
A plain checkpoint left all 1667 dirty unlogged buffers untouched on both versions. Only PostgreSQL 19 with FLUSH_UNLOGGED wrote them out.
4.5 How this was handled in PostgreSQL 18
There was no SQL equivalent. CHECKPOINT was always fast, so a gentle checkpoint meant waiting for the scheduled one, and a plain manual checkpoint did not write unlogged buffers.
4.6 Practical guidance
- CHECKPOINT still requires superuser or membership in the pg_checkpoint role, and the documentation says it is not meant for normal operation.
- The server may merge concurrent checkpoint requests and combine their options. If one session asks for fast and another for spread, the server may perform one fast checkpoint.
- During recovery, CHECKPOINT still forces a restartpoint instead of a new checkpoint.
- With MODE SPREAD, your session waits as long as the checkpoint takes. Run it from a session that can wait.
| Command / State | PostgreSQL 18 | PostgreSQL 19 |
| CHECKPOINT; | Fast checkpoint | Same (equivalent to MODE FAST) |
| CHECKPOINT (MODE SPREAD); | Syntax error | Spread over the checkpoint interval |
| CHECKPOINT (MODE FAST); | Syntax error | Supported |
| CHECKPOINT (FLUSH_UNLOGGED); | Syntax error | Also writes dirty unlogged buffers |
| Unlogged buffers on plain CHECKPOINT; | Left dirty | Left dirty, unless FLUSH_UNLOGGED |
| Invalid option | Syntax error | Specific error message |
Conclusion
- GRANTED BY: state the grantor inside GRANT or REVOKE, without SET ROLE. You must inherit the named role. Check scripts that use GRANTED BY CURRENT_USER and invoked by a superuser.
- CHECK … [NOT] ENFORCED: load data under a relaxed CHECK, then enforce it with one command. It verifies existing rows under an ACCESS EXCLUSIVE lock; so it is still recommended to use NOT VALID plus VALIDATE for large, busy tables.
- SET EXPRESSION on virtual columns: no more dropping and re-creating the CHECK. No rewrite, existing rows are verified, publications are still unsupported.
- CHECKPOINT options: MODE SPREAD for a gentle manual checkpoint, FLUSH_UNLOGGED to write unlogged buffers on demand.
Credits
These features exist thanks to the PostgreSQL community. Thanks to Nathan Bossart and Tom Lane (GRANTED BY), Jian He (CHECK enforcement and SET EXPRESSION), Christoph Berg (CHECKPOINT options), and to the following committers and reviewers whose names appear in the commit messages of the official PostgreSQL repository at the REL_19_RC1 tag for creating and validating these features.
| Feature | Author | Committer | Reviewers | Commit |
| GRANT/REVOKE … GRANTED BY | Nathan Bossart (co-author: Tom Lane) | Nathan Bossart | none listed | dd1398f137, 2026-03-19 |
| ALTER CONSTRAINT … [NOT] ENFORCED for CHECK | Jian He | Andrew Dunstan | Robert Treat, Kirill Reshke, Amul Sul, Zsolt Parragi, Andrew Dunstan | 342051d73b, 2026-03-11 |
| SET EXPRESSION on virtual columns with CHECK | Jian He | Peter Eisentraut | Matheus Alcantara | f80bedd52b, 2026-02-24 |
| CHECKPOINT option list | Christoph Berg | Nathan Bossart | Fujii Masao | a4f126516e, 2025-07-11 |
| CHECKPOINT MODE | Christoph Berg | Nathan Bossart | Andres Freund, Fujii Masao, Laurenz Albe, Dilip Kumar | 2f698d7f4b, 2025-07-11 |
| CHECKPOINT FLUSH_UNLOGGED | Christoph Berg | Nathan Bossart | Laurenz Albe, Fujii Masao, Dilip Kumar | 8d33fbacba, 2025-07-11 |
References
• GRANT
• CHECKPOINT
• ALTER TABLE
• PostgreSQL 19 release notes
