pg_upgrade --check does not check extensions
It validates the core binaries and stops. Every extension you installed needs verifying by hand, and that is where major upgrades actually fail.
The standard advice for a PostgreSQL major upgrade is to run pg_upgrade --check first, confirm it passes, and proceed with confidence. The advice is fine as far as it goes. The problem is how far people assume it goes.
--check validates that the old and new clusters are compatible at the level the core server cares about. It confirms the binaries line up, the data directory is in a state it can work with, and nothing in the catalog will refuse the conversion. It does this well and it is worth running.
What it does not do is tell you whether the extensions installed in your databases have a build available for the version you are moving to.
Why that is the failure that actually happens
Nobody is upgrading a bare PostgreSQL install. There is a statistics extension, probably a scheduler, likely something for partition management, possibly a vector type, and on any cluster that has been alive for a few years there is something installed in 2021 by someone who has since left.
Each of those is a shared library compiled against a specific major version. When the server moves, the library has to move with it, and it can only do that if somebody has built it for the new version. Popular extensions usually get a build out quickly. Less popular ones sometimes take months. A few never get one at all, because the maintainer moved on.
pg_upgrade --check will pass cleanly on a cluster whose extensions cannot be carried forward, because that is not the question it was asked. You discover the answer during the upgrade, on a cluster that is already down.
The inventory nobody has
Ask a platform team which extensions are installed across their fleet and you usually get a pause, then a best guess, then someone offering to check a few clusters manually.
This is not negligence. Extensions are installed per database, not per cluster, so a complete answer means querying every database on every cluster. Nothing prompts anyone to do that, and the answer goes stale as soon as a developer enables something in a staging database that later gets promoted.
The list you need before an upgrade has four columns: the extension, the version installed, whether a build exists for the target major version, and whether anything actually uses it. That last column matters more than people expect. A meaningful proportion of extensions found in a fleet audit are installed and entirely unused, usually enabled during an evaluation that went nowhere, and the correct action is to drop them rather than to block the upgrade on them.
What else changes across a boundary
Extensions are the largest gap but not the only one. Three others catch people.
- Removed and deprecated features. Every major release retires something. If a cluster relies on an authentication method or a setting that has gone, it will not start, and it will not tell you in advance.
- Statistics. Historically a freshly upgraded database had no optimizer statistics until something analysed it, which produced an hour of terrible plans immediately after an upgrade at the exact moment everyone was watching. Recent versions can carry statistics across, which removes that cliff, but only if you use the option.
- Query identifiers. The way statements are normalised into identifiers changes occasionally. When it does, performance history keyed on those identifiers does not survive the upgrade. The queries are the same, their names are not, and your baselines are empty.
A pre-upgrade list worth having
Before any major upgrade, the questions worth answering are the ones nothing asks for you.
- Which extensions are installed, at which versions, in every database on this cluster? Does each have a build for the target, and are any of them unused and safe to drop?
- Does anything in the configuration rely on a feature the target version removed?
- Will statistics be carried across, or is there a plan for the window where there are none?
- Is anything downstream keyed on query identifiers, and has a baseline been captured?
None of that is difficult. All of it is tedious, all of it is per-cluster, and the tedium is why it does not get done until the upgrade is already underway.
Why this is getting more urgent
PostgreSQL supports each major version for five years, and the community holds that line. Version 13 passed its end of life in late 2025, which means a meaningful number of production clusters are now running an unsupported major with no security patches coming.
The audit queries themselves, and the order the steps have to happen in, are set out in pg_upgrade and extensions.
Those clusters are the ones most likely to have accumulated old extensions from a long life, and therefore the ones most exposed to exactly this gap. The upgrades that have been deferred longest are the ones where --check passing means the least.