The Keycloak Upgrade Ledger

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

2026-08-25 · run 2026-08-25-index-skip-threshold

Keycloak silently skips index creation above 300,000 rows

Upgrade
26.0.0 → 26.7.1
Scale
100,002 users, typical profile; EVENT_ENTITY varied per run
Database
PostgreSQL 16.15, single node, container
Host
Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30GB RAM, local NVMe
Condition
Admin-event volume

Environment

FieldValue
Keycloak from → to26.0.0 → 26.7.1
DatabasePostgreSQL 16.15, single node, container
Postgres tuningstock — shared_buffers 128MB, maintenance_work_mem 64MB, statement_timeout 0
Dataset100,002 users, typical profile; EVENT_ENTITY varied per run
Seedingusers via partialImport; events via direct SQL INSERT … generate_series, then ANALYZE
HostHetzner CCX33 (fsn1) — 8 vCPU dedicated, 30GB RAM, local NVMe
JVM heap-Xms1g -Xmx4g

Procedure

For each row count: restore baseline-26.0.0-typical-100k, insert N rows into EVENT_ENTITY, ANALYZE, upgrade to 26.7.1, then check pg_indexes for idx_event_entity_user_id_type and grep the log for the skip warning.

Results

EVENT_ENTITY rowsTime to readyIndex createdSkip warning
015.7syesno
1,00015.4syesno
100,00015.6syesno
200,00015.7syesno
300,00015.9syesno
301,00015.5sNOyes
350,00015.5sNOyes
400,00016.0sNOyes
450,00015.3sNOyes
500,00015.4sNOyes
5,000,00014.9sNOyes

The threshold is exactly 300,000 rows. Time to ready is flat across a range of 0 to 5,000,000 rows — a 2.1 GB database at the top end — precisely because the expensive work is skipped.

Outcome

Verification

ClaimPrimary sourceChecked
Index absent above thresholdpg_indexes, queried directly
Changeset recorded EXECUTEDDATABASECHANGELOG.exectype
Keycloak warns at skip and at startupcontainer log, both messages quoted
Threshold is 300,000300,000 creates; 301,000 skips
The 300,000 constant applies to other tables/indexesconfirmed on USER_ENTITY — bisected to the same inclusive 300,000 boundary in 2026-08-26-index-precondition-anatomy §3b
Same behaviour in majors other than 26.7.1not tested
Whether row count comes from statistics or an exact countanswered — the planner estimate. Falsifying pg_class for USER_ENTITY at 2M made Keycloak build the index; see 2026-08-25-runA-indexes-manually-created L5

Publishable extract

Keycloak will skip creating a database index during an upgrade if your table is too big, and the only place it tells you is a WARN in the startup log.

Upgrading 26.0.0 → 26.7.1 adds an index to EVENT_ENTITY. We ran that upgrade eleven times against the same 100,000-user realm, varying only the number of rows in EVENT_ENTITY (Postgres 16.15 at stock settings, Hetzner CCX33 — 8 vCPU dedicated, 32 GB, local NVMe).

At 300,000 event rows the index is created. At 301,000 it is not.

The upgrade still succeeds. The server starts. MIGRATION_MODEL reads 26.7.1. And DATABASECHANGELOG records changeset 26.4.0-51321 as EXECUTED, not as skipped — so a schema-version check, which is what most people verify with, shows everything applied. Only two WARN lines say otherwise:

WARN [CustomCreateIndexChange] Following index should be created:
  CREATE INDEX IDX_EVENT_ENTITY_USER_ID_TYPE ON public.EVENT_ENTITY(USER_ID, TYPE, EVENT_TIME);
WARN [DatabaseIndexChecker] Missing database index IDX_EVENT_ENTITY_USER_ID_TYPE
  on table EVENT_ENTITY. Create the index manually: ...

This is deliberate. CustomCreateIndexChange exists so a large table is not locked during startup, and that is a reasonable design choice — a slow upgrade is worse than a late index. We measured what it is avoiding: at 5,000,000 rows the index takes about 3 seconds to build on this hardware.

The consequence is what nobody mentions. The installations that skip the index are exactly the ones large enough to need it, small deployments get it automatically, and every future release that indexes a large table will behave the same way, so the gap grows with each upgrade.

If you have upgraded Keycloak recently and your event table is bigger than 300,000 rows, check whether IDX_EVENT_ENTITY_USER_ID_TYPE exists. Then add "list indexes Keycloak wanted and did not create" to your post-upgrade checklist permanently, because this will happen again.

Incidentally, this also explains something that surprised us. We had expected migration time to grow with data volume. It does not — 15 seconds whether the database holds 0 or 5,000,000 events — and this is why. Keycloak keeps the upgrade fast by declining to do the expensive part.

← All runs