Database

Read and write data

Use typed PostgreSQL builders in server code and inside query or mutation handlers.

On this page

Import the generated db in a server route, action, job or script. A query handler uses the same read builders through ctx.db; a mutation can also write. The examples extend the tasks table from the quick start.

server/tasks.ts
import { db } from '../db/_generated/server'

const tasks = await db
  .select('tasks')
  .where({ completed: false })
  .orderBy('title')
  .limit(20)

The result is an array of plain rows with IDs. Builders are lazy: the operation executes when awaited. Awaiting the same builder again reuses its result; create another builder for another read.

Use an object for equality, or put the field, operator and value in order:

TypeScript
const unfinished = await db.select('tasks').where({ completed: false })
const matches = await db.select('tasks').where('title', 'contains', 'guide')

Repeated where calls combine with AND. Use the callback helpers for an explicit compound expression:

TypeScript
const tasks = await db
  .select('tasks')
  .where({ completed: false })
  .where(({ or, startsWith }) =>
    or(startsWith('title', 'Write'), startsWith('title', 'Review'))
  )

Text matching is case-sensitive by default. Use { caseSensitive: false } with literal helpers when needed:

TypeScript
.where('title', 'contains', 'guide', { caseSensitive: false })

contains, startsWith, and endsWith match literal text. like and ilike interpret SQL % and _ wildcards. Negate in the operator position, as in .where('title', 'not contains', 'draft'), or use callback not(...).

Equality with null becomes IS NULL; inequality becomes IS NOT NULL. Undefined filter values throw instead of silently dropping a condition.

Use generated references when you want table-based field access:

TypeScript
import { tasks } from '../db/_generated/tables'

const rows = await db.select(tasks).where(tasks.completed, '=', false)

Both forms are typed. Field-name strings are relative to the selected table: use title, not tasks.title. Generated references come from _generated/tables, not the authored schema declaration. Strings in the value position are literals; a generated field reference there compares compatible columns of the same table.

Pass an array or inclusion map as the second argument to select:

TypeScript
const titles = await db.select('tasks', ['id', 'title']).limit(20)
const titlesAgain = await db
  .select('tasks', { id: true, title: true })
  .limit(20)

You can filter and sort by unselected fields. Conditional inclusion produces optional result properties. An empty effective selection is an error.

limit(1) returns an array with at most one row. unique() returns one row or null and rejects multiple matches. paginate({ limit, cursor }) returns a page. These are terminal alternatives; do not combine them. See live pagination for browser lists and stable ranges.

TypeScript
const task = await db.insert('tasks', { title: 'Write the guide' })
const ids = await db
  .insert('tasks', [
    { title: 'Write the guide' },
    { title: 'Review the guide' },
  ])
  .returning(['id'])

A single insert returns one row. Bulk inserts return an array. Defaults and generated IDs come from the schema. returning narrows the result with the same array or inclusion-map syntax as select.

Updates take their patch as the second argument. Both operations require where:

TypeScript
const completed = await db
  .update('tasks', { completed: true })
  .where({ title: 'Write the guide' })
  .returning(['id', 'completed'])

const deleted = await db.delete('tasks').where({ completed: true })

Both return arrays of rows actually changed; no match returns []. An update that leaves a stored row unchanged does not invent a changed-row result. Add .expectRows(1) when a missing row should fail before commit. A synchronous predicate such as .expectRows(count => count >= 1 && count <= 10) checks a range. The expectation checks the write; it does not limit how many rows it attempts to change.

An explicit .where(true) permits a whole-table operation. Undefined patch values leave known fields unchanged; null requires a nullable field. Unknown properties throw. JSON fields are replaced as complete values, without implicit deep patches.

Separate direct operations commit independently. Group dependent changes in one transaction and use the provided tx throughout:

TypeScript
await db.transaction(async (tx) => {
  await tx.insert('tasks', { title: 'Write the guide' })
  await tx.insert('tasks', { title: 'Review the guide' })
})

If a write or the callback fails, the group rolls back. Calling the outer db inside this callback throws; it cannot accidentally start a separate transaction. Await every operation before returning.

Direct transaction callbacks run once by default. Opt into conflict retries with { retries: 3 } only when the whole callback is safe to replay. Keep external effects outside it. A declared mutation already has its transaction and bounded conflict retries, so use its ctx.db without opening another transaction.

An onConflict policy targets a real primary or unique key declared in the schema. For example, configure uniqueness on the task title in your schema:

db/schema.ts
export const tasks = defineTable(
  { title: v.string(), completed: v.boolean().default(false) },
  { unique: { task_title: { fields: ['title'] } } }
)

Keep this table registered in defineSchema, regenerate, and explicitly provision the matching storage. Then an insert can skip an existing title:

TypeScript
const task = await db
  .insert('tasks', { title: 'Write the guide' })
  .onConflict(({ on }) => on('title').skip())

The result is the inserted row or null for a skip. To write the incoming completion state into an existing task instead:

TypeScript
const task = await db
  .insert('tasks', { title: 'Write the guide', completed: true })
  .onConflict(({ on }) =>
    on('title').update(({ incoming }) => ({ completed: incoming.completed }))
  )

The callback can also read current fields. A composite unique constraint needs all its fields in on(...). The policy never bypasses other database constraints. Bulk results contain only written rows and have no promised correspondence to input positions.

For a counter that several callers can update, use a numeric column and a write expression instead of reading a value into JavaScript and writing it back:

db/schema.ts
export const counts = defineTable(
  { name: v.string(), value: v.int64().default(0) },
  { primaryKey: 'name' }
)

Register counts in defineSchema, regenerate and provision it. Insert a named counter once, then update that row with an expression:

TypeScript
await db.insert('counts', { name: 'visits' })
await db
  .update('counts', (current) => ({ value: current.value.add(1) }))
  .where({ name: 'visits' })
  .expectRows(1)

The addition runs in PostgreSQL against the stored value, avoiding a separate application read/replace race. The callback constructs an expression; it is not called once per fetched row. Column constraints still apply. For a codec, SQL arithmetic follows its storage type, as explained in data types and codecs.

Named aggregates return a compact result instead of loading every row:

TypeScript
const total = await db
  .select('tasks')
  .where({ completed: false })
  .aggregate(({ count }) => ({ unfinished: count() }))

Use groupBy before aggregate for one summary per group:

TypeScript
const totals = await db
  .select('tasks')
  .groupBy('completed')
  .aggregate(({ count }) => ({ total: count() }))
  .having(({ gte }) => gte('total', 2))
  .orderBy('total', 'desc')

where filters rows; having filters aggregate groups and is available only after groupBy(...).aggregate(...). Numeric fields support sum and avg; orderable fields support min and max. Null values are skipped; empty sums/averages/extrema return null, while count() returns zero. Grouped pagination is not supported. Representation details are in data types and codecs.

When this code runs in your application server, enforce authorization before the operation. A live browser declaration needs its own guard; see queries and mutations and authentication.