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
| Field | Value |
|---|---|
| Keycloak from → to | 26.0.0 → 26.7.1 |
| Distribution | quay.io/keycloak/keycloak official image |
| Start mode | start --http-enabled=true w/ external DB |
| Database | PostgreSQL 16.15, single node, container |
| Postgres tuning | stock — shared_buffers 128MB, maintenance_work_mem 64MB, statement_timeout 0 |
| Dataset scale | 100,002 users · 2 realms · 10 clients · 8 roles · 100,001 credentials · 200,001 user attributes · 100,004 admin events |
| DB size | 264 MB before, 265 MB after |
| Seeding method | partialImport, batch 2000 — 5,235s (87 min) for 100k, i.e. 19.1 users/s |
| Fixture profile | typical |
| Topology | single container |
| Host | Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30GB RAM, local NVMe |
| JVM heap | -Xms1g -Xmx4g |
| Adverse scenario | none |
Timings — and the finding
| Phase | 1k (…-26.0.0-to-26.7.1-2) | 100k (this run) |
|---|---|---|
Quarkus started | 11.4s | 10.5s |
| Liquibase migration | 12.2s → 16.2s = 3.97s | 13.0s → 16.8s = 3.79s |
| Total to ready | 17.0s | 15.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:
createTableon new, empty tables — CLUSTER_EVENT, LOGIN_FAILURE, SINGLE_USE_OBJECT, AUTH_SESSION, ROOT_AUTH_SESSION, ISSUED_VER_CREDENTIAL, OUTBOX_ENTRY, USER_VER_CREDENTIAL, FED_USER_VER_CREDENTIALcreateIndexon those same empty tablesaddColumnon CLIENT (10 rows) and REALM (2 rows)addColumnon USER_CONSENT_CLIENT_SCOPE (0 rows)- one
deleteon REALM_ATTRIBUTE (tiny)
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
-
Reached ready · [x] 100,002 users intact · [x]
MIGRATION_MODEL→ 26.7.1 -
DATABASECHANGELOG144 → 211 · [x] end-user login OK -
/health/readybehaved exactly as at 1k: 503 until migration finished
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
| Claim | Primary source | Checked |
|---|---|---|
| No changeset rewrites user data on this path | DATABASECHANGELOG.description for all 67, read directly | ☑ |
| Migration time flat 1k → 100k | Two instrumented runs, same box, same fixture profile | ☑ |
| Holds at 2M | not tested — 100k may simply be too small to expose an effect | ☐ |
| Holds for pairs that DO touch user tables | not 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.