Schema Updates
Database schema evolution with Convex
Convex handles schema evolution through optional fields and gradual migrations. This guide shows the actual patterns used in production code for safe schema updates.
Safe Schema Evolution
Using v.optional()
for new fields maintains compatibility with existing records and enables zero-downtime schema updates.
Schema Evolution Strategy
Update Schema Definition
Modify /convex/schema.ts
with backward-compatible changes
Run Codegen
Execute pnpm codegen
to generate TypeScript types
Commit Generated Files
Commit _generated/*
artifacts (CI relies on them, doesn't run codegen)
Deploy Migration
Run data migration functions if needed
Critical: Commit Generated Files
Always commit _generated/*
artifacts. CI depends on these files and does not run codegen.
Adding Optional Fields
The safest way to evolve your schema is adding optional fields:
Before
supportTickets: defineTable({
userId: v.id("user"),
title: v.string(),
description: v.string(),
status: v.union(
v.literal("Open"),
v.literal("In Progress"),
v.literal("Resolved")
),
updatedAt: v.number(),
})
After
supportTickets: defineTable({
userId: v.id("user"),
title: v.string(),
description: v.string(),
status: v.union(
v.literal("Open"),
v.literal("In Progress"),
v.literal("Resolved")
),
updatedAt: v.number(),
// New optional fields
priority: v.optional(v.union(
v.literal("low"),
v.literal("medium"),
v.literal("high")
)),
tags: v.optional(v.array(v.string())),
assignedTo: v.optional(v.id("user")),
})
Why Optional Fields?
Using v.optional()
maintains compatibility with existing records that don't have these fields.
Adding Indexes
Indexes optimize query performance for common access patterns:
Creating New Tables
Follow the established patterns from /convex/schema.ts
:
Define Table Schema
// Add to your schema
comments: defineTable({
userId: v.id("user"),
ticketId: v.id("supportTickets"),
content: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
deletedAt: v.optional(v.number()), // Soft delete
})
.index("by_ticket", ["ticketId", "createdAt"])
.index("by_user", ["userId"])
.index("by_deleted", ["deletedAt"]),
Update Tables Constants
export const T = {
user: "user",
session: "session",
account: "account",
verification: "verification",
billingCustomers: "billingCustomers",
supportTickets: "supportTickets",
rateLimits: "rateLimits",
comments: "comments", // Add new table
} as const;
// Add type alias
export type CommentId = Id<typeof T.comments>;
// Add validator
export const V = {
userId: v.id(T.user),
// ... other validators
commentId: v.id(T.comments), // Add validator
} as const;
Implement Functions
import { userMutation, userQuery } from "./_helpers/builders";
import { T, V, type UserId, type CommentId } from "./tables";
export const createComment = userMutation({
args: {
ticketId: V.supportTicketId,
content: v.string(),
},
handler: async (ctx, { ticketId, content }) => {
const userId = ctx.viewerId as UserId;
return await ctx.db.insert(T.comments, {
userId,
ticketId,
content,
createdAt: Date.now(),
updatedAt: Date.now(),
});
},
});
export const getTicketComments = userQuery({
args: { ticketId: V.supportTicketId },
handler: async (ctx, { ticketId }) => {
return await ctx.db
.query(T.comments)
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.filter((q) => q.eq(q.field("deletedAt"), undefined))
.collect();
},
});
Data Migration Patterns
When you need to update existing data, use mutation functions:
Changing Field Types
When you need to change a field type, use a multi-step migration:
Add New Optional Field
supportTickets: defineTable({
status: v.string(), // Old field
statusV2: v.optional(v.union(
v.literal("Open"),
v.literal("In Progress"),
v.literal("Resolved")
)), // New typed field
})
Migrate Data
export const migrateStatusToEnum = internalMutation({
args: {},
handler: async (ctx) => {
const tickets = await ctx.db
.query(T.supportTickets)
.filter((q) => q.eq(q.field("statusV2"), undefined))
.collect();
for (const ticket of tickets) {
// Map old string to new enum
const statusMap: Record<string, any> = {
"open": "Open",
"in_progress": "In Progress",
"resolved": "Resolved",
};
await ctx.db.patch(ticket._id, {
statusV2: statusMap[ticket.status] || "Open",
});
}
},
});
Update Application Code
Update queries/mutations to use statusV2
Remove Old Field
Once all data is migrated:
supportTickets: defineTable({
status: v.union(...), // Rename statusV2 to status
})
Tables Pattern Migration
When adding new tables, follow the existing pattern:
Never Hardcode Table Names
Always update convex/tables.ts
when adding new tables to maintain type safety.
// 1. Add table name constant
export const T = {
// ... existing tables
newTable: "newTable",
} as const;
// 2. Add type alias
export type NewTableId = Id<typeof T.newTable>;
// 3. Add validator
export const V = {
// ... existing validators
newTableId: v.id(T.newTable),
} as const;
Then use in functions:
import { T, V, type NewTableId } from "./tables";
export const createItem = userMutation({
args: { name: v.string() },
handler: async (ctx, { name }) => {
return await ctx.db.insert(T.newTable, {
userId: ctx.viewerId,
name,
createdAt: Date.now(),
});
},
});
Soft Delete Pattern
Implement soft deletes for data retention:
supportTickets: defineTable({
userId: v.id("user"),
title: v.string(),
description: v.string(),
status: v.union(
v.literal("Open"),
v.literal("In Progress"),
v.literal("Resolved")
),
deletedAt: v.optional(v.number()),
updatedAt: v.number(),
})
.index("by_user_id", ["userId"])
.index("by_deleted", ["deletedAt"]) // Index for filtering
Usage:
export const softDeleteTicket = userMutation({
args: { ticketId: V.supportTicketId },
handler: async (ctx, { ticketId }) => {
const userId = ctx.viewerId as UserId;
const ticket = await ctx.db.get(ticketId);
assertOwnerByUserId(ticket, userId);
await ctx.db.patch(ticketId, {
deletedAt: Date.now(),
updatedAt: Date.now(),
});
},
});
export const getActiveTickets = userQuery({
args: {},
handler: async (ctx) => {
return await ctx.db
.query(T.supportTickets)
.filter((q) => q.eq(q.field("deletedAt"), undefined))
.collect();
},
});
Schema Validation
The actual schema from /convex/schema.ts
shows validation patterns:
// Status enum validation
const supportStatusValues = ["Open", "In Progress", "Resolved"] as const;
supportTickets: defineTable({
status: v.union(...supportStatusValues.map((status) => v.literal(status))),
})
// Type guard helper
export type SupportTicketStatus = (typeof supportStatusValues)[number];
export function isValidSupportStatus(
status: string
): status is SupportTicketStatus {
return (supportStatusValues as readonly string[]).includes(status);
}
Usage in mutations:
export const updateStatus = userMutation({
args: {
ticketId: V.supportTicketId,
status: v.string(),
},
handler: async (ctx, { ticketId, status }) => {
if (!isValidSupportStatus(status)) {
throw new AppError("INVALID_INPUT", "Invalid status value");
}
await ctx.db.patch(ticketId, {
status,
updatedAt: Date.now(),
});
},
});
Testing Migrations
Test migration functions before running in production:
import { convexTest } from "convex-test";
import { expect, test } from "vitest";
import schema from "../schema";
import { backfillPriority } from "../migrations";
test("backfill priority adds default values", async () => {
const t = convexTest(schema);
// Insert test data without priority
const ticketId = await t.mutation(api.support.createSupportTicket, {
title: "Test",
description: "Test ticket",
});
// Run migration
const result = await t.mutation(backfillPriority, {});
// Verify result
const ticket = await t.query(api.support.getTicket, { id: ticketId });
expect(ticket?.priority).toBe("medium");
expect(result.updated).toBe(1);
});
Best Practices
Use Optional Fields
Add new fields as optional to maintain backward compatibility
Index Strategically
Create indexes for fields used in queries and filters
Batch Migrations
Process large datasets in batches to avoid timeouts
Test First
Always test migration functions before production deployment
Schema Evolution Checklist
- Update
convex/schema.ts
with backward-compatible changes - Run
pnpm codegen
to generate types - Update
convex/tables.ts
if adding new tables - Commit
_generated/*
files - Write migration function if needed
- Test migration thoroughly
- Deploy and monitor