Mutations
Insert, update, and delete operations with Drizzle-style builders
Dedicated Pages
Each mutation type has its own detailed page:
Insert
Insert rows, returning, and onConflict upserts
Update
Update rows with set/where/returning
Delete
Delete rows with where/returning
Setup
All mutation examples assume you attached ORM to ctx.orm once in your context (see Quickstart).
Typical import block for mutation handlers:
import { z } from 'zod';
import { eq } from 'kitcn/orm';
import { publicMutation } from '../lib/crpc';
import { users } from '../schema';Shared Concepts
Concepts that apply across insert, update, and delete.
Returning
Every mutation builder supports .returning() to get back the affected rows. Without it, the result is void.
You can return all fields or pick specific columns:
// Return all fields
const [user] = await ctx.orm
.insert(users)
.values({ name: 'Ada', email: 'ada@example.com' })
.returning();
// Return specific fields
const [partial] = await ctx.orm
.insert(users)
.values({ name: 'Ada', email: 'ada@example.com' })
.returning({ id: users.id, email: users.email });Note: .returning() always returns an array, even for single-row mutations.
Where Clauses
update() and delete() require a .where(...) clause by default. Calling without one throws an error unless you explicitly opt in with .allowFullScan().
// Target specific rows with where
await ctx.orm.update(users).set({ name: 'Updated' }).where(eq(users.id, userId));
// Operate on ALL rows (use with care)
await ctx.orm.delete(users).allowFullScan();For more on allowFullScan and index compilation, see Querying Data.
Atomicity
Important: Convex mutations are atomic -- all changes in a single mutation call succeed or fail together.
The ORM enforces runtime limits on how many rows a single mutation can touch. See API Reference -- Safety Limits for defaults and override syntax.
Paginated Mutation Batches
For large update/delete workloads that exceed safety limits, you can process rows page-by-page with .paginate(). This requires an index on the filtered field:
// Schema: index('by_role').on(t.role) on users tableconst batch = await ctx.orm
.update(users)
.set({ role: 'member' })
.where(eq(users.role, 'pending'))
.paginate({ cursor: null, limit: 100 });The paginated result includes:
continueCursor-- cursor for the next batchisDone--truewhen no more pages remainnumAffected-- rows affected in this pagepage-- returned rows (only when.returning()is used)
Async Mutation Batching
Mutations that affect large sets of rows run in async mode by default. The first batch runs in the current mutation, then remaining batches are scheduled automatically through the Convex scheduler.
import { eq } from 'kitcn/orm';
import { publicMutation } from '../lib/crpc';
import { users } from '../schema';
export const renameUsers = publicMutation.mutation(async ({ ctx }) => {
await ctx.orm
.update(users)
.set({ name: 'Updated' })
.where(eq(users.role, 'pending')); // async by default
});You can customize batch size and delay per call:
await ctx.orm
.update(users)
.set({ name: 'Updated' })
.where(eq(users.role, 'pending'))
.execute({ batchSize: 200, delayMs: 0 });Sync Mode
To force all rows to be processed in a single transaction (no scheduling), opt into sync mode:
- Per call:
.execute({ mode: 'sync' }) - Global default:
defineSchema(..., { defaults: { mutationExecutionMode: 'sync' } })
Sync mode throws if matched rows exceed mutationMaxRows.
API Reference
Safety Limits
The ORM enforces runtime limits on how many rows a single mutation can touch. The key defaults are:
mutationBatchSize:400(page size for collecting matched rows)mutationMaxRows:10000(sync-mode hard cap)mutationLeafBatchSize:1600(async FK fan-out batch size)
You can customize these in your schema definition. For the full list of configurable defaults, see Schema Definition -- Runtime Defaults.