BETTER-CONVEX

Delete

Delete rows with Drizzle-style builders

In this guide, we'll learn how to delete rows using the ORM's Drizzle-style delete() builder. You'll see basic deletes, returning clauses, paginated execution, async batching, and advanced delete modes like soft and scheduled deletes.

Delete Rows

Let's start with a mutation that deletes a user by id:

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

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

To delete all rows in a table (use with care):

await ctx.orm.delete(users).allowFullScan();

Important: delete() without .where(...) throws unless you call .allowFullScan(). See Querying Data for details on allowFullScan.

Returning

Use .returning() to get back the deleted rows. You can return all fields or pick specific columns:

const deleted = await ctx.orm
  .delete(users)
  .where(eq(users.id, userId))
  .returning();

const ids = await ctx.orm
  .delete(users)
  .where(eq(users.id, userId))
  .returning({ id: users.id });

Safety Limits

The ORM collects matching rows in bounded pages before applying writes. The key defaults are:

  • mutationBatchSize: 100
  • mutationMaxRows: 1000
  • mutationLeafBatchSize: 900 (async FK fan-out)

If matched rows exceed mutationMaxRows, the delete throws. You can customize these values in your schema:

export default defineSchema({ users, posts }, {
  defaults: {
    mutationBatchSize: 200,
    mutationMaxRows: 5000,
  },
});

For the full list of configurable defaults, see Schema Definition -- Runtime Defaults.

Paginated Delete Execution

For large workloads, you can process delete batches page-by-page with .paginate(). This requires an index on the filtered field:

// Schema: index('by_role').on(t.role) on users table
const page1 = await ctx.orm
  .delete(users)
  .where(eq(users.role, 'inactive'))
  .paginate({ cursor: null, limit: 100 });

if (!page1.isDone) {
  const page2 = await ctx.orm
    .delete(users)
    .where(eq(users.role, 'inactive'))
    .paginate({ cursor: page1.continueCursor, limit: 100 });
}

Each page returns:

  • continueCursor -- cursor for the next batch
  • isDone -- true when no more pages remain
  • numAffected -- rows deleted in this page
  • page -- returned rows (only when .returning() is used)

Note: paginate() currently supports single-range index plans. Multi-probe filters (inArray, some OR patterns, complement ranges) are not yet supported in paged mutation mode.

Async Batched Delete

When a delete can affect large sets of rows, use async mode. The first batch runs in the current mutation, then remaining batches are scheduled automatically.

You can enable async mode in three ways:

  • Per call: .execute({ mode: 'async' })
  • Convenience alias: .executeAsync()
  • Global default: defineSchema(..., { defaults: { mutationExecutionMode: 'async' } })

Here's an async delete with custom batch size:

const firstBatch = await ctx.orm
  .delete(users)
  .where(eq(users.role, 'inactive'))
  .returning({ id: users.id })
  .execute({ mode: 'async', batchSize: 200, delayMs: 0 });

Key behaviors to keep in mind:

  • execute() in async mode returns the same shape as sync mode (with .returning(), you get rows from the first batch only)
  • Remaining batches are scheduled asynchronously
  • Async APIs (execute({ mode: 'async' }) / executeAsync()) cannot be combined with .paginate() or resolved scheduled delete mode (.scheduled(...) or table-level deletion('scheduled'))
  • batchSize resolves as: per-call batchSize > defaults.mutationBatchSize > 100
  • delayMs resolves as: per-call delayMs > defaults.mutationAsyncDelayMs > 0
  • FK/cascade async fan-out uses mutationBatchSize for recursive delete cascade (onDelete: 'cascade') and mutationLeafBatchSize for non-recursive actions (set null, set default)

Important: Async execution requires wiring ormFunctions and scheduledMutationBatch in your ORM setup. See Mutations -- Async Wiring Setup for the setup steps.

Table Default Delete Mode

You can configure default delete semantics per table using deletion(...) in the table definition. This is useful when a table should always default to soft or scheduled deletes.

convex/schema.ts
import { convexTable, deletion, integer, text } from 'better-convex/orm';

const users = convexTable(
  'users',
  {
    slug: text().notNull(),
    deletionTime: integer(),
  },
  () => [deletion('scheduled', { delayMs: 60_000 })]
);

Delete mode precedence (highest to lowest):

  1. Per-query override (.hard(), .soft(), .scheduled({ delayMs }))
  2. Table default via deletion(...)
  3. Fallback: 'hard'

When a table defaults to scheduled mode, use .hard() to opt out for a specific delete:

await ctx.orm.delete(users).where(eq(users.id, userId)).hard();

Scheduled Delete Cancellation

.scheduled({ delayMs }) stores the current deletionTime and passes it to the scheduled worker. The worker only proceeds if the row still has the same deletionTime.

To cancel a pending scheduled hard-delete, clear or change deletionTime before the worker runs.

Drizzle Differences

A few SQL-only features from Drizzle are not applicable in Convex:

  • limit, orderBy, and WITH clauses are not supported
  • Deletes are executed against Convex documents; there is no SQL DELETE FROM ...

Note: Foreign key actions and RLS policies are enforced at runtime for ORM deletes. Direct ctx.db.delete(...) bypasses these checks.

You now have everything you need to delete data -- hard, soft, and scheduled modes with full cascade support.

Next Steps

On this page