The Keycloak Upgrade Ledger

Every upgrade we have rehearsed, with the environment stated and the clock running.

2026-08-25 · run 2026-08-25-26.0.0-to-26.7.1-100k

26.0.0 → 26.7.1 at 100k

Upgrade
26.0.0 → 26.7.1
Scale
100,002 users · 2 realms · 10 clients · 8 roles · 100,001 credentials · 200,001 user attributes · 100,004 admin events
Database
PostgreSQL 16.15, single node, container
Topology
single container
Host
Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30GB RAM, local NVMe
Condition
routine — no adverse condition applied
Elapsed
17.0s | 15.4s

Environment

FieldValue
Keycloak from → to26.0.0 → 26.7.1
Distributionquay.io/keycloak/keycloak official image
Start modestart --http-enabled=true w/ external DB
DatabasePostgreSQL 16.15, single node, container
Postgres tuningstockshared_buffers 128MB, maintenance_work_mem 64MB, statement_timeout 0
Dataset scale100,002 users · 2 realms · 10 clients · 8 roles · 100,001 credentials · 200,001 user attributes · 100,004 admin events
DB size264 MB before, 265 MB after
Seeding methodpartialImport, batch 2000 — 5,235s (87 min) for 100k, i.e. 19.1 users/s
Fixture profiletypical
Topologysingle container
HostHetzner CCX33 (fsn1) — 8 vCPU dedicated, 30GB RAM, local NVMe
JVM heap-Xms1g -Xmx4g
Adverse scenarionone

Timings — and the finding

Phase1k (…-26.0.0-to-26.7.1-2)100k (this run)
Quarkus started11.4s10.5s
Liquibase migration12.2s → 16.2s = 3.97s13.0s → 16.8s = 3.79s
Total to ready17.0s15.4s

A 100× increase in users produced no increase in migration time. The 100k run was marginally faster, which is run-to-run noise. This is not a small difference from expectation — it is the opposite of what the lab was built to find.

Why — CORRECTED 2026-08-25 by 2026-08-25-index-skip-threshold

The explanation below is wrong and is kept for the record. It was built by reading the descriptions of the last 22 of 67 changesets and generalising from the sample. Reading all 67 from source shows nine operations that do touch user-scaled tables, including createIndex on USER_ENTITY and on EVENT_ENTITY.

The real mechanism: Keycloak deliberately skips index creation on tables above 300,000 rows, logs a WARN, and records the changeset as EXECUTED. The migration is flat because the expensive work does not happen — not because there is no expensive work. That behaviour is far more valuable than the finding it replaces, and it was only found because the prediction tool disagreed with the measurement.

Original (incorrect) explanation: migration cost is O(schema change), not O(rows)

Reading the 67 changesets that ran, not one rewrites user data. They are:

The only reference to a large table is addForeignKeyConstraint ... referencedTableName=USER_ENTITY on a new empty table — validating an empty child, not scanning the parent.

So the duration is set by how many DDL statements run and what they touch, not by how many users exist. For this version pair the answer is "nothing large", and it would take ~4 seconds on a realm of any size.

Outcome

Breakage observed

None. Confirms the started-before-ready timing at 100k: /health/started returned 200 at 10.5s while /health/ready stayed 503 until 15.4s — a 4.9s window, essentially identical to the 5.6s measured at 1k, for the same reason the migration did not grow.

Verification

ClaimPrimary sourceChecked
No changeset rewrites user data on this pathDATABASECHANGELOG.description for all 67, read directly
Migration time flat 1k → 100kTwo instrumented runs, same box, same fixture profile
Holds at 2Mnot tested — 100k may simply be too small to expose an effect
Holds for pairs that DO touch user tablesnot tested — this is the open question

Publishable extract

We upgraded a 1,000-user Keycloak and a 100,000-user Keycloak on identical hardware. The schema migration took the same four seconds.

26.0.0 → 26.7.1, official image, Postgres 16.15 at stock settings, Hetzner CCX33 (8 vCPU dedicated, 32 GB, local NVMe), JVM -Xms1g -Xmx4g. At 1,000 users the Liquibase phase ran 3.97s; at 100,002 users, 3.79s. Total time to serving traffic was 17.0s and 15.4s respectively — the larger realm was marginally faster, which is noise.

We expected the opposite, so we read all 67 changesets. None of them rewrite user data. They create new empty tables, index those new tables, and add columns to CLIENT (10 rows) and REALM (2 rows). The only mention of USER_ENTITY is a foreign key from a new empty table.

SUPERSEDED. The real reason is in 2026-08-25-index-skip-threshold: Keycloak skips index creation above 300,000 rows and records the changeset as executed anyway. Migration is flat because the expensive work is declined, not because it is absent. Use that extract, not this one.

← All runs