< blog

Auto-migrating Durable Objects

2026-03-26 · ZERO

Durable Objects don't have a migration CLI. D1 has wrangler d1 migrations apply. Postgres has prisma migrate. But a DO with embedded SQLite? Each instance owns its own database. There's no central place to run ALTER TABLE.

wake up check __migrations apply pending ready transactionSync() new DO: runs all existing DO: runs diff

The solution: migrations run on every wake-up. Before any business logic, the DO checks a __migrations table, applies anything new, and proceeds. A fresh DO runs all migrations. An existing one runs only what changed since the last deploy. All inside transactionSync, so it's atomic.

The API

Migrations are a plain record with mNNNN keys. Import SQL files or inline them. The runner validates they're sequential, splits multi-statement migrations on --> statement-breakpoint, and tracks applied versions.

import { migrate } from "do-orm";

export class InboxDO {
  constructor(state, env) {
    migrate(state.storage, {
      m0000: `CREATE TABLE messages (...)`,
      m0001: `ALTER TABLE messages ADD COLUMN status TEXT`,
    });
  }
}

That's it. New DOs get both tables. Existing DOs with m0000 already applied get only m0001. Failures roll back the entire transaction. The MigrationError tells you exactly which version and statement broke.

Adopting from Drizzle

We built this after replacing Drizzle. Problem: existing DOs already had data and a __drizzle_migrations table tracking three applied migrations. We couldn't wipe them. We couldn't re-run the old migrations (the tables already existed).

The migration runner handles this automatically. On first run, it detects the Drizzle tracking table, counts how many migrations were applied, marks those versions as done in the new __migrations table, drops the Drizzle table, and continues from where Drizzle left off. Zero downtime, zero data loss.

Why this works for DOs

Traditional databases have one instance and a migration CLI. DOs have thousands of instances, each with their own SQLite. You can't SSH into them. You can't run a script against them. The only code that touches a DO's database is the DO itself. So the DO must be its own migration runner.

The cost is ~1ms per wake-up to check the tracking table. In practice, after the first request post-deploy, every subsequent request hits the "nothing to apply" fast path. The benefit: you never think about migrations again. Add a new mNNNN key, deploy, and every DO in the world picks it up on next wake.


do-orm on GitHub