The Keycloak Upgrade Ledger

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

2026-08-25 · run 2026-08-25-s15-unmanaged-attributes

S15: upgrading does not hide custom attributes; *re-creating the realm* discards them

Upgrade
23.0.7 → 24.0.5
Scale
small — 3 attribute-carrying users, plus SQL-inserted controls; 2 realms, later 3
Database
PostgreSQL 16.15, single node, container
Topology
single container
Host
Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe
Condition
Unmanaged user attributes

Summary

S15's hypothesis was wrong, and the truth is worse.

The hypothesis: upgrading across 23 → 24, where the declarative user profile became the default, makes existing custom attributes invisible to the admin API — a data-visibility regression triggered purely by upgrading.

It does not. A realm created on 23.0.7 with users carrying department and employeeId shows those attributes through the admin API on 23.0.7, on 24.0.5 and on 26.7.1, unchanged at every hop. An in-place upgrade preserves them, because the migration sets that realm's unmanagedAttributePolicy to ENABLED.

What actually loses attributes is creating a realm on 24+. Such a realm has no unmanagedAttributePolicy, and then the admin API:

So the danger is not the upgrade. It is a realm created on a new version.

⚠ CORRECTED 2026-08-26. This paragraph originally read "every path that produces a new realm on a new version: export/import migration, a rebuilt environment, disaster recovery, or simply a realm created after the upgrade." That generalisation is wrong. Run C exported a 23.0.7 realm and imported it into 24.0.5, 25.0.6, 26.0.0 and 26.7.1: all six USER_ATTRIBUTE rows survive, the admin API returns them, and the realm still accepts new ones — because the import sets unmanagedAttributePolicy=ENABLED, exactly as the in-place migration does. See 2026-08-26-runC-realm-export-import-23-to-24.

The path measured below — a realm created through the admin API — is the one that loses attributes. The measurements in this record stand; the inference from them to a class of untested paths did not.

Environment

FieldValue
Keycloak path23.0.7 → 24.0.5 → 26.7.1 (in-place, real migrations)
Distribution / start modequay.io/keycloak/keycloak official image, start w/ external DB
DatabasePostgreSQL 16.15, single node, container
Postgres tuningstock — shared_buffers 128MB, maintenance_work_mem 64MB, statement_timeout 0
Adverse scenarioS15
Dataset scalesmall — 3 attribute-carrying users, plus SQL-inserted controls; 2 realms, later 3
Seeding methodpartialImport (--profile minimal), admin API user creation, and direct SQL inserts as a control
Topologysingle container
HostHetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe
JVM heap-Xms1g -Xmx4g

Results

The upgrade path: no regression at any hop

Users created on 23.0.7 with attributes: {department, employeeId}:

USER_ATTRIBUTE rowsAdmin API returns
23.0.7 (before)6 (2 per user × 3){"department":["engineering"],"employeeId":["E001"]}
24.0.5 (after the hop that matters)6{"department":["engineering"],"employeeId":["E001"]}
26.7.16{"employeeId":["E001"],"department":["engineering"]}

Migration timings: 23.0.7 → 24.0.5 in 18s, 24.0.5 → 26.7.1 in 17s.

Two controls ruled out the other candidate explanations. Users inserted by direct SQL — the way seed-sql.sh does it, with long_value_hash left NULL — returned their attributes through the admin API on 26.7.1 just as admin-API-created users did, both before and after a restart. The seeding method is not the variable, and neither is the hash column.

The realm's birth version is the variable

On the same 26.7.1 server, side by side:

realm lab (created on 23.0.7, upgraded in place)realm born26 (created on 26.7.1)
unmanagedAttributePolicyENABLEDUNSET
Create user with attributesHTTP 201
USER_ATTRIBUTE rows written100
Admin API returns{"employeeId":…,"department":…}attributes: null

The user creation succeeded. It returned 201. The attributes were simply not persisted, and nothing said so.

The policy governs both sides, independently

Toggling unmanagedAttributePolicy on born26 and re-testing:

PolicyWrite through the admin APIRows in DB afterRead back
UNSETHTTP 201 — accepted0attributes: null
ENABLEDHTTP 204 — accepted2{"employeeId":["E001"],"department":["engineering"]}
back to UNSET, rows left in place2attributes: null

The last row is the S15 scenario as originally described: the data is present and invisible. The middle row shows the fix. And the first row shows the part nobody warns about — with the policy unset, the write is a no-op that reports success.

Setting the policy to ENABLED does not recover attributes that were discarded earlier. There is nothing to recover.

Verification

ClaimPrimary sourceChecked
Attributes survive 23.0.7 → 24.0.5admin API returns them on both sides; 6 rows in DB throughout
And survive on to 26.7.1same
An upgraded realm has unmanagedAttributePolicy=ENABLED/admin/realms/lab/users/profile
A realm created on 26.7.1 has it unset/admin/realms/born26/users/profile
With it unset, an admin API write silently discardsHTTP 201, select count(*) from user_attribute = 0
With it unset, existing rows are hidden2 rows present, attributes: null
With it ENABLED, both write and read workHTTP 204, 2 rows, attributes returned
Seeding method is not the causeSQL-inserted users returned attributes on 26.7.1, hash column NULL and populated alike
Keycloak's migration is what sets ENABLEDinferred from the two realms' policies, not read from source or release notes
Behaviour of realm export/import across the boundarynot tested — the most likely real-world path to this, and the obvious next run
Whether tokens/mappers see unmanaged attributesnot tested — this record covers the admin API only
ADMIN_VIEW / ADMIN_EDIT policy valuesnot tested — only UNSET and ENABLED

Publishable extract

Upgrading Keycloak does not hide your custom user attributes. Rebuilding the realm deletes them, and tells you it worked.

Keycloak 24 made the declarative user profile the default, and the widely repeated warning is that custom attributes go invisible when you cross that boundary. We tested it: a realm created on 23.0.7 with users carrying department and employeeId, upgraded in place to 24.0.5 and on to 26.7.1. The attributes were returned by the admin API at every hop, unchanged. The in-place upgrade sets that realm's unmanagedAttributePolicy to ENABLED, and everything keeps working.

Then we created a new realm on 26.7.1 and posted the identical user:

POST /admin/realms/born26/users   →  HTTP 201
select count(*) from user_attribute where user_id = ...  →  0
GET  /admin/realms/born26/users?username=...   →  "attributes": null

The request succeeded. The attributes were never written. Nothing warned us. A realm created on 24 or later has no unmanagedAttributePolicy, and with it unset the admin API accepts custom attributes and drops them on the floor.

The same policy hides rows that got there another way. We inserted attributes straight into Postgres, confirmed two rows, and the admin API still answered attributes: null. Turning the policy on makes both problems go away — but it does not bring back what was discarded, because that was never stored.

So the risk is not the upgrade. It is every path that produces a new realm on a new version: export-and-reimport migrations, environment rebuilds, disaster recovery, and any realm created after the upgrade. Those are precisely the paths people take because they consider them safer than an in-place upgrade.

One query tells you which kind of realm you have:

GET /admin/realms/{realm}/users/profile   →   .unmanagedAttributePolicy

If it is absent, your custom attributes are not being stored.

We nearly published the opposite of this. Our own large test realms were built on 26.0.0 and showed exactly the attributes: null symptom the folklore predicts — and we had written it down as an upgrade regression before checking where the realm came from.

← All runs