noboil

Schema Evolution

Safe field changes, migrations, and deployment strategies for production schema updates.

How schema changes work

Convex is schemaless at the storage layer — documents can have any shape. Your Zod schema in convex/s.ts is a TypeScript-level contract, not a database constraint. This means:

  • Adding an optional field: deploy immediately, no migration needed
  • Removing a field: old documents keep the field in the database but it's ignored
  • Renaming a field: requires a three-step add/migrate/remove process

Convex deploys atomically — schema and functions update together.

SpacetimeDB handles schema changes through module republishing. When you run spacetime publish, it compares the new schema against the deployed one and applies the diff.

The basic workflow:

  1. Edit your module (backend/spacetimedb/src/index.ts)
  2. Publish: spacetime publish my-app --module-path backend/spacetimedb/
  3. Regenerate bindings: spacetime generate --lang typescript --module-path backend/spacetimedb/ --out-dir backend/spacetimedb/module_bindings/
  4. Update client code to use new fields/tables

Adding a field

Add the field as optional in your Zod schema:

const owned = makeOwned({
  blog: object({
    title: string().min(1),
    content: string().min(3),
    category: zenum(['tech', 'life', 'tutorial']),
    published: boolean(),
    coverImage: file().nullable().optional(),
    subtitle: string().optional()
  })
})

Deploy. Existing documents have no subtitle field, which satisfies optional(). New documents can include it. Forms using <Text name='subtitle' /> will render an empty field for old documents.

If the field should have a default value for existing documents, backfill after deploying:

const backfillSubtitle = m({
  args: {},
  handler: async c => {
    const docs = await c.db.query('blog').collect()
    for (const doc of docs)
      if (doc.subtitle === undefined) await c.patch(doc._id, { subtitle: '' })
  }
})

Once all documents have the field, you can remove optional() to make it required.

Add the field to your table definition and republish:

const post = table(
  { public: true },
  {
    id: t.u32().autoInc().primaryKey(),
    title: t.string(),
    content: t.string(),
    category: t.string().optional(),
    updatedAt: t.timestamp(),
    userId: t.identity().index()
  }
)
spacetime publish my-app --module-path backend/spacetimedb/

Existing rows get null for the new optional field. Required (non-optional) fields on existing rows will have their zero value (empty string for t.string(), 0 for t.u32(), false for t.bool()). Verify this is acceptable for your business logic before deploying.

Removing a field

  1. Remove all frontend code that reads or writes the field.
  2. Keep the field in the Zod schema as optional() during a transition period.
  3. Deploy the frontend changes.
  4. Remove the field from the Zod schema.
  5. Deploy the schema change.

Convex is schemaless at the storage layer — old documents keep the field in the database but it's ignored. No migration needed for removal.

If you want to clean up old data:

const cleanupField = m({
  args: {},
  handler: async c => {
    const docs = await c.db.query('blog').collect()
    for (const doc of docs)
      if ('oldField' in doc) await c.patch(doc._id, { oldField: undefined })
  }
})

Warning: This operation permanently deletes all data in the removed column. Back up your database before proceeding. This cannot be undone.

Remove the field from the table definition and republish. Existing data in that column is dropped.

const post = table(
  { public: true },
  {
    id: t.u32().autoInc().primaryKey(),
    title: t.string(),
    content: t.string(),
    updatedAt: t.timestamp(),
    userId: t.identity().index()
  }
)

After republishing, regenerate bindings. Client code referencing the removed field will get TypeScript errors, which is the intended behavior.

Adding a table

Add the table to your schema and deploy:

const owned = makeOwned({
  blog: object({ ... }),
  tag: object({ name: string().min(1) })
})

export default defineSchema({
  blog: ownedTable(owned.blog),
  tag: ownedTable(owned.tag)
})

The new table starts empty. Existing data is unaffected.

Add the table to your schema and republish:

const tag = table(
  { public: true },
  {
    id: t.u32().autoInc().primaryKey(),
    name: t.string().unique(),
    postId: t.u32().index()
  }
)

const spacetimedb = schema({
  post,
  tag
})

The new table starts empty. Existing data is unaffected.

Removing a table

Remove the table from your schema and deploy. Convex keeps the data in the database but stops serving it through the schema. Clean up the data manually if needed.

Remove the table from the schema and republish. All data in that table is dropped.

const spacetimedb = schema({
  post
})

Renaming a field

Neither database supports field renames at the storage layer. The safe approach is add/migrate/remove.

  1. Add the new field name as optional():
blog: object({
  title: string().min(1),
  body: string().min(3),
  content: string().optional()
})
  1. Deploy and run a migration to copy values:
const migrateField = m({
  args: {},
  handler: async c => {
    const docs = await c.db.query('blog').collect()
    for (const doc of docs)
      if (doc.content === undefined && doc.body !== undefined)
        await c.patch(doc._id, { content: doc.body })
  }
})
  1. Update frontend to use content instead of body.
  2. Remove body from the schema, make content required.
  3. Deploy.
  1. Add the new field alongside the old one:
const post = table(
  { public: true },
  {
    id: t.u32().autoInc().primaryKey(),
    title: t.string(),
    body: t.string(),
    content: t.string(),
    updatedAt: t.timestamp(),
    userId: t.identity().index()
  }
)
  1. Write a migration reducer:
export const migrateContentToBody = spacetimedb.reducer(
  { name: 'migrate_content_to_body' },
  {},
  ctx => {
    for (const post of ctx.db.post) {
      if (post.body === '' && post.content !== '') {
        ctx.db.post.id.update({ ...post, body: post.content })
      }
    }
  }
)
  1. Run the migration:
spacetime call my-app migrate_content_to_body '{}'
  1. Update client code to use body instead of content.
  2. Remove the old field in a subsequent publish.

Changing a field's type

Similar to renaming — you can't change a field's type in-place. Use add/migrate/remove.

Option A: New field (safe)

blog: object({
  priority: string(),
  priorityLevel: number().optional()
})

Migrate, then remove the old field.

Option B: Widen the type temporarily

If the old and new types can coexist:

blog: object({
  priority: union([string(), number()])
})

Migrate all documents to the new type, then narrow:

blog: object({
  priority: number()
})

SpacetimeDB doesn't support in-place type changes. Use the rename pattern: add a new field with the new type, migrate data, remove the old field.

Adding an enum value

Add the value to the Zod enum:

category: zenum(['tech', 'life', 'tutorial', 'news'])

Deploy. No migration needed — existing documents keep their old values.

Update the enum in your schema and republish. Existing rows are unaffected.

Removing an enum value

  1. Stop creating new documents with the old value.
  2. Migrate existing documents to a new value:
const migrateCategory = m({
  args: {},
  handler: async c => {
    const docs = await c.db.query('blog').collect()
    for (const doc of docs)
      if (doc.category === 'tutorial')
        await c.patch(doc._id, { category: 'tech' })
  }
})
  1. Remove the value from the enum.
  2. Deploy.
  1. Stop creating new rows with the old value.
  2. Write a migration reducer to update existing rows.
  3. Remove the value from the enum in your schema.
  4. Republish.

Index changes

If your field change affects a Convex index, update the index definition alongside the schema change. Convex rebuilds indexes automatically on deploy.

export default defineSchema({
  blog: ownedTable(owned.blog)
    .index('by_category', ['category'])
    .index('by_status', ['status'])
})

Add or remove indexes by modifying the table definition:

const post = table(
  { public: true },
  {
    id: t.u32().autoInc().primaryKey(),
    title: t.string(),
    category: t.string().index(),
    updatedAt: t.timestamp(),
    userId: t.identity().index()
  }
)

const wiki = table(
  {
    public: true,
    indexes: [
      {
        accessor: 'orgIdSlug',
        algorithm: 'btree',
        columns: ['orgId', 'slug']
      }
    ]
  },
  {
    /* fields */
  }
)

Republish to apply index changes. SpacetimeDB rebuilds indexes on publish.

Reducer changes (SpacetimeDB)

Convex functions are deployed atomically with the schema. Adding, removing, or changing function signatures is safe as long as you follow the same backward-compatible patterns.

Adding, removing, or changing reducer signatures follows the same pattern: edit the module, republish, regenerate bindings.

If you remove a reducer that clients are calling, those calls will fail with an error. Coordinate client and server deploys to avoid downtime.

Deployment strategy

Change typeSafe to deploy directly?
Add optional fieldYes
Add enum valueYes
Remove unused fieldYes
Make optional field requiredOnly after backfill
Remove enum valueOnly after migration
Rename fieldNo — use add/migrate/remove
Change field typeNo — use add/migrate/remove

Zero-downtime pattern

For breaking changes, use a two-phase deployment:

Phase 1: Deploy backward-compatible schema

blog: object({
  oldField: string().optional(),
  newField: string().optional()
})

Phase 2: Run migration, then deploy final schema

blog: object({
  newField: string()
})

For production deployments with live users:

  1. Backward-compatible changes first: add optional fields, new tables, new reducers. Deploy server.
  2. Deploy client: update client to use new fields/reducers.
  3. Remove old fields: once all clients are updated, remove deprecated fields. Deploy server again.

This two-phase approach avoids breaking live clients during a deploy.

Form compatibility

When you add or remove a field, form components adapt automatically:

  • New optional field: form renders with empty/default value
  • Removed field: remove the <Text name='removedField' /> from JSX — if you forget, the form-field-exists ESLint rule catches it
  • Renamed field: update the name prop — form-field-exists catches typos
  • Type change: update the component — form-field-kind warns if you use <Text> for a boolean field

After any schema change, regenerate bindings:

spacetime generate --lang typescript --module-path backend/spacetimedb/ --out-dir backend/spacetimedb/module_bindings/

Then update your zodFromTable calls. If you removed a field, zodFromTable will no longer include it in the schema, and any form fields referencing it will get TypeScript errors.

Checking the current schema

The Convex dashboard shows your current schema and all deployed functions. Run bunx convex dev --once to see any schema validation errors before deploying.

spacetime describe my-app

spacetime describe my-app --table post

Resetting a local module (SpacetimeDB)

To reset local Convex data during development, use the Convex dashboard or run bunx convex dev --once after clearing the local database.

During development, you can wipe and republish. Never do this in production.

spacetime delete my-app

spacetime publish my-app --module-path backend/spacetimedb/

On this page