Postgres 14 Ends 12 November. Skip 19, Take 18.
Postgres 14 receives its final fix on 12 November. Postgres 19's GA has slipped to late October at best. Upgrading to a two-week-old major release to escape an EOL date is the wrong trade.

PostgreSQL 14 receives its final bug fix on 12 November 2026. PostgreSQL 19, which most teams assume is the destination, will not be ready in time to be a sensible one: the release management team moved Beta 4 to 24 September and is now aiming for general availability by the end of October, roughly two weeks before the 14 deadline. Putting a production database on a two-week-old major release in order to escape an end-of-life date is trading a known risk for an unknown one. The upgrade to run this quarter is 14 to 18. Below is the readiness check, the runbook, and the reason the newest version is almost never the right target.
Why this matters now
Start with two dates. PostgreSQL's versioning policy lists 14's final release as 12 November 2026, and the minor release roadmap shows that the next scheduled minor train after it lands on 11 February 2027. So 14 gets one last set of fixes on 12 November and then nothing, ever. The database keeps running. It stops being patched.
The second date is softer and more consequential. On 3 September the release management team posted to pgsql-hackers that PostgreSQL 19 Beta 4 would ship on 24 September — later than normal, "primarily due to handling the influx of issues that have been reported" — with a stated goal of getting 19 to GA "by the end of October." The project's own roadmap page still says September. A fourth beta three weeks out from the GA target is not a crisis; it is a release team choosing quality over the calendar, which is the correct call and one of the reasons Postgres is worth building on. It is also a clear signal about what you would be putting underneath your production data in November.
Then there is the date almost nobody looks up: your provider's. AWS documents that RDS for PostgreSQL 14 reaches end of standard support on 28 February 2027, with Extended Support year-one pricing starting 1 March 2027. The same page states the availability policy for new majors: within 30 days of the community release of the first minor, meaning 19.1 rather than 19.0. PostgreSQL 18 followed exactly that path — community GA on 25 September 2025, 18.1 in the November train, RDS availability on 14 November 2025. Project that onto 19 and the earliest plausible RDS availability sits somewhere between mid-November 2026 and the February 2027 train. That is a projection from published policy and one precedent, not an announced date. But it means that for a large number of teams, "upgrade to 19" is not an option that exists before the billing changes.
Pick the oldest version that outlives your horizon
You do not need the newest Postgres. You need the oldest supported Postgres whose final release date is past your planning horizon, because that is the one with the most bugs already found.
That inverts the instinct most teams bring to an EOL. The instinct says: I am doing a painful major upgrade, so I should jump as far forward as possible and buy myself the longest runway. The problem is that the runway you buy at the front of a release cycle is paid for with the bugs nobody has found yet, and you are paying in production. Four questions settle it.
- When does my current version stop getting fixes? Both dates — the community date and your provider's. The earlier one that costs you money is the one you plan against.
- What is my planning horizon, honestly? Not "forever." When will I next willingly spend a maintenance window on a major upgrade? For a small team the honest answer is two to three years.
- Which is the oldest supported major whose final release is past that horizon? This is a lookup, not a judgement call.
- Does the newer version have something I need this year that the older one lacks? Not something I would enjoy. Something a workload currently in production needs. If not, take the older one.
For question three, the versioning policy gives the whole answer in one table. Supported majors as of today, with the date each stops receiving fixes:
- PostgreSQL 15 — released 13 October 2022, currently 15.19, final release 11 November 2027.
- PostgreSQL 16 — released 14 September 2023, currently 16.15, final release 9 November 2028.
- PostgreSQL 17 — released 26 September 2024, currently 17.11, final release 8 November 2029.
- PostgreSQL 18 — released 25 September 2025, currently 18.6, final release 14 November 2030.
- PostgreSQL 19 — not released. Beta 4 on 24 September, GA targeted for the end of October, zero minor releases shipped.
A team on 14 in September 2026 with a three-year horizon resolves to 18. It has been in production for a year, it is on its sixth minor release, and its support window runs to November 2030 — four more years, comfortably past the horizon you just admitted to. And you can go straight there: the versioning policy is explicit that you can upgrade from one major version to another without passing through the intervening ones, though it recommends reading the release notes for each.
What pg_upgrade --check will not tell you
Run pg_upgrade --check. It verifies cluster compatibility, it leaves the old cluster untouched, and it outlines the manual adjustments you will need afterwards. It will not surface three things that reliably turn a ninety-minute window into a five-hour one — and all three are knowable today, from a read-only connection, weeks before you touch anything.
The first is the extension matrix. pg_upgrade checks that the binaries are compatible; it cannot check that the package repository for your target major actually ships a build of every extension you have installed. The docs are clear that shared object files matching the new server must be installed in the new cluster before you start, and that pg_upgrade will report available extension updates and generate a script for them. What it cannot do is tell you an extension has no build at all on the target — that discovery happens after cutover.
The second is buried at the bottom of the pg_upgrade page and it is an outright refusal, not a warning: pg_upgrade does not support upgrading databases containing table columns that use regcollation, regconfig, regdictionary, regnamespace, regoper, regoperator, regproc or regprocedure. (regclass, regrole and regtype are fine.) These types are rare and catastrophic when present, because the remedy is a schema change on a live table rather than a flag on the command line.
The third is replication slots. The documentation states that if the old primary is prior to version 17.0, no slots on the primary are copied to the new standby, so all slots must be recreated manually. You are upgrading from 14. If a CDC pipeline or a logical subscriber reads from one of those slots, its absence after cutover is silent until somebody notices the lag.
Here is the audit, in about a hundred and twenty lines. Every query is a catalogue read.
// upgrade-readiness.ts — what `pg_upgrade --check` will not tell you.
//
// Read-only. Run it against the live PostgreSQL 14 cluster weeks before the
// maintenance window. Every statement here is a catalogue read; none of them
// take a lock that matters.
//
// PGURI=postgres://... npx tsx upgrade-readiness.ts
import { Client } from 'pg'
/** The failure classes that turn a 90-minute window into a five-hour one. */
type Finding =
| { kind: 'extension'; severity: 'blocker' | 'review'; detail: string }
| { kind: 'reg-type-column'; severity: 'blocker'; detail: string }
| { kind: 'replication-slot'; severity: 'review'; detail: string }
| { kind: 'analyze-window'; severity: 'info'; detail: string }
/**
* 1. The extension matrix.
*
* pg_upgrade verifies that the *binaries* are compatible. It cannot verify
* that your target major's package repository ships a build of every extension
* you have installed. That is the most common reason an upgrade gets rolled
* back at 2am: the new cluster comes up and one extension has no matching
* shared object file.
*/
const EXTENSIONS = `
SELECT e.extname,
e.extversion AS installed,
a.default_version AS available
FROM pg_extension e
LEFT JOIN pg_available_extensions a ON a.name = e.extname
ORDER BY e.extname
`
/**
* 2. The reg* columns.
*
* The pg_upgrade docs are explicit: it does not support upgrading databases
* containing table columns using regcollation, regconfig, regdictionary,
* regnamespace, regoper, regoperator, regproc or regprocedure.
* (regclass, regrole and regtype upgrade fine.)
*
* Rare, and a hard stop when present. The fix is a schema change, not a flag,
* so you want to find it in September rather than inside the window.
*/
const REG_COLUMNS = `
SELECT c.relname AS table_name,
a.attname AS column_name,
t.typname AS type_name
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_type t ON t.oid = a.atttypid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE a.attnum > 0
AND NOT a.attisdropped
AND c.relkind IN ('r', 'm', 'p')
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
AND t.typname IN (
'regcollation', 'regconfig', 'regdictionary', 'regnamespace',
'regoper', 'regoperator', 'regproc', 'regprocedure'
)
`
/**
* 3. Replication slots.
*
* From a pre-17.0 primary, no slots are copied to the new standby — every slot
* has to be recreated by hand after cutover. If a CDC pipeline reads from one,
* its absence is silent until someone notices the lag.
*/
const SLOTS = `
SELECT slot_name, slot_type, database, active,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
) AS retained_wal
FROM pg_replication_slots
`
/**
* 4. Sizing the post-upgrade ANALYZE.
*
* PostgreSQL 18's pg_upgrade carries most optimizer statistics across, which
* is new. It does not carry extended statistics (CREATE STATISTICS), extension
* statistics, or the cumulative statistics system. This sizes the work the two
* documented vacuumdb passes still have to do.
*/
const STATS_DEBT = `
SELECT c.relname,
pg_size_pretty(pg_total_relation_size(c.oid)) AS size,
(SELECT count(*) FROM pg_statistic_ext x WHERE x.stxrelid = c.oid)
AS extended_stats
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 10
`
export async function audit(connectionString: string): Promise<Finding[]> {
const client = new Client({ connectionString })
await client.connect()
const findings: Finding[] = []
try {
const { rows: exts } = await client.query(EXTENSIONS)
for (const e of exts) {
if (!e.available) {
findings.push({
kind: 'extension',
severity: 'blocker',
detail: `${e.extname} ${e.installed} is installed but unavailable on this host — confirm a build exists for the target major before scheduling anything`,
})
} else if (e.available !== e.installed) {
findings.push({
kind: 'extension',
severity: 'review',
detail: `${e.extname} ${e.installed} -> ${e.available}; pg_upgrade will emit an ALTER EXTENSION script — run it, do not skip it`,
})
}
}
const { rows: regs } = await client.query(REG_COLUMNS)
for (const r of regs) {
findings.push({
kind: 'reg-type-column',
severity: 'blocker',
detail: `${r.table_name}.${r.column_name} is ${r.type_name} — pg_upgrade refuses this; change the column type first`,
})
}
const { rows: slots } = await client.query(SLOTS)
for (const s of slots) {
findings.push({
kind: 'replication-slot',
severity: 'review',
detail: `${s.slot_name} (${s.slot_type}, active=${s.active}, ${s.retained_wal} WAL retained) — upgrading from 14, this is not copied; recreate it after cutover`,
})
}
const { rows: tables } = await client.query(STATS_DEBT)
const withExtended = tables.filter((t) => Number(t.extended_stats) > 0)
findings.push({
kind: 'analyze-window',
severity: 'info',
detail:
`largest table ${tables[0]?.relname} at ${tables[0]?.size}; ` +
`${withExtended.length} of the top 10 carry extended statistics that ` +
`pg_upgrade will not transfer`,
})
} finally {
await client.end()
}
return findings
}
/** Blockers are scheduling decisions, not window decisions. Sort accordingly. */
export function report(findings: Finding[]): string {
const rank = { blocker: 0, review: 1, info: 2 } as const
return findings
.sort((a, b) => rank[a.severity] - rank[b.severity])
.map((f) => `[${f.severity.toUpperCase()}] ${f.kind}: ${f.detail}`)
.join('\n')
}Blockers change the schedule; reviews change the runbook. That distinction is the only reason to run this in September rather than the night before — a missing extension build found six weeks out is a packaging task, and the same finding at 01:30 is a rollback.
One thing has genuinely changed, and it is the best argument for 18 specifically. Unless you pass --no-statistics, pg_upgrade now transfers most optimizer statistics from the old cluster to the new one. If your mental model of a major upgrade includes "and then the database is slow for two hours while ANALYZE catches up," that model predates 18. But "most" is carrying weight: the documentation names what does not come across — statistics created explicitly with CREATE STATISTICS, custom statistics added by an extension, and statistics collected by the cumulative statistics system. So the post-upgrade sequence is still two commands, both spelled out in the docs: vacuumdb --all --analyze-in-stages --missing-stats-only first, to get minimal statistics onto relations that have none, then vacuumdb --all --analyze-only so everything has current cumulative statistics.
What a founder or CTO does with this
The risk of running an unsupported database is not that it breaks. Postgres 14 will run perfectly well on 13 November, and on 13 November 2028. The risk has three shapes, and none of them is an outage.
The next CVE in your major version has no patch and no date for one, so your remediation plan becomes "upgrade under pressure" — the exact situation this quarter exists to avoid. Your managed provider starts charging a premium to keep patching it on your behalf; on RDS that is Extended Support, and it begins on 1 March 2027 whether or not anyone on the team noticed. And every security questionnaire, SOC 2 review and enterprise procurement checklist you fill in next year contains a question about supported software versions that you now answer with a no, or with a paragraph. The paragraph is the expensive one, because it comes back in every deal.
None of that is an emergency. All of it is cheaper to handle in October than to explain in March.
The five-step runbook
- Fix both dates and book the window. Community EOL and provider end-of-standard-support, written down where the team can see them. Treat the upgrade as a project with an owner, not a chore that migrates between sprints until it becomes urgent.
- Pick the target by horizon, and write down why. Four questions, ten minutes. Record the reasoning, because in six months somebody will ask why you are not on 19 and "we chose the oldest version that outlives our horizon" is a better answer than trying to reconstruct it.
- Run the readiness audit, then --check against a restored copy. Not against production. The docs recommend creating a schema-only copy of the old cluster, inserting dummy data, and upgrading that. Pass the mode flag you actually intend to use — the mode-specific checks only run when you supply --link, --clone, --copy-file-range or --swap.
- Rehearse the revert, not just the upgrade. This is the step teams skip, and the docs give four different revert stories depending on mode. If you used --link and you have started the new cluster, the old one is unsafe and you are restoring from backup. If you used --swap and pg_upgrade reported the old cluster is no longer safe to start, likewise. Know which sentence applies to you before the window opens, not during it.
- Plan the statistics pass and run the generated scripts. pg_upgrade emits post-upgrade scripts for anything needing rebuild, and the docs warn that it is unsafe to touch tables referenced in those scripts until they finish. Budget the two vacuumdb passes explicitly, with --jobs set, rather than discovering them at the end of a long night.
My perspective
Six weeks ago I wrote that Postgres 19 was worth upgrading for — specifically for parallel index vacuuming and autovacuum prioritization in a shared-schema multi-tenant database, and specifically not for the REPACK feature every roundup was leading with. I still think those are the right reasons to want 19. I no longer think this is the quarter to take it, and what changed is not the features. It is the calendar.
That distinction is worth more than the specific recommendation. "Is this version good?" and "is this the version I should deploy in November?" are different questions, and engineers reliably answer the first when asked the second. I have done it. The version-evaluation instinct is a feature-comparison instinct, and a deadline-driven upgrade is not a feature decision at all — it is a risk-transfer decision, where the only question that matters is which unknowns you are willing to hold while the clock runs.
I run twelve services on Keaz with no platform team, which means every maintenance window is mine and every rollback is a night I do not get back. That constraint has made me steadily more boring about infrastructure versions and steadily more aggressive about infrastructure patch cadence. Those sound like opposites and they are the same discipline: stay current on the minors, where the community has already absorbed the risk for you, and stay behind on the majors until somebody else has run the .0 in anger.
The honest counter-argument is that the 18 path costs you a year. Taking 19 buys support until roughly November 2031; taking 18 buys until November 2030, so you will do this again one cycle sooner. If you are greenfield with no production traffic, or your binding constraint genuinely is one of the things 19 fixes and you have a quarter to run it in staging first, take 19 when it ships. What I would not do is let an end-of-life date push me onto a release that has not had a single minor version yet. Those are two different projects, and running them as one is how a deadline turns into an incident.
Recommended action this quarter
Three things, in order. Run the readiness audit this week — it is read-only, it takes twenty minutes, and a blocker found now is a scheduling problem rather than a rollback. Look up your own provider's end-of-standard-support date and the Extended Support price rather than assuming the community date is the one that binds you; on RDS those are 28 February 2027 and 1 March 2027 respectively, and they are not the same as 12 November. Then book the cutover for October. November is the month the final 14 patch lands and the month everyone else discovers the same deadline, and a maintenance window is much easier to staff before that happens than during it.
If you are running a major upgrade under a deadline and want a second opinion on the target version or the revert plan, that review is part of the fractional CTO and architecture work I take on.
Get the upgrade off the backlog before November
If you are on Postgres 14 and have not picked a target version or rehearsed the revert, working out which one you need usually takes an afternoon. Book a time and we will size it together.
Keep reading
Postgres 19 for Multi-Tenant SaaS: Skip REPACK, Tune Autovacuum
Every Postgres 19 roundup leads with REPACK CONCURRENTLY. For a shared-schema multi-tenant SaaS it is the wrong feature — and the one that matters ships disabled by default.
How I Patch Next.js Security Releases Without a Security Team
Next.js now ships pre-announced monthly security patches — four high-severity CVEs in the first batch. The exposure triage and patch runbook I run to absorb them with no security hire.
How I'd Architect Multi-Tenant Postgres for a SaaS in 2026
Postgres row-level security, the seven footguns that leak tenant data, and a seven-point checklist before you ship your next multi-tenant feature.