Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. private_keys
  2. users
  3. rooms
  4. devices
  5. events
  6. account_data
  7. room_account_data
  8. presence
  9. receipts
  10. device_keys
  11. cross_signing_keys
  12. cross_signing_signatures
  13. one_time_keys
  14. fallback_keys
  15. device_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 typeSynapse sort column
usersname (user_id, text, lexicographic order)
roomsroom_id (text, lexicographic order)
devices(user_id, device_id) (composite, lexicographic order)
eventsstream_ordering
account_datastream_id
room_account_datastream_id
presenceuser_id (one row per user, lexicographic order)
receiptsstream_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_messagesstream_id

private_keys holds at most one record per key type and is not chunked.

Record Format

{
  "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_version must be a version the importer supports.
  • server should 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:

  1. Insert into events. The exported stream_ordering maps to events.stream_id.

  2. Parse event_json.prev_events and insert corresponding rows into previous_events. Both the current event and each prev-event must already exist; events must therefore be imported in ascending stream_ordering order (which the exporter guarantees).

  3. Parse event_json.auth_events and insert corresponding rows into auth_events. The same ordering constraint applies.

  4. Call insert_in_ordering to place each event into event_ordering.

  5. After all events are inserted, recompute leaf_events per room: an event is a leaf if it has no entry in previous_events.previous_event_nid.

  6. Build state_snapshots, state_snapshot_state_events, and events_state_snapshots by replaying state resolution over all events in topological order.

  7. Recompute room_state_snapshots from 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 fieldarminius columnNotes
statemanual_presenceImported only when manually_set is true; records with manually_set: false are dropped
status_msgstatus
last_active_ts_mslast_activeConvert 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_nid is resolved from user_id.
  • signing_key_type is resolved by looking up the user_keys row for the signing user whose key_object contains a key with ID matching key_id; its key_type (master, self_signing, or user_signing) is used.
  • signed_key_nid is resolved from user_keys as follows: if target_device_id is a real device ID (a user_keys row exists with key_type = 'device' for the resolved (target_user_id, target_device_id)), use that row’s NID. Otherwise target_device_id is a cross-signing key ID; find the user_keys row where user_nid = target_user_nid, device_nid IS NULL, and the key ID embedded in key_object matches target_device_id.
  • The signature field is decoded from base64 into bytea.

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:

SequenceReset to
eventidsmax(events.stream_id) + 1
account_data_streamidsmax(global_account_data.stream_id, room_account_data.stream_id) + 1
read_receipts_streamidsmax(read_receipts.stream_id) + 1
device_message_streamidsmax(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.