ADR: Homeserver Migration Format
When migrating a Matrix homeserver from one deployment to another, operators need a way to export the server state and import it elsewhere. This ADR describes the format produced.
Proposal
The export is a streaming jsonl (newline-delimited json) file. Each line is a self-describing json object with a mandatory "type" field. The file is streamed from the HTTP response with no buffering, so it can be consumed without loading it entirely into memory.
All timestamp fields use milliseconds since the Unix epoch and carry a _ms suffix (e.g. creation_ts_ms). The exported_at field in the header is the only exception: it is an ISO 8601 string (for readability).
The first record is always the header. The last record is always the trailer (to make sure the output wasn’t truncated). Between them is the exported data.
Stream Layout
Record Type Ordering
Tables are exported one at a time in the following order, which respects the dependency chain:
private_keysusersroomsdeviceseventsaccount_dataroom_account_datapresencereceiptsdevice_keyscross_signing_keyscross_signing_signaturesone_time_keysfallback_keysdevice_messages
Chunking
The import/export should support chunking in case an error happens during the process on which we should be able to resume where we stopped. Within each table, rows are sorted by a stable, monotonically-increasing column (the sort key). The importer can keep track of a cursor (the last seen value of the sort key) and pass it as a query to the exporter when resuming.
The name of that query parameter is not fixed by the importer: the exporter advertises it in the header (cursor_param, e.g. from), and the importer echoes the cursor value back under that name (?table=users&from=<cursor>). This keeps the resume contract owned by the exporter, so its parameter name can change on one side alone.
| Record type | Synapse sort column |
|---|---|
users | name (user_id, text, lexicographic order) |
rooms | room_id (text, lexicographic order) |
devices | (user_id, device_id) (composite, lexicographic order) |
events | stream_ordering |
account_data | stream_id |
room_account_data | stream_id |
presence | user_id (one row per user, lexicographic order) |
receipts | stream_id |
device_keys | (user_id, device_id) (composite, lexicographic order) |
cross_signing_keys | (user_id, keytype) (composite, lexicographic order; one row per user/type, latest stream_id only) |
cross_signing_signatures | (user_id, key_id, target_user_id, target_device_id) (composite, lexicographic order) |
one_time_keys | (user_id, device_id, key_id) (composite, lexicographic order) |
fallback_keys | (user_id, device_id, algorithm) (composite, lexicographic order) |
device_messages | stream_id |
private_keys holds at most one record per key type and is not chunked.
Record Format
Header
{
"type": "header",
"format_version": 1,
"server": "servername",
"exported_at": "2026-05-21T10:00:00+00:00",
"table": "events",
"record_count": 4217,
"cursor_param": "from"
}
table names the record type contained in this stream. record_count is the total number of data records in this stream (excluding the header and trailer), allowing the importer to track progress and detect truncation. cursor_param is the name of the query parameter the importer sends the resumption cursor back as (see Chunking); the exporter owns this name so it can be changed on one side alone.
Trailer
{ "type": "trailer" }
The trailer carries no additional fields. Its sole purpose is to signal that the stream ended normally and was not truncated.
private_keys
{ "type": "private_keys", "key_id": "ed25519:1", "private_key": "base64..." }
Synapse only has one signing key.
users
{
"type": "users", "user_id": "@user:testserver", "creation_ts_ms": 1700000000000,
"admin": false, "is_guest": false, "user_type": null, "appservice_id": null,
"approved": true, "locked": false, "suspended": false, "shadow_banned": false,
"deactivated": false, "displayname": "Alice", "avatar_url": "...", "profile_fields": null
}
rooms
{ "type": "rooms", "room_id": "!ubOnvyjOlxOJsnegbq:testserver", "room_version": "10", "is_public": false}
is_public is explicitly for if the room was created as if it was to be published in the public room directory, and not that it was designated as a “public room” from join_rules
events
{ "type": "events", "stream_ordering": 42, "room_id": "!ubOnvyjOlxOJsnegbq:testserver", "event_id": "$abc", "event_json": { ... } }
account_data
{ "type": "account_data", "stream_id": 42, "user_id": "@user:testserver", "data_type": "m.push_rules", "content": { ... } }
room_account_data
{ "type": "room_account_data", "stream_id": 42, "user_id": "@user:testserver", "room_id": "!ubOnvyjOlxOJsnegbq:testserver", "data_type": "m.fully_read", "content": { ... } }
presence
{ "type": "presence", "user_id": "@user:testserver", "state": "online", "status_msg": "...", "last_active_ts_ms": 1700000000000, "manually_set": true }
receipts
{ "type": "receipts", "stream_id": 42, "user_id": "@user:testserver", "room_id": "!ubOnvyjOlxOJsnegbq:testserver", "receipt_type": "m.read", "event_id": "$abc", "ts_ms": 1700000000000, "thread_id": null }
devices
{ "type": "devices", "user_id": "@user:testserver", "device_id": "ABCDEF", "display_name": "Phone", "last_seen_ms": 1700000000000, "ip": "1.2.3.4", "user_agent": "..." }
All devices are exported but in the first version of the migration, only dehydrated devices will get imported and users will have to re-login after the migration is done.
device_keys
{ "type": "device_keys", "user_id": "@user:testserver", "device_id": "ABCDEF", "ts_added_ms": 1700000000000, "key_json": { ... } }
cross_signing_keys
{ "type": "cross_signing_keys", "user_id": "@user:testserver", "key_json": { ... } }
One record per (user_id, key_type). key_type is one of master, self_signing, or user_signing. key_json is the full key object from e2e_cross_signing_keys.keydata (JSON-encoded Matrix cross-signing key, including keys, usage, and any embedded signatures).
cross_signing_signatures
{ "type": "cross_signing_signatures", "signatures": { "@target:testserver": { "DEVICEIDORKEY": { "@user:testserver": { "ed25519:AAAAAAAA": "base64...", "ed25519:BBBBBBBB": "base64..." } } } } }
One record per signed target. signatures uses the same target-keyed nesting as /keys/signatures/upload. Keyed by target user, then target device/key ID whose value is the signer’s key_id signature map, aggregated from the matching rows in e2e_cross_signing_signatures.
one_time_keys
{ "type": "one_time_keys", "user_id": "@user:testserver", "device_id": "ABCDEF", "algorithm": "curve25519", "key_id": "AAAAAAAA", "ts_added_ms": 1700000000000, "key_json": { ... } }
fallback_keys
{ "type": "fallback_keys", "user_id": "@user:testserver", "device_id": "ABCDEF", "algorithm": "signed_curve25519", "key_id": "AAAAAAAA", "key_json": { ... }, "used": false }
There is at most one fallback key per (device_id, algorithm). The used flag indicates whether it has already been handed out to a peer (but not yet replaced by a new upload).
device_messages
{ "type": "device_messages", "stream_id": 42, "user_id": "@user:testserver", "device_id": "ABCDEF", "sender": "@sender:testserver", "event_type": "m.room.key", "content": { ... } }
Import Process
Prerequisites
Both servers must not be in use before the import begins to prevent concurrent writes from interfering with sequence numbering or derived-table consistency.
Validation
The importer validates the header on startup:
format_versionmust be a version the importer supports.servershould match the expected source server name. A mismatch is a warning, not a hard error, since some migrations intentionally rename the server, but the operator must confirm this explicitly.
The importer validates the trailer on completion. A missing trailer means the stream was truncated; any partial data from the last in-progress table should be rolled back.
Resumption
The importer tracks a cursor per table. On restart, it passes the cursor back to the exporter under the query parameter named by the header’s cursor_param, and the exporter resumes the stream from that point. Records within a table are independent of each other, so partial-table imports are always safe to resume.
Per-Record-Type Processing
users
user_id, displayname and avatar_url are stored directly on the user row (user_id, display_name, avatar_id). Arminius has no way to represent a deactivated account, so a deactivated user stops the import at that record (keeping the users imported before it) rather than being imported as active.
devices
Synapse’s last_seen_ms, ip, and user_agent have no equivalent in arminius and are dropped.
events
For each event:
-
Insert into
events. The exportedstream_orderingmaps toevents.stream_id. -
Parse
event_json.prev_eventsand insert corresponding rows intoprevious_events. Both the current event and each prev-event must already exist; events must therefore be imported in ascendingstream_orderingorder (which the exporter guarantees). -
Parse
event_json.auth_eventsand insert corresponding rows intoauth_events. The same ordering constraint applies. -
Call
insert_in_orderingto place each event intoevent_ordering. -
After all events are inserted, recompute
leaf_eventsper room: an event is a leaf if it has no entry inprevious_events.previous_event_nid. -
Build
state_snapshots,state_snapshot_state_events, andevents_state_snapshotsby replaying state resolution over all events in topological order. -
Recompute
room_state_snapshotsfrom each room’s current resolved state.
Replaying state resolution is only possible where the source server actually computed the state itself. For outlier events Synapse does not always resolve state locally, it instead trusts other homeservers. For this version, rooms whose state is incomplete are not imported, and events that cannot be validated are left out of the imported DAG.
account_data
Maps to global_account_data. user_nid is resolved from user_id.
room_account_data
Maps to room_account_data. Both the user and the room must already exist; room_nid is resolved from room_id.
presence
Field mapping from Synapse to arminius:
| Synapse export field | arminius column | Notes |
|---|---|---|
state | manual_presence | Imported only when manually_set is true; records with manually_set: false are dropped |
status_msg | status | |
last_active_ts_ms | last_active | Convert from epoch milliseconds to timestamptz |
receipts
Maps to read_receipts. The event_id must be resolved to event_nid. thread_id: null maps to thread: null. ts_ms maps to ts as timestamptz.
device_keys
Maps to user_keys with key_type = 'device'. The device_nid is resolved from (user_id, device_id). The exported key_json field maps to the key_object column. stream_id is not present in the export; it is assigned from the user_keys_streamids sequence on insert.
cross_signing_keys
Maps to user_keys with the corresponding key_type (master, self_signing, or user_signing). device_nid is NULL for cross-signing keys. user_nid is resolved from user_id. The exported key_json field maps to the key_object column.
cross_signing_signatures
Maps to local_signatures. Only records where target_user_id is a local user are imported.
signing_user_nidis resolved fromuser_id.signing_key_typeis resolved by looking up theuser_keysrow for the signing user whosekey_objectcontains a key with ID matchingkey_id; itskey_type(master,self_signing, oruser_signing) is used.signed_key_nidis resolved fromuser_keysas follows: iftarget_device_idis a real device ID (auser_keysrow exists withkey_type = 'device'for the resolved(target_user_id, target_device_id)), use that row’s NID. Otherwisetarget_device_idis a cross-signing key ID; find theuser_keysrow whereuser_nid = target_user_nid,device_nid IS NULL, and the key ID embedded inkey_objectmatchestarget_device_id.- The
signaturefield is decoded from base64 intobytea.
one_time_keys
Maps to one_time_keys. device_nid resolved from (user_id, device_id).
fallback_keys
Maps to fallback_keys. device_nid resolved from (user_id, device_id).
device_messages
Maps to to_device_messages. device_nid resolved from (user_id, device_id). sender must be a local user; it is stored as bigint REFERENCES users in arminius, so remote senders are not representable and their messages must be dropped.
Post-Processing
Sequence Reset
All PostgreSQL sequences must be reset after import so that new writes do not collide with imported data:
| Sequence | Reset to |
|---|---|
eventids | max(events.stream_id) + 1 |
account_data_streamids | max(global_account_data.stream_id, room_account_data.stream_id) + 1 |
read_receipts_streamids | max(read_receipts.stream_id) + 1 |
device_message_streamids | max(to_device_messages.stream_id) + 1 |
State Rebuild
The state_snapshots / state_snapshot_state_events / events_state_snapshots / room_state_snapshots tables are derived from the event DAG and are not exported. They must be computed by replaying state resolution over all imported events in topological order. For large rooms this is expensive and may take significant time.
Leaf Events
leaf_events must be recomputed after all events are imported. The leaf set is every event that does not appear in previous_events.previous_event_nid.
IdP Sync
In this first version of the migration, users, and devices will be created automatically, but linking them to the IdP will be a manual operation done by the customer.
Known Gaps
Media files: Events may contain mxc:// URIs. The export carries no media files; only event JSON is exported. Media must be transferred separately at the filesystem level (copy the source server’s media store to the target before or after import). Remote media (hosted on other homeservers) is unaffected as it is re-fetched on demand.
Pushers: Push notification registrations (APNs/FCM tokens) are not exported. Users’ client applications will re-register their push tokens automatically when they first connect to the new server.
Access tokens: Not exported. All existing sessions are invalidated; users must log in again after migration.
User threepids: Will be exported later if needed
Remote-sender to-device messages: to_device_messages in arminius requires the sender to be a local user. Any pending to-device messages from remote senders (e.g. key exchange messages from federated users) are dropped during import. This may cause one-time decryption failures for in-flight E2E sessions; affected users should re-request keys from their peers.