PostgreSQL 19 - new SQL surface and performance changes
Not everything in a major release is a headline. This page collects the SQL additions that will
show up in application code once 19 ships - new clauses, new COPY behaviour, and one planner
change (NOT IN) that is invisible until you read the plan. The I/O side of the release has its
own page in asynchronous I/O; the smaller executor
speedups are summarised on the overview. Everything
below is from the beta documentation.
INSERT … ON CONFLICT DO SELECT
ON CONFLICT used to have two outcomes: DO NOTHING (skip silently) or DO UPDATE (write the
conflict away). 19 adds DO SELECT: return the conflicting row, optionally locking it, without
modifying it:
INSERT INTO accounts (id, balance) VALUES (42, 100)
ON CONFLICT (id) DO SELECT FOR UPDATE
RETURNING *;
If the insert succeeds you get the new row; if it conflicts you get the existing row under a
FOR UPDATE lock (NO KEY UPDATE, SHARE and KEY SHARE are also accepted), and a WHERE
condition can filter which conflicts are returned at all. This is the “upsert-or-read” round trip
collapsed into one statement - an idempotent “get or create”. Privileges: DO SELECT needs SELECT
on the table; adding FOR UPDATE/SHARE needs UPDATE on at least one column too.
Window functions: IGNORE NULLS
IGNORE NULLS and RESPECT NULLS are now supported on lead(), lag(), first_value(),
last_value() and nth_value():
SELECT ts,
last_value(price) IGNORE NULLS
OVER (ORDER BY ts ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS last_known
FROM quotes;
IGNORE NULLS answers “the most recent non-null value”, which previously took a subquery or a
FILTER workaround. Default remains RESPECT NULLS.
COPY grows up
Four changes to COPY in 19:
- JSON output:
COPY ... TO (FORMAT JSON)emits one JSON object per row;FORCE_ARRAYwraps the whole result in a single JSON array. JSON is output-only (COPY TO). Caveat documented: a SQLNULLand a JSON column containing the literalnullare indistinguishable in the output. - Bad input to NULL:
COPY FROM ... (ON_ERROR SET_NULL)storesNULLfor invalid input values instead of failing or skipping - distinct fromON_ERROR ignore, which skips the row. - Multi-line headers:
HEADERonCOPY FROMaccepts a non-negative integer, discarding that many lines;HEADER MATCHrequires the first line to name the table’s columns exactly, in order. Previously only a single header line could be skipped. - Partitioned tables:
COPY TOcan now dump a partitioned table directly instead of needingCOPY (SELECT ...). The release notes note this also speeds up logical replication table sync. One restriction to know:COPY FROM ... WHEREcan no longer reference system columns (xmin,ctid) - their values were never well-defined there.
COPY (SELECT * FROM events WHERE ts >= '2026-01-01') TO '/tmp/events.json' (FORMAT JSON, FORCE_ARRAY);
COPY measurements TO '/tmp/measurements.csv' (HEADER); -- partitioned table, directly
COPY staging FROM '/tmp/dirty.csv' (FORMAT CSV, HEADER 2, ON_ERROR SET_NULL);
Smaller SQL and type additions
| Addition | Detail |
|---|---|
GRANTED BY |
GRANT/REVOKE can specify the effective role performing the grant, for administrators acting on behalf of another role |
CHECK constraint ENFORCED toggle |
ALTER TABLE ... ALTER CONSTRAINT ... [NOT] ENFORCED now works for CHECK constraints, not just foreign keys - a NOT ENFORCED constraint is documented but not checked |
oid8 |
New 64-bit unsigned integer type |
| jsonpath string methods | ltrim(), rtrim(), btrim(), lower(), upper(), initcap(), replace(), split_part() - immutable like their text counterparts |
random(min, max) for temporal types |
date, timestamp and timestamptz variants join the numeric ones |
base64url and base32hex encodings |
New encode()/decode() formats; base32hex retains ordering, unlike base32 |
error_on_null(x) |
Returns x, or raises an error on NULL |
bytea/uuid casts |
Direct casts between the two types |
regdatabase |
Cast between database names and oids |
tid_block(), tid_offset() |
Extract block number and offset from a tid |
range_minus_multi() / multirange_minus_multi() |
Range subtraction that returns the gaps as a set of ranges |
The planner change hidden in your WHERE clause
NOT IN can now be planned as an ANTI JOIN when the planner can prove NULLs cannot occur - more
LEFT JOINs convert to anti joins too, and Memoize applies to anti joins with unique inner sides.
The caveat deserves emphasis: the transform fires only when NULLs are not present, which is
exactly the case where NOT IN already returns surprising results. NOT EXISTS remains the robust
formulation; the transform removes the performance argument against it rather than the correctness
one.
The rest of 19’s performance work - faster foreign-key checks, radix sort, SIMD COPY FROM parsing,
targeted NOTIFY wakeups, scans setting pages all-visible - is summarised in the
overview’s performance section, and the I/O
worker autoscaling plus EXPLAIN (ANALYZE, IO) is on the
asynchronous I/O page.
What changed, and in which release
| Behaviour | PG 18 and earlier | PG 19 |
|---|---|---|
| Upsert that also returns the row | DO UPDATE with a no-op SET, or a second query |
ON CONFLICT DO SELECT [FOR UPDATE] |
| Window “last non-null” | Subquery or FILTER workarounds |
IGNORE NULLS |
COPY TO output formats |
text, csv, binary | plus json with FORCE_ARRAY |
| Bad COPY input rows | Fail, or skip with ON_ERROR ignore |
ON_ERROR SET_NULL writes NULLs |
COPY FROM header lines |
At most one | Integer count or MATCH |
COPY TO on partitioned tables |
Required COPY (SELECT ...) |
Direct |
ALTER CONSTRAINT ... [NOT] ENFORCED |
FK only | Also CHECK |
NOT IN planning |
Subplan/antijoin limits | Converted to ANTI JOIN when NULLs impossible |
Where it breaks
ON CONFLICT DO SELECTreturning fewer rows than expected - a row locked but filtered out by theWHEREcondition is not returned; checkRETURNINGoutput rather than assuming row count.- JSON COPY ambiguity -
'null'::jsonand SQLNULLproduce identical output; do not ingest the JSON back expecting to distinguish them. ON_ERROR SET_NULLmasking data problems - invalid input becomesNULLsilently; pair it withREJECT_LIMITor a post-load audit on nullable columns.- Beta caveat - all syntax above is from the 19 beta documentation.
What this is not for
- Replacing
NOT EXISTSdefensively - the anti-join transform only fires when the planner can prove no NULLs; keep writingNOT EXISTSwhere correctness matters regardless. - JSON export as a stable interchange format -
COPY ... FORMAT JSONloses the null-versus-JSON-null distinction; use aSELECTto JSON function composition if you need explicit null markers.
PostgreSQL release support
5 supported| Release | Latest | Released | End of life | Status |
|---|---|---|---|---|
18 | 18.6 | Sept 2025 | Nov 2030 | Supported |
17 | 17.11 | Sept 2024 | Nov 2029 | Supported |
16 | 16.15 | Sept 2023 | Nov 2028 | Supported |
15 | 15.19 | Oct 2022 | Nov 2027 | Supported |
14 | 14.24 | Sept 2021 | Nov 2026 | Supported |
13 | 13.23 | Sept 2020 | Nov 2025 | End of life |
12 | 12.22 | Oct 2019 | Nov 2024 | End of life |
11 | 11.22 | Oct 2018 | Nov 2023 | End of life |
Data as of 2026-10-01 from endoflife.date. Refresh with node scripts/fetch-versions.mjs.
What to watch
Specific things that would change what this page concludes.
- Whether the beta-only libpq protocol grease parameters affect anything after GA - they stop being sent at release but the parameter reservations remain
Where PostgreSQL sits
How this component relates to the rest of the stack. Items without a link are not covered here yet.