Database

Queries and mutations

Declare browser-callable reads and atomic writes, with typed input and server-side validation.

On this page

A query reads a consistent database snapshot. A mutation writes in one transaction. Both handlers run on the service; the browser imports generated callables rather than the handler module.

After generation, import the schema-bound constructors from ./_generated/functions. The examples use the quickstart’s tasks table.

A handler alone is the shorthand for a declaration without options:

db/tasks.ts
import { query } from './_generated/functions'

export const list = query((ctx) =>
  ctx.db.select('tasks').orderBy('title').limit(100)
)

ctx.db is read-only in a query. The service tracks its database reads so a subscription can reevaluate after relevant changes. Return selected rows, an aggregate or another supported value; the result does not have to be a table.

Use an options object when a query accepts input. Both queries and mutations support the same declaration shape:

db/tasks.ts
import { query, mutation } from './_generated/functions'
import { z } from 'zod'

export const unfinished = query({
  input: z.object({ limit: z.number().int().min(1).max(100) }),
  handler: (ctx, input) =>
    ctx.db.select('tasks').where({ completed: false }).limit(input.limit),
})

The handler receives ctx first and validated input second. The browser call is unfinished({ limit: 20 }); its input and result types come from the declaration. Zod is one Standard Schema validator, not a requirement of the database.

Table fields use v to describe storage rules. Function validators describe the values the handler accepts, including explicit transformations. See schema validation to reuse table fields.

db/tasks.ts
export const create = mutation({
  input: z.object({ title: z.string().min(1) }),
  handler: (ctx, input) => ctx.db.insert('tasks', input),
})

All database operations performed by this handler share its transaction. Throwing rolls back its writes. A failed database operation also prevents commit if caught. There is no caller-supplied storage version or separate transaction declaration.

Mutations use serializable OCC with up to three conflict retries after the first attempt. Each retry follows a confirmed rollback and reruns the handler against current data. Application failures and uncertain commits are not replayed. Keep handlers free of external effects and await their database operations.

Declarations are browser-callable. Protect them in the handler or a shared guard. With auth configured to return an identity containing id, a guarded query constructor can prepare a user once:

db/guards.ts
import { query } from './_generated/functions'

export const authenticatedQuery = query.extend((ctx) => {
  if (!ctx.identity) throw new Error('Sign in required')
  return { user: ctx.identity }
})

The derived constructor keeps the declaration syntax. For a table with an owner field, scope the read to the verified user:

db/private-tasks.ts
import { authenticatedQuery } from './guards'

export const list = authenticatedQuery((ctx) =>
  ctx.db.select('privateTasks').where({ owner: ctx.user.id })
)

Extensions run in declaration order. Each stage validates its input before running its handler, and returned properties become part of ctx.

Keep these names distinct:

  • Context properties cannot duplicate earlier properties or use reserved names.
  • Parsed input keys cannot repeat across extensions or the final declaration, even when their values match.

An extension’s input schema validates fields from the same second argument the final handler receives. There is no separate locals object. Set limits and output validation on the final declaration.

An optional output schema checks the handler result before it leaves the transaction. An explicit schema transform can change the returned representation:

db/tasks.ts
export const createTitle = mutation({
  input: z.object({ title: z.string().min(1) }),
  output: z.string().transform((title) => title.toUpperCase()),
  handler: async (ctx, input) => (await ctx.db.insert('tasks', input)).title,
})

The stored title keeps its original case; the caller receives the transformed string. Output validation failure rolls back the mutation. There is no implicit coercion added around your validator.

If server routes and declarations need the same read, extract the database work:

db/task-data.ts
import type { ReadDatabase } from './_generated/types'

export const readTasks = (database: ReadDatabase) =>
  database.select('tasks', ['id', 'title']).orderBy('title').limit(50)

Use readTasks(ctx.db) in a declaration and readTasks(db) in application server code. Use WriteDatabase for a helper that also writes. Exporting a helper does not expose it as an endpoint.

Email, payments, filesystem access and framework APIs belong in ordinary server routes/actions/jobs. Handlers can bundle compatible computation and validation libraries, but cannot use arbitrary Node/Bun modules or external I/O. Generation diagnoses unsupported imports; the service enforces the execution boundary too.

Next, observe results in the browser, connect a framework binding, or use the database builders for more reads and writes.