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.
| Change | Why it is safe |
|---|---|
| A new entity or singleton collection | Nothing referenced it before |
A new field with .default(...) or .opt | The codec fills it in for existing snapshots |
| A new RPC | Old clients never call it |
| A new role | Old clients never ask for it |
A required field becoming .opt | Every existing value is still valid |
Adding, removing or changing a .default(...) | Existing snapshots already carry values |
| Appending values to the end of an enum | Existing values keep their index |
Raising a str or list maximum | Existing values still fit |
Changes that need a migration
These are classified breaking.
| Change | Why it breaks |
|---|---|
| Removing a collection, field, RPC or role | Code and stored data still reference it |
| Adding a required field with no default | Existing snapshots have nothing to put there |
| Reordering fields | Field order is the wire index |
| Changing a field’s type | Old bytes decode as the old type |
| Renaming a field | Read as a remove plus an add, and flagged as a likely rename |
An .opt field becoming required | Existing values may be absent |
Lowering a str or list maximum | Existing values may no longer fit |
| Reordering, renaming or removing enum values | Enum values are stored by index |
Changing a collection’s serverOwned, visibility, roles or spatial grid | Changes 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 change | The resumed room |
|---|---|
| No schema change | Restores its snapshot as it is |
| Additive | Restores its snapshot, new fields filled from their defaults |
| Breaking, with a migration chain | Runs each up in order, then restores. Written back under the new version |
| Breaking, and a chain step throws or is missing | Refuses to start. Nothing is restored and nothing is discarded |
| A snapshot whose schema does not match after all of that | Discards 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_MISMATCHand the messagethe 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.