Engineering 8 min read

What Postgres 18 Changes for a Django App

Papan Sarkar
Papan Sarkar

The first thing that happens when you point pg_upgrade at a fresh PostgreSQL 18 cluster is, quite often, nothing. It refuses. That refusal is the most useful thing in this release for anyone running a Django app, because it forces you to look at a default that changed underneath you — and there are four more like it.

PostgreSQL 18 was released on 25 September 2025. Django 6.0 arrived that December and supports PostgreSQL 14 and higher, so nothing in the ORM forces the move. What follows is what I actually check before moving a Django project onto 18, in the order the problems show up.

The upgrade refuses before it starts

initdb in 18 now defaults to enabling data checksums, with a new --no-data-checksums flag to turn them off. pg_upgrade requires that the old and new clusters agree on checksum settings. If your existing cluster was initialised without checksums — which is every cluster created by an older initdb that nobody thought about — the new 18 cluster will not match it, and the upgrade stops.

Two ways out, and they are not equivalent:

# A: accept the old cluster's setting, keep the upgrade short
initdb --no-data-checksums -D /var/lib/postgresql/18/main

# B: turn checksums on in the old cluster first, then upgrade
pg_ctl -D /var/lib/postgresql/16/main stop
pg_checksums --enable -D /var/lib/postgresql/16/main

Option B is the one you want long term, but pg_checksums requires the server to be shut down cleanly and rewrites every block with a changed checksum in place, so on a large database it turns a short maintenance window into a long one. On a client project with a tight window I take option A, note it in the runbook, and enable checksums on a later, quieter night. Deciding that during the outage is how windows get blown.

While you are in pg_upgrade, two new options are worth reading about: --swap, which swaps directories instead of copying or linking files, and parallel database checks via the existing --jobs option. --swap is the fastest mode and also the least reversible, which is the trade-off you would expect.

Asynchronous I/O is on, with three worker processes

The headline feature is the new asynchronous I/O subsystem, which the release announcement describes as giving up to 3x performance improvements when reading from storage for sequential scans, bitmap heap scans and vacuum.

The part that matters operationally is the configuration. io_method defaults to worker, with io_uring available only in a build compiled against liburing, and sync to fall back to the old behaviour. It can only be set at server start. The number of I/O worker processes is controlled by io_workers, which defaults to 3, and io_combine_limit defaults to 128kB.

So on a busy machine you inherit a small pool of I/O workers you never sized. Check what you actually got before assuming the benchmark applies to you:

SHOW io_method;
SHOW io_workers;
SHOW io_combine_limit;

This is the kind of setting that a managed provider may pin for you, so check it on the instance rather than in your own postgresql.conf. And treat the improvement as a hypothesis about your workload: the operations that benefit are large sequential reads and vacuum, which is a fine description of an analytics query over a fact table and a poor description of a Django admin page fetching forty rows by primary key.

Skip scan, and the index it will not save

For years the rule was that a composite B-tree index on (a, b) was useless to a query that filtered only on b. PostgreSQL 18 relaxes that: the planner can now use a multicolumn B-tree index when there is no equality constraint on the leading column but there is one on a later column, by repeatedly searching for each value of the leading column.

The caveat in the same page is the whole story, and it is the opposite of what the feature summaries imply. The docs say this approach is taken when there are so few distinct leading values that the scan skips most of the index, and that if there are many distinct values the entire index has to be scanned, at which point the planner generally prefers a sequential scan.

That flips the obvious Django use case on its head. Take the index every multi-tenant schema has:

from django.db import models
from django.db.models.functions import Now


class Invoice(models.Model):
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
    status = models.CharField(max_length=16)
    created_at = models.DateTimeField(db_default=Now())

    class Meta:
        indexes = [
            models.Index(fields=["tenant", "created_at"]),
            models.Index(fields=["status", "created_at"]),
        ]

A cross-tenant report filtering only on created_at will not be rescued by the first index once you have a few thousand tenants — too many distinct leading values. The second index is the candidate: status has a handful of values, so a query filtering only on created_at can plausibly skip through it. Same feature, same table, opposite outcome, decided entirely by cardinality.

The practical move is not to delete any indexes. It is to take the three or four queries where you previously added a redundant single-column index purely to work around the leftmost rule, and re-run EXPLAIN (ANALYZE, BUFFERS) on 18 to see whether the composite index now covers them. If it does, the redundant index is write amplification you can drop. If it does not, you learned that for the price of one query plan.

uuidv7() as a database default

PostgreSQL 18 adds uuidv7(), which generates timestamp-ordered UUIDs, along with a uuidv4() alias. Time-ordered keys are the fix for the classic random-UUID primary key problem, where inserts scatter across the B-tree instead of appending to the right-hand edge.

Django has no built-in wrapper for it — django.contrib.postgres.functions ships RandomUUID, which returns a version 4 UUID. You write the function yourself, and hand it to db_default, which accepts a database function:

from django.db import models
from django.db.models import Func


class UUIDv7(Func):
    function = "uuidv7"
    arity = 0
    output_field = models.UUIDField()


class Event(models.Model):
    id = models.UUIDField(
        primary_key=True,
        db_default=UUIDv7(),
        editable=False,
    )

There is a second option now, which is why this is a decision rather than a recipe: Python 3.14 added uuid.uuid7(), generating a time-based UUID per RFC 9562. Generating the key in Python means you know the id before the INSERT returns, which is what you want when you are writing related rows in one transaction or pushing an id into a queue payload. Generating it in the database means every writer gets ordered keys — including COPY, a data migration written in raw SQL, and whatever the analytics team runs at two in the morning. I default to the database for anything with more than one writer, and to Python where the application needs the id first.

Neither choice is free of the obvious caveat: a v7 UUID embeds a timestamp, so exposing one in a public URL leaks the creation time of the row. That is usually harmless and occasionally is not.

Generated columns went virtual, and Django did not notice

In 18, generated columns are now virtual by default, computing values when read rather than when written, with STORED still available explicitly.

This does not change Django-managed columns, because Django never leaves it to the default: GeneratedField documents that PostgreSQL only supports persisted columns, so db_persist=True is the only setting that works there and the emitted DDL says so. Where it bites is the RunSQL migration somebody wrote by hand two years ago that says GENERATED ALWAYS AS (...) with no STORED. On 16 that was a syntax error and you fixed it at the time. On 18 it succeeds and quietly gives you a virtual column — a column your Django model cannot represent, and one that cannot be indexed the way the stored version could.

grep -rn "GENERATED ALWAYS AS" --include="*.py" */migrations/

Run that before the upgrade, not after.

What this adds up to

Nothing here is a reason to upgrade on its own, and the 3x read number is not going to show up on your dashboard because you moved a Django CRUD app to a new major version. The reasons to go are the ordinary ones — support windows, your managed provider’s roadmap, uuidv7() if you were about to build it yourself in Python anyway.

The reason to read the release notes rather than a feature list is that three of the five items above are changed defaults, not new features. Checksums, io_method and virtual generated columns all behave differently on a cluster where you changed nothing. A major-version upgrade is the one moment when reading a changelog for defaults pays for itself, and it is the part everyone skips in favour of the benchmark headline.

Sources