PostgreSQL 18 was released on September 25, 2025, and most .NET teams are still on 16. This post runs one script on both, 16.14 and 18.4 from the official Docker images, and shows what 18 does that 16 refuses, what changed underneath without any code of yours changing, what breaks when you move, and the ten-second way to carry a development database across. Every output is quoted from the run; nothing here comes from the release notes alone.
- Start both servers:
docker run -d --name pg16 -e POSTGRES_PASSWORD=dev -p 15480:5432 postgres:16and the same withpg18, port15481andpostgres:18 - Download pg16-vs-18.sql and copy it in:
docker cp pg16-vs-18.sql pg16:/tmp/, then the same for pg18 - Run it on each:
docker exec pg16 psql -U postgres -v ON_ERROR_STOP=0 -e -f /tmp/pg16-vs-18.sql(the script detects the version and skips what 16 cannot run after showing the error) - Section 2:
uuidv7()fails on 16 and returns time-ordered ids on 18 - Section 7: the same query picks a sequential scan on 16 and the multicolumn index on 18
- Section 8: 18 prints buffer counts in
EXPLAIN (ANALYZE)without being asked - Then move a database from 16 to 18 with the one-line dump and restore at the end
Five things 16 refuses and 18 does
Time-ordered UUID primary keys: uuidv7()
If your entities use Guid keys, 16 gives you gen_random_uuid(), which is
random and scatters inserts across the primary key index. On 18, uuidv7() exists in
core, and two inserts come back in insert order with a millisecond timestamp inside the id:
01a138b2-6c6e-7264-b0f2-b5629e18c4d9 | Maria Garcia | 7 | 2026-10-14 04:36:19.694+00
01a138b2-6c70-797d-8216-42cdf9db463b | David Chen | 7 | 2026-10-14 04:36:19.696+00
On 16 the same statement, and a CREATE TABLE that uses it as a default, stop here:
psql:pg16-vs-18.sql:22: ERROR: function uuidv7() does not exist
LINE 1: SELECT uuidv7();
^
HINT: No function matches the given name and argument types. You might need to add explicit type casts.
For a .NET app the choice is where the id is made. On 18, HasDefaultValueSql("uuidv7()")
lets the database do it. On 16, .NET 9 and later can make the same kind of id on the client with
Guid.CreateVersion7(), and the database never knows the difference.
Virtual generated columns, now the default
A generated column on 16 must say STORED; the keyword VIRTUAL is a syntax
error, and leaving the keyword out is a syntax error too, reported at the closing parenthesis of
the table rather than at the column, which is a confusing place to look:
psql:pg16-vs-18.sql:53: ERROR: syntax error at or near "VIRTUAL"
LINE 6: total numeric GENERATED ALWAYS AS (price * qty) VIRTUAL
^
On 18 both forms work, and a column with no keyword is virtual: computed when read, not written.
The catalogue shows it as attgenerated = v. One limit showed up at once:
psql:pg16-vs-18.sql:68: ERROR: indexes on virtual generated columns are not supported
So a total you filter on still wants STORED. If your EF Core model uses
HasComputedColumnSql, read the migration it scaffolds against 18 before you apply it,
because the default meaning of a generated column changed between the two versions.
RETURNING OLD and NEW
An update that must record what it replaced used to need a trigger or two statements. On 18 one
statement returns both values; on 16 the parser has no idea what OLD means outside a
trigger:
psql:pg16-vs-18.sql:80: ERROR: missing FROM-clause entry for table "old"
LINE 2: RETURNING OLD.status AS old_status, NEW.status AS new_status...
^
On 18 the same statement printed booked | checked_in and then UPDATE 1.
Temporal keys: WITHOUT OVERLAPS
The clinic's rule that a room holds one booking at a time is, on 16, an exclusion constraint you
write by hand. On 18 it is part of the primary key, with the btree_gist extension
installed first:
psql:pg16-vs-18.sql:94: ERROR: conflicting key value violates exclusion constraint "room_bookings_pkey"
DETAIL: Key (room_id, during)=(1, ["2026-10-20 10:30:00+00","2026-10-20 11:30:00+00")) conflicts with existing key (room_id, during)=(1, ["2026-10-20 10:00:00+00","2026-10-20 11:00:00+00")).
A booking that starts exactly when the previous one ends is accepted, because ranges are
half-open. 16 stops at the syntax: syntax error at or near "WITHOUT". Note that 18
still calls the violation an exclusion constraint, using the primary key's name, which is worth
knowing when you map the error in C#.
Two things you also skipped if you never ran 17
MERGE … RETURNING merge_action() and JSON_TABLE arrived in PostgreSQL 17
(September 2024). On 16, MERGE itself works but the parser stops at
RETURNING, and JSON_TABLE fails at its COLUMNS clause. On 18 the
merge reported one UPDATE and one INSERT row, and the JSON query returned
the two patients from a JSON document as a table.
What changed underneath, with no code of yours changing
Section 7 builds a thousand visits with an index on (status, starts_at) and filters on
starts_at alone, the column that is not first in the index. 16 chose a sequential scan:
Seq Scan on visits (actual rows=1 loops=1)
Filter: (starts_at = '2026-10-03 10:00:00+00'::timestamp with time zone)
Rows Removed by Filter: 999
18 chose the index by default, and its plan carries a line 16 never prints:
Index Scan using visits_status_starts_at_idx on visits (actual rows=1.00 loops=1)
Index Cond: (starts_at = '2026-10-03 10:00:00+00'::timestamp with time zone)
Index Searches: 5
Buffers: shared hit=7 read=6
Planning:
Buffers: shared hit=25 read=1
That is the skip scan the release notes announce: a multicolumn B-tree index used when the leading column is not in the query. To be precise about what the run showed: with sequential scans disabled, 16 could use the same index too; the difference is that 18 chose it on its own and reports how many index searches it took. Any .NET app with a composite index and queries that do not always filter on its first column gets this for free.
Section 8 runs a plain EXPLAIN (ANALYZE). 18 adds buffer counts without being asked and
prints actual row counts with two decimals (rows=1000.00); 16 prints neither. If you
parse plans in a script, that is a change to handle. Section 9 shows the asynchronous I/O subsystem
that is new in 18: SHOW io_method answered worker on this install and is an
unrecognized parameter on 16. The same section is also the first thing that breaks.
What breaks when you move
- Monitoring queries on
pg_stat_io. 16 has anop_bytescolumn; 18 removed it and addedread_bytes,write_bytesandextend_bytes. A dashboard query that selectsop_bytesfails on 18. - The Docker image's data directory moved. 16 keeps data at
/var/lib/postgresql/data; 18 reports/var/lib/postgresql/18/dockerand wants the volume mounted at/var/lib/postgresql. Starting a 16 volume with the 18 image refuses before PostgreSQL even runs, with either mount path, and only prints the server's owndatabase files are incompatible with serverwhen you forcePGDATAby hand. Both messages have a post of their own: the data-volume refusal and the incompatible-files error. - Data checksums are on by default for new clusters in 18, and the release notes say
pg_upgraderequires matching checksum settings, so an old cluster without checksums needs the new--no-data-checksumsoption atinitdbtime or a dump and restore. - Generated columns without a keyword mean something different, as shown above.
Moving a development database: the one-liner
A major version needs a dump and restore, pg_upgrade, or logical replication. For a
development database the first is one command, run from inside the 18 container so that the newer
pg_dump reads the older server, which is the direction that is supported:
docker exec fix4-pg18 createdb -U postgres clinic
docker exec fix4-pg18 bash -c 'PGPASSWORD=fix4pass pg_dump -h host.docker.internal -p 15480 -U postgres clinic | psql -U postgres -d clinic'
The container names, port and password are the test's. The restore printed COPY 3, set
the identity sequence to 3, and the next insert on 18 got id 4, so the sequence carried over.
host.docker.internal resolved from inside the container on Docker Desktop, so no Docker
network was needed. For production, pg_upgrade is the tool, and 18's version keeps the
optimizer statistics across the upgrade, which earlier versions threw away.
Should a .NET team move?
If you create ids in the database, book time ranges, or audit updates, 18 removes code you wrote yourself. If your queries filter composite indexes on their trailing columns, 18 is faster without a change. The cost is one dump and restore per environment, a look at your monitoring queries and generated columns, and a Docker volume moved to the new mount point. Every script in the Database Design for Developers series already runs on 18.4 next to SQL Server 2022, with Npgsql and EF Core 10, so the .NET side is not the risk. The comparison with SQL Server itself is in PostgreSQL vs SQL Server for .NET developers.
Frequently asked
- What is new in PostgreSQL 18 for developers?
- Run against 16 on the same script: uuidv7() for time-ordered UUID keys, virtual generated columns as the default, RETURNING OLD and NEW in UPDATE and DELETE, temporal primary keys with WITHOUT OVERLAPS, skip scans that use a multicolumn index without its leading column, buffer counts in every EXPLAIN ANALYZE, and an asynchronous I/O subsystem. MERGE with RETURNING and JSON_TABLE came in 17 and are new too if you jump from 16.
- Should I upgrade from PostgreSQL 16 to 18?
- Yes when you create ids in the database, enforce non-overlapping time ranges, audit updates, or run queries that filter composite indexes on trailing columns; each of those gets simpler or faster. Check three things first: monitoring queries on pg_stat_io (op_bytes is gone), generated columns declared without STORED (they become virtual and cannot be indexed), and the Docker volume mount, which moves from /var/lib/postgresql/data to /var/lib/postgresql.
- How do I upgrade a PostgreSQL Docker container from 16 to 18?
- Not by pointing the 18 image at the 16 volume; the image refuses to start with either mount path. Start a new 18 container, create the database, and run pg_dump from inside the 18 container against the 16 server piped into psql, as shown in the post; the identity sequences carry over. For production use pg_upgrade, which in 18 also keeps the optimizer statistics.