Schema changes and migrations

Your schema is a wire format. Clients decode state by field order and field type, and hibernated rooms are stored as bytes that only the schema they were written under can read. So every irtio deploy diffs your new schema against the last one you shipped and sorts the differences into two piles: changes that old data and old clients survive, and changes that they do not.

Additive changes ship without ceremony. Breaking ones stop the deploy until you write a migration.

Safe changes

These are classified additive. The deploy lists them and continues.

ChangeWhy it is safe
A new entity or singleton collectionNothing referenced it before
A new field with .default(...) or .optThe codec fills it in for existing snapshots
A new RPCOld clients never call it
A new roleOld clients never ask for it
A required field becoming .optEvery existing value is still valid
Adding, removing or changing a .default(...)Existing snapshots already carry values
Appending values to the end of an enumExisting values keep their index
Raising a str or list maximumExisting values still fit

Changes that need a migration

These are classified breaking.

ChangeWhy it breaks
Removing a collection, field, RPC or roleCode and stored data still reference it
Adding a required field with no defaultExisting snapshots have nothing to put there
Reordering fieldsField order is the wire index
Changing a field’s typeOld bytes decode as the old type
Renaming a fieldRead as a remove plus an add, and flagged as a likely rename
An .opt field becoming requiredExisting values may be absent
Lowering a str or list maximumExisting values may no longer fit
Reordering, renaming or removing enum valuesEnum values are stored by index
Changing a collection’s serverOwned, visibility, roles or spatial gridChanges who sees what

A refused deploy prints every one of them and the command to run next:

deploy refused: 2 breaking changes
  players.hp -> players.health: looks like a rename; write a transform
  players.hp: field was removed (breaking)

--allow-breaking needs a migration for v2. Run: irtio migrate create <name>, then re-run with: irtio deploy --allow-breaking

Renaming hp to health is two changes, not one. The old field is gone (breaking) and a new one appeared, and the diff points out that they look like the same field under a new name.

Writing a migration

npx irtio migrate create rename-hp

This writes irtio/migrations/<version>_<name>.ts, where <version> is the version your next deploy will create. It never overwrites an existing file, and it tells you where the number came from: irtio when it can be reached, or a scan of irtio/migrations/ when it cannot.

next version: v2 (from the control plane)
created irtio/migrations/2_rename-hp.ts
quoted 2 breaking change(s) in the header comment

The scaffold quotes the breaking changes it found, and the previous schema’s shape, in a header comment, then leaves you an up to fill in:

// irtio/migrations/2_rename-hp.ts
import type { MigrationHelpers, MigrationState } from '@irtio/runtime';

export function up(state: MigrationState, s: MigrationHelpers): MigrationState {
  const players = state.players as Record<string, { owner: string; value: Record<string, unknown> }>;
  for (const record of Object.values(players)) {
    record.value.health = record.value.hp ?? 100;
    delete record.value.hp;
  }
  s.log(`renamed hp on ${Object.keys(players).length} players`);
  return state;
}

The @irtio/runtime import is types only, and it is erased before the bundler sees it. Nothing else is available inside a migration: no network, no filesystem, no clock.

The state a migration sees is plain data, not the tracked state tree you write in handlers. Entity collections arrive as { id: { owner, value } } maps, singletons as the record itself. Mutate it and return it, or build a new object and return that. The built-in clients presence collection is removed before up runs and comes back empty, because presence is rebuilt from the sockets that rejoin.

The second argument carries version, fromVersion, roomId, and log(...), which writes to the room’s log prefixed with the version.

Deploying it

npx irtio deploy --allow-breaking

--allow-breaking on its own is not enough. The CLI looks for a file in irtio/migrations/ whose name starts with the version this deploy will create, and refuses again if there is none. The CLI and the server classify the change independently, so a breaking deploy cannot slip past by skipping the local check.

One exception needs no migration: a change that only touches the RPC or message tables, such as moving an RPC declaration. That is breaking for connected clients (their calls would reach the wrong handler, so the version fence reconnects them) but touches nothing a snapshot stores. --allow-breaking alone ships it, and the deploy output says so.

Note that adding an RPC or a message is not breaking at all — it is additive, and live rooms keep their sockets across it. What is breaking is reordering: RPC ids and message indices are your declaration order, so add to the bottom and nothing moves.

The migration is bundled the same way your room is, uploaded alongside it, and stored on the deployment record.

What happens to a live room

Nothing, at the moment you deploy, under the default drain strategy. Migrations run at wake, not at deploy time.

--strategy migrate is the exception. It moves every currently running room onto the new version immediately, one at a time, running the migration chain right then instead of waiting for a wake. See deploying a room for what that does to connected clients. It depends on whether the schema changed at all, not only on whether the change was breaking. The rest of this section describes the default drain behavior.

On a project running on more than one server, migrate reaches every running server, one after another, and each migrates its own rooms one at a time as described above. If one refuses partway through, the deploy is reported as a 502 naming that server, with the others’ outcomes listed beside it, rather than left silently half-migrated. Running the same irtio deploy --strategy migrate command again is the retry: a server that already finished is left alone, and only those still carrying the old version are migrated.

Under drain, a room that is running keeps running on the version it started under, untouched. When a room hibernates and is later woken under a newer version, the worker runs the migration chain once on the way up: it decodes the stored bytes with the schema of the version they were written under, runs each intervening version’s up in order, and re-encodes under the target schema. A room asleep since v1 waking under v3 runs v2’s up and then v3’s. Versions whose change was purely additive have no migration to run, and their fields come from the schema’s defaults.

The migrated state is written back under the new version’s key immediately, so the chain runs once per room and not once per wake. You see it in the log:

2026-08-26 14:03:11Z  INFO   FV7A          waking a v1 snapshot under v2 (migration chain)
2026-08-26 14:03:11Z  INFO   FV7A          migrated v1 → v2 in 3.4 ms (ran v2)

If a version in the middle of the chain is missing, the room refuses to start rather than decoding bytes with the wrong schema. That is why deployments are never deleted.

A resumed room, change by change

What a sleeping room does on the way back up depends on how the change was classified.

The changeThe resumed room
No schema changeRestores its snapshot as it is
AdditiveRestores its snapshot, new fields filled from their defaults
Breaking, with a migration chainRuns each up in order, then restores. Written back under the new version
Breaking, and a chain step throws or is missingRefuses to start. Nothing is restored and nothing is discarded
A snapshot whose schema does not match after all of thatDiscards the state and starts the room fresh, logging schema changed, snapshot discarded

The last row is the one to plan for: it is not an error, the room comes back empty, and the only notice is the log line. It is what a snapshot older than your oldest migration looks like. Keep the chain complete back to the oldest version any room might still be asleep under, and take a room.save() before a breaking deploy if the state matters.

Physics is a separate case. Any migration drops the saved world and rebuilds it from schema state, so pose and velocity survive and resting contacts do not. See physics rooms.

Connected clients under --strategy migrate

A client holds the schema it joined with, so migrating a running room has to deal with it. Each connected client is handled on its own:

  • If the change is additive and touches no physics-backed collection, the client is handed the new schema in place and stays connected.
  • Otherwise the connection is closed with E_SCHEMA_MISMATCH and the message the room migrated to a newer deployment; reconnect with an updated client. The room keeps running. The player rejoins once their page is serving the new client bundle.

A change to a physics-backed collection never swaps in place, even when every field change in it is additive. Ship those with the default drain strategy, or expect every player in a physics room to reconnect.

Skipping migrations with preRelease

For a project still finding its schema, with no player state worth keeping, irtio.json can set "preRelease": true:

{
  "project": "p_a5da84132e8d6c2e",
  "name": "dive",
  "preRelease": true
}

Like queues, this is synced to the project on every deploy: declaring it false turns it back off, and leaving the key out entirely leaves the stored setting alone.

With preRelease on, a breaking schema change deploys with no --allow-breaking flag and no migration file. There is no migration chain to run, because there is nothing to migrate: a breaking deploy defaults to --strategy migrate instead of drain, which closes every room still running on an older version. Connected clients are disconnected and reconnect into a fresh room on the new version, and a hibernated room’s old snapshot is discarded at wake instead of migrated, so the room starts cold. --strategy drain still overrides the migrate default if you pass it explicitly. Additive changes behave exactly as they do everywhere else.

Turn preRelease off before players have progress worth keeping. The state it discards is real, and saves written before the break cannot be restored across it: there is no migration to make their bytes mean anything under the new schema.

Worth knowing