BETTER-CONVEX

Mutations

Insert, update, and delete operations with Drizzle-style builders

In this guide, we'll cover how to write data with the ORM -- inserting rows, updating fields, and deleting documents. You'll use familiar Drizzle-style builders while the ORM handles constraint enforcement, foreign key cascades, and batching under the hood.

Dedicated Pages

Each mutation type has its own detailed page:

Setup

All mutation examples assume you attached ORM to ctx.orm once in your context (see Quickstart).

Here's a typical import block for mutation handlers:

convex/functions/users.ts
import { z } from 'zod';
import { eq } from 'kitcn/orm';
import { publicMutation } from '../lib/crpc';
import { users } from '../schema';

Shared Concepts

Before diving into each mutation type, let's cover the 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.

Insert

Here's a basic insert that creates a new user:

convex/functions/users.ts
export const createUser = publicMutation
  .input(z.object({ name: z.string(), email: z.string().email() }))
  .mutation(async ({ ctx, input }) => {
    await ctx.orm.insert(users).values({
      name: input.name,
      email: input.email,
    });
  });

Upsert (onConflict)

You can handle conflicts with onConflictDoUpdate:

await ctx.orm
  .insert(users)
  .values({ email: 'ada@example.com', name: 'Ada' })
  .onConflictDoUpdate({
    target: users.email,
    set: { name: 'Ada Lovelace' },
  });

For more options including onConflictDoNothing and multi-row inserts, see Insert.

Update

Here's a basic update that renames a user by id:

convex/functions/users.ts
export const updateUserName = publicMutation
  .input(z.object({ userId: z.string(), newName: z.string() }))
  .mutation(async ({ ctx, input }) => {
    await ctx.orm
      .update(users)
      .set({ name: input.newName })
      .where(eq(users.id, input.userId));
  });

For paginated updates, async batching, and more, see Update.

Delete

Here's a basic delete that removes a user by id:

convex/functions/users.ts
export const deleteUser = publicMutation
  .input(z.object({ userId: z.string() }))
  .mutation(async ({ ctx, input }) => {
    await ctx.orm.delete(users).where(eq(users.id, input.userId));
  });

For soft deletes, scheduled deletes, and async batching, see Delete.

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 table
const 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 batch
  • isDone -- true when no more pages remain
  • numAffected -- rows affected in this page
  • page -- 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.

convex/functions/users.ts
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.

You now have all the building blocks for writing data. Dive into the sub-pages below for detailed coverage of each mutation type.

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.

Next Steps

On this page