The number a customer most wants before a Keycloak upgrade is migration duration against their dataset size - the number that sizes the maintenance window, and the number nobody publishes. We measured it, and the answer is not what we expected.
Upgrading 26.0.0 → 26.7.1 on identical hardware, official image, Postgres 16 at stock settings:
| Users | Liquibase phase | Time to serving |
|---|---|---|
| 1,000 | 3.97s | 17.0s |
| 100,002 | 3.79s | 15.4s |
| 2,000,003 | 3.84s | 15.6s |
(Method note: the 2M realm was seeded by direct SQL, the two smaller ones through Keycloak's own write path via partialImport - same row counts and column contents, different provenance. It does not change the timing result, and we record it because it is the kind of difference that could have.)
A 2,000-fold increase in users changes nothing. We expected the opposite. The lab was built to find the opposite, and our first explanation for the flatness was wrong.
The explanation we published, and then retracted
We read the descriptions of the last 22 of the 67 changesets, saw createTable on new empty tables and addColumn on tiny tables, and concluded "no changeset touches user data." That was wrong. Read in full, the path contains index builds on EVENT_ENTITY and on USER_ENTITY itself. The migration should scale. The reason it does not is more interesting, and more commercially useful, than the explanation it replaced.
Keycloak declines its own expensive DDL
Above 300,000 rows on a table, Keycloak's CustomCreateIndexChange skips creating a new index. It logs two WARN lines, records the changeset as EXECUTED, and moves on. The migration is flat because the expensive work is skipped. The expensive work still exists. (We bisected that threshold to exactly 300,000 builds / 300,001 skips, on both EVENT_ENTITY and USER_ENTITY.)
So "migration duration against a stated dataset size" is the right format for a claim, but the causal story is which changesets run, and that is decided by a number you are not watching.
The number is a cached estimate, read raw
What we had not established is where the 300,000-row test gets its row count. It is pg_class.reltuples, the planner's cached estimate, and it is read raw, not as a SELECT count(*), and not even as the planner's own derived estimate (which rescales reltuples by page count).
The proof used a lighter 2,000,003-user fixture than the table's, restored at 3,026 MB, whose untouched control migrates in 1.69s. On it we changed one catalogue row to claim USER_ENTITY held 1,000 rows, and changed nothing else. Keycloak built the index, a real CREATE INDEX on two million rows, and the Liquibase phase went from 1.69s to 3.51s. The first non-flat migration this lab has produced, at identical true scale, from the same baseline, by lying to the planner about one number.
reltuples is refreshed only by ANALYZE, VACUUM and index builds. A table that has grown since autovacuum last visited carries a stale, low estimate. So two databases of identical size can take different paths through the same upgrade, and neither operator has any way to predict which. Your staging rehearsal tells you about production only if the statistics match - and nobody checks that.
We also bounded the scary version of this. Stock autovacuum corrects a bulk load while it is still running (we watched a 73-second load get analyzed mid-flight). Reaching an estimate stale enough to matter needs two ordinary choices together: autovacuum_enabled=false on the large table, plus a loader that skips ANALYZE, which most loaders are. Real, and narrow enough to never be described as the default.
The trade-off is smaller than it looks
What is Keycloak actually avoiding by skipping the index? On two million rows the index builds in 754 ms plain (969 ms CONCURRENTLY), occupies 14 MB, and raising maintenance_work_mem from 64 MB to 1 GB changes the build by 1.5%. Keycloak is avoiding a sub-second ACCESS EXCLUSIVE lock on USER_ENTITY during startup. A defensible trade, and worth knowing that the price of it is an index your database may never get.
The check that verifies the index is satisfied by the name
The follow-on is the part we did not go looking for. Keycloak's warning tells operators to create the skipped index by hand, and doing so is safe: the next upgrade sees it, marks the changeset MARK_RAN, and warns about nothing. We verified that, because "the changeset will try to create an index that already exists" was a real risk.
But the precondition matches on (table, name) and nothing else. We created an index called IDX_USER_CREATED_TIMESTAMP on the wrong columns - one column instead of two, and upgraded. Keycloak marked the changeset satisfied, emitted zero warnings, and its own DatabaseIndexChecker reported the database correct. The index Keycloak wanted does not exist; the one that does will not serve the query it was added for; nothing will ever say so again. And unlike the skip, this state does not even leave a warning in the log.
DatabaseIndexChecker is weaker still: it matches on the index name alone, so a same-named index on a completely unrelated table also satisfies it. The only place that failure surfaces is when the build is forced, and then it is fatal:
ERROR: relation "idx_user_created_timestamp" already exists
Postgres index names are unique per schema, and Liquibase's precondition is scoped per table. The two facts do not compose. A same-named index on another table is invisible to the precondition and fatal to the CREATE. The upgrade dies at changeset 187 of 211 with migration_model still on the old version. This is the third route this lab has found to a half-applied changelog, and it stops at the same changeset as disk exhaustion.
What to do about it
- Capture
reltuplesin the pre-flight, alongside the true row counts. They are different numbers, and it is the estimate that decides what the upgrade does.ANALYZEthe large tables before the window. It costs seconds and makes the upgrade a function of the data rather than of history. - Verify indexes by definition, not by name. Compare
pg_indexes.indexdefagainst the changeset's column list.exectypealone distinguishes nothing:EXECUTED+ index absent is the skip;MARK_RAN+ index present is healthy only if the definition matches. - Stop sizing maintenance windows from user count. The predictive variable is the changeset set for your version pair, cross-referenced against the statistics of the tables it touches.
Keycloak Advisory Watch - one email per advisory batch, within 72 hours of publication, listing the patched versions per maintained minor line. Double opt-in, no tracking pixels, no click tracking, public archive. Subscribe.
Self-hosted Keycloak is one of the things we keep patched, upgraded and owned for teams that run it but have nobody to run it. Talk to us about your project.
Sources: 2026-08-25-26.0.0-to-26.7.1-100k, 2026-08-25-26.0.0-to-26.7.1-2m, 2026-08-25-index-skip-threshold, 2026-08-25-runA-indexes-manually-created, 2026-08-26-index-precondition-anatomy, 2026-08-26-stale-statistics-reachability. Unchecked rows carried as open items: whether reltuples or relpages is the isolated input (both were changed together), whether the precondition accepts a unique same-named index, and behaviour on version pairs other than 26.0.0 → 26.7.1.