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)

run: irtio migrate create <name>

Note that 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: the control plane when it can reach it, 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. Both the CLI and the control plane classify the change independently, so a breaking deploy cannot slip past by skipping the local check.

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 — with 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: strategies for what that does to connected clients — it depends on whether the schema changed at all, not just whether the change was breaking. The rest of this section describes the default drain behavior.

A room that is running keeps running on the version it started under, untouched — under drain. 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 will 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.

Worth knowing

  • The chain runs once per room. The migrated state is written back under the new version immediately, so a room that has been through the chain wakes straight into the new schema from then on. Write your transform to run exactly once.
  • Migrations run at wake, not at deploy time — unless the deploy used --strategy migrate. A room keeps its version while it is running and picks the chain up the next time it starts, or immediately if the deploy that shipped the migration used migrate.
  • A migration only needs up. That is the function the chain calls.
  • create is the only subcommand. There is nothing to run by hand: the runtime runs the chain when a room wakes.
  • A migration that throws stops the room from starting. Test the transform against a real snapshot shape before you ship it, the same way you test a handler.
  • room.save() gives you an addressable copy. If you want a named generation of a room’s state from before a breaking deploy, save one while the room is still on the old version. See saves and restores.

Next steps