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:
- Edit your module (
backend/spacetimedb/src/index.ts) - Publish:
spacetime publish my-app --module-path backend/spacetimedb/ - Regenerate bindings:
spacetime generate --lang typescript --module-path backend/spacetimedb/ --out-dir backend/spacetimedb/module_bindings/ - 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
- Remove all frontend code that reads or writes the field.
- Keep the field in the Zod schema as
optional()during a transition period. - Deploy the frontend changes.
- Remove the field from the Zod schema.
- 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.
- Add the new field name as
optional():
blog: object({
title: string().min(1),
body: string().min(3),
content: string().optional()
})- 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 })
}
})- Update frontend to use
contentinstead ofbody. - Remove
bodyfrom the schema, makecontentrequired. - Deploy.
- 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()
}
)- 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 })
}
}
}
)- Run the migration:
spacetime call my-app migrate_content_to_body '{}'- Update client code to use
bodyinstead ofcontent. - 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
- Stop creating new documents with the old value.
- 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' })
}
})- Remove the value from the enum.
- Deploy.
- Stop creating new rows with the old value.
- Write a migration reducer to update existing rows.
- Remove the value from the enum in your schema.
- 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 type | Safe to deploy directly? |
|---|---|
| Add optional field | Yes |
| Add enum value | Yes |
| Remove unused field | Yes |
| Make optional field required | Only after backfill |
| Remove enum value | Only after migration |
| Rename field | No — use add/migrate/remove |
| Change field type | No — 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:
- Backward-compatible changes first: add optional fields, new tables, new reducers. Deploy server.
- Deploy client: update client to use new fields/reducers.
- 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, theform-field-existsESLint rule catches it - Renamed field: update the
nameprop —form-field-existscatches typos - Type change: update the component —
form-field-kindwarns 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 postResetting 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/