Reading and writing
A generated client has one accessor per table, db.<table>, with the methods on this page. The examples use db.users and db.posts.
| Task | Methods |
|---|---|
| Reading | findMany, findUnique, findFirst, findUniqueOrThrow, findFirstOrThrow |
| Writing | create, createMany, update, updateMany, upsert, delete, deleteMany |
| Streaming | findManyStream, findManyStreamBatches |
| Filtering and sorting | where, orderBy, limit, cursor, distinct |
| Aggregations | count, aggregate, groupBy |
| Raw SQL | db.raw, db.rawUnsafe, db.sql |
Reading#
findMany#
Returns every row matching the query.
const users = await db.users.findMany({
where: { role: 'admin', orgId: 1 },
orderBy: { createdAt: 'desc' },
limit: 20,
offset: 0,
});| Option | What it does |
|---|---|
where | Filter rows |
select / omit | Choose columns |
with | Load relations |
orderBy | Sort |
limit / take, offset / skip, cursor | Page |
distinct | One row per distinct value (PostgreSQL) |
lock | Lock the rows inside a transaction |
timeout | Milliseconds |
includePii | UNSAFE returns PII-tagged columns |
skipGlobalFilters | UNSAFE, or [UNSAFE, ...accessors], skips global filters |
relationLoadStrategy | 'auto', 'join', 'batched' or 'flatten' |
jsonEncoding | 'object' or 'positional' encoding for with rows |
stableRelationOrder | Order unordered to-many relations by primary key |
forceCustomPlan | Plan with the real values on every run (PostgreSQL) |
warnOnUnlimited | false silences the unlimited-query warning |
A findMany with no limit, take or cursor logs unlimited-find-many once per table, in production too. A where that pins a unique key, or a client defaultLimit, skips the warning.
// A tiny lookup table: no warning wanted
const roles = await db.roles.findMany({ warnOnUnlimited: false });On the client, set warnOnUnlimited: false, or a per-table map such as { userProfiles: false }. Per call beats per table, which beats global.
findUnique / findUniqueOrThrow#
Returns the row a unique key identifies, or null. findUniqueOrThrow throws NotFoundError (E001) instead.
const user = await db.users.findUnique({ where: { id: 42 }, with: { posts: true } });
// Composite key: name every column
const row = await db.memberships.findUnique({ where: { userId: 1, orgId: 2 } });
// E003: an insensitive match can hit "Ada@x.com" and "ada@x.com". Use findFirst.
await db.users.findUnique({ where: { email: { equals: input, mode: 'insensitive' } } });where must name the primary key, a unique column, every column of a compound unique, or a compound-unique selector. Each key takes a plain value or { equals: value }, and extra predicates are fine.
A key compared with null, in, a range, mode or a column reference does not identify a row. A where with no identifying key throws ValidationError (E003) naming the keys that work. update, delete and upsert follow the same rule.
findFirst / findFirstOrThrow#
Returns the first row in orderBy order, or null. findFirstOrThrow throws NotFoundError instead.
const post = await db.posts.findFirst({
where: { authorId: 42, published: true },
orderBy: { createdAt: 'desc' },
});A where whose every key is undefined throws E003, so a missing token cannot match an arbitrary row. Omit where, or pass {}, to ask for any row.
Selecting fields#
select / omit#
const users = await db.users.findMany({ select: { id: true, email: true } });
const safe = await db.users.findMany({ omit: { passwordHash: true } });At any level, these throw E003: select and omit both naming fields; a select naming no field; a name that is not a column. A relation name gets its own message pointing at with.
Keys may use the field name (lastRun) or the column name (last_run), and relations their declared name (blogPosts) or table spelling (blog_posts). Both compile to the same SQL, and results use the field and declared names.
PII fields: includePii#
Columns tagged pii: true in defineSchema are left out of every default projection, write results included. A read returns them, at every with level, with includePii: UNSAFE.
import { UNSAFE } from 'turbine-orm';
const u = await db.users.findFirst({ where: { id: 1 } });
u.email; // undefined: email is tagged pii
// E003: select alone is not an opt-in
await db.users.findFirst({ where: { id: 1 }, select: { email: true } });
await db.users.findFirst({ where: { id: 1 }, select: { email: true }, includePii: UNSAFE });- Reads, streams,
groupByandaggregatetake it; writes do not. where,orderByandhavingon a PII column need no opt-in: they return no value.- A PII column in the primary key is always returned.
- Grouping by a PII column, or
_min,_maxor_percentileover one, needs the opt-in._count,_sumand_avgdo not. - A write
selectnaming a PII column throws E003. Read the row back withincludePii: UNSAFE.
Relations in reads#
with loads relations into each row. Each takes its own where, orderBy, limit, offset, select, omit and with:
const users = await db.users.findMany({
with: {
posts: {
where: { published: true },
orderBy: { createdAt: 'desc' },
limit: 5, // per user
with: { comments: true },
},
},
});
users[0].posts[0].comments[0]; // fully typedNesting stops at 10 levels (E007). Relations covers the options and load strategies.
Counting related rows, _count#
const users = await db.users.findMany({ with: { _count: true } });
users[0]._count; // { posts: 12, orgs: 3 }
const authors = await db.users.findMany({ with: { posts: true, _count: { posts: true } } });
authors[0]._count.posts; // 12It counts hasMany and manyToMany relations; a to-one relation throws E003. Under 'auto', a count over an unindexed foreign key runs as one grouped follow-up query, with the same numbers.
Row locks#
findMany, findFirst, findUnique and their *OrThrow forms take lock, held until the transaction ends:
await db.$transaction(async (tx) => {
const job = await tx.jobs.findFirst({
where: { status: 'queued' },
orderBy: { id: 'asc' },
lock: { mode: 'update', skipLocked: true }, // FOR UPDATE OF "jobs" SKIP LOCKED
});
if (job) await tx.jobs.update({ where: { id: job.id }, data: { status: 'running' } });
});lock | SQL |
|---|---|
'update', 'noKeyUpdate', 'share', 'keyShare' | FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, FOR KEY SHARE |
{ mode, noWait: true } | Fail at once on a locked row |
{ mode, skipLocked: true } | Leave locked rows out |
- Only the queried table is locked, not rows loaded by
with. - Outside a transaction, on a stream, or with
distinct, it throws E003. - MySQL takes
'update'and'share'; SQLite, SQL Server and PowDB throw E017.
A lock that cannot be taken throws LockNotAvailableError, which is retryable. On PostgreSQL it aborts the transaction, so try uncertain locks in a nested block:
import { LockNotAvailableError } from 'turbine-orm';
await db.$transaction(async (tx) => {
try {
await tx.$transaction((inner) =>
inner.accounts.findUnique({ where: { id }, lock: { mode: 'update', noWait: true } }),
);
} catch (err) {
if (!(err instanceof LockNotAvailableError)) throw err;
// Only the savepoint rolled back.
}
});Writing#
create#
Inserts one row and returns it, generated columns included.
const user = await db.users.create({ data: { email: 'alice@example.com', name: 'Alice', orgId: 1 } });
const event = await db.events.create({ data: {} }); // INSERT ... DEFAULT VALUESTakes data, select / omit and timeout.
Returning fewer columns#
create, createMany, update, delete and upsert take select / omit with the read rules, checked before anything is written. Only the chosen columns cross the wire.
const { id } = await db.events.create({ data: { kind: 'click', payload }, select: { id: true } });Writing JSON columns#
| Value written | Stored |
|---|---|
| An object or array | That JSON |
| A string | Raw JSON text: '{"a":1}' stores { a: 1 } |
null | SQL NULL |
jsonValue(v) | JSON.stringify(v): a JSON string, or JSON null |
import { jsonValue } from 'turbine-orm';
await db.docs.create({ data: { data: jsonValue('hello') } }); // the JSON string "hello"On PostgreSQL, an object or array written to a built-in scalar column, such as text, a number or a date, throws E003. Pass JSON.stringify(value) to store it as text.
createMany#
Inserts many rows in one statement and returns them. A plain MySQL createMany returns [] (Engines).
const users = await db.users.createMany({
data: [
{ email: 'a@b.com', name: 'A', orgId: 1 },
{ email: 'b@b.com', name: 'B', orgId: 1 },
],
});| Option | What it does |
|---|---|
data | The rows. Every row must name the same fields (E003 otherwise) |
skipDuplicates | Skip rows that hit any unique key. E017 on SQL Server and PowDB |
onConflict | Upsert on one unique key. Not with skipDuplicates |
returning | false resolves to { count } |
select / omit | Narrow the returned rows |
skipGlobalFilters, timeout | As on findMany |
- A large batch is split into chunks sized to the engine's parameter and row limits, inside one transaction, so all rows land or none. Inside
$transaction, one failed chunk rolls back every chunk, even if you catch the error. - An oversized batch throws E017 on a pool without
connect(), such as an HTTP driver.buildCreateManyis one statement, so it throws E003. - Several all-defaults rows throw E017 on SQLite and SQL Server; call
create({ data: {} })per row.
Counting instead of returning rows: returning: false#
const { count } = await db.events.createMany({ data: batch, returning: false });A plain insert then sends no RETURNING. count is rows inserted or updated, never skipped ones. select or omit beside it throws E003; MySQL refuses it with skipDuplicates (E017).
To fill several tables together, pipeline one buildCreateMany per table. They share a transaction, but none sees ids another generates:
const [orders, lines] = await db.pipeline(
db.orders.buildCreateMany({ data: orderRows, returning: false }),
db.orderLines.buildCreateMany({ data: lineRows, returning: false }),
);Multi-row upsert: onConflict#
import { col, expr } from 'turbine-orm';
await db.users.createMany({
data: [{ email: 'a@b.com', name: 'A' }, { email: 'b@b.com', name: 'B' }],
onConflict: { target: ['email'], update: ['name'] },
});
// INSERT ... ON CONFLICT ("email") DO UPDATE SET "name" = EXCLUDED."name" RETURNING *
await db.pageViews.createMany({
data: batch,
onConflict: {
target: ['path'],
update: { views: expr.add(col('views'), expr.excluded('views')) },
where: { archived: false },
},
});| Key | What it does |
|---|---|
target | The primary key or one full unique key, in any order. Not a partial index |
update | Fields that take the incoming value, or a per-column object of values, operators and expressions |
where | Update only matching rows; others are skipped and not returned |
ignore: true | Instead of update: leave existing rows alone |
Two rows with one key, even keys equal only under the column's collation, throw E003 and write nothing. Global filters apply to the update, updatedAt is stamped unless update names it, and rows return in no set order.
| Engine | Statement |
|---|---|
| PostgreSQL, SQLite | INSERT ... ON CONFLICT |
| SQL Server | MERGE ... WITH (HOLDLOCK) |
| MySQL 8.0.19+ | ON DUPLICATE KEY UPDATE. E017 for where, ignore, a global filter, a NULL key, or a second unique key in data |
| PowDB | E017 |
update / updateMany#
update changes one row by unique key and returns it. updateMany changes every match and returns { count }.
await db.users.update({ where: { id: 42 }, data: { name: 'Alice Updated' } });
await db.users.updateMany({ where: { role: 'guest' }, data: { role: 'member' } });| Option | What it does |
|---|---|
where | update: one row, as in findUnique. updateMany: any filter |
data | Values, operators or expressions |
select / omit | update only |
optimisticLock | update only. See Optimistic locking |
allowFullTableScan | UNSAFE disarms the mass-mutation guard and the one-row rule |
skipGlobalFilters, timeout | As on findMany |
updatedAt columns are stamped unless named. limit, take, offset and skip on updateMany or deleteMany throw E003; select the ids first:
const batch = await db.jobs.findMany({ where: { status: 'queued' }, select: { id: true }, orderBy: { id: 'asc' }, limit: 100 });
await db.jobs.updateMany({ where: { id: { in: batch.map((j) => j.id) } }, data: { status: 'running' } });Atomic update operators#
data: {
viewCount: { increment: 1 }, // "view_count" = "view_count" + $1
likesCount: { decrement: 1 },
score: { multiply: 2 },
rank: { divide: 2 }, // truncates on an integer column
title: { set: 'New title' },
}Concurrent updates cannot lose writes. On a JSON column only set is an operator; in create data an operator object is a plain value.
Update expressions: expr#
Build a value from the row with expr and col(). It runs inside the UPDATE:
import { col, expr } from 'turbine-orm';
await db.posts.update({
where: { id: 1 },
data: {
viewCount: expr.add(col('viewCount'), 1),
peakViews: expr.greatest(col('peakViews'), col('viewCount')),
tier: expr.case([{ when: { viewCount: { gt: 1000 } }, then: 'hot' }], 'cold'),
seenAt: expr.now(),
},
});| Builder | Notes |
|---|---|
expr.add, sub, mul, div | Numeric. Two integers give integer math, and div truncates toward zero |
expr.coalesce, greatest, least | 2 to 8 operands. greatest and least ignore NULLs |
expr.case(arms, otherwise?) | 1 to 16 { when, then } arms; when is a filter on the row |
expr.now() | Temporal columns; UTC for zone-less ones |
expr.excluded('field') | The incoming value, in upsert and onConflict updates |
expr.secondsBetween(a, b) | Only in a percentile's of |
- Operands are values,
col()references or expressions, so they nest. A non-integer result into an integer column throws E003. - Expressions throw E003 in
createandcreateManydata, inside{ set }, and on JSON or array columns. PowDB and prisma-compat throw E017. - Only
exprbuilds one: a look-alike object is a value, so request data cannot become SQL. - If your generated update types reject
expr, regenerate the client. Lint rules banning athenproperty flagcasearms.
upsert#
Updates the row where names, or inserts create, and returns the row. where must identify one row, as in findUnique.
const user = await db.users.upsert({
where: { email: 'alice@example.com' },
create: { email: 'alice@example.com', name: 'Alice', orgId: 1 },
update: { name: 'Alice Updated' },
});When every where key has the same value in create, it is one atomic INSERT ... ON CONFLICT. Otherwise Turbine looks the row up, then updates or inserts, in one transaction. These take the lookup:
- A
wherevalue thatcreatelacks or contradicts. update: {}, or nested writes.- A global filter on MySQL or SQL Server.
- On MySQL, a
createthat sets another unique key.
Like Prisma's, the lookup can race a concurrent insert, which then fails with UniqueConstraintError (E008). A batched buildUpsert refuses these shapes.
delete / deleteMany#
delete removes one row by unique key and returns it. deleteMany returns { count }.
await db.users.delete({ where: { id: 42 } });
await db.users.deleteMany({ where: { createdAt: { lt: thirtyDaysAgo } } });import { UNSAFE } from 'turbine-orm';
await db.sessions.deleteMany({ where: {}, allowFullTableScan: UNSAFE }); // every row, on purposeallowFullTableScan: true throws; write a conditional as ...(flag ? { allowFullTableScan: UNSAFE } : {}). A list that may be empty (notIn: ids) needs that decision. An always-false where is allowed and matches nothing.
Streaming#
findManyStream#
Yields rows through an async iterator. On PostgreSQL it reads a server-side cursor in constant memory. SQLite, MySQL and SQL Server load the result, then yield it in batches.
for await (const user of db.users.findManyStream({ where: { orgId: 1 }, batchSize: 1000, with: { posts: true } })) {
process.stdout.write(`${user.email}\n`);
}
// One array per fetch, without a promise per row
for await (const batch of db.users.findManyStreamBatches({ where: { orgId: 1 }, batchSize: 500 })) {
await sink.writeAll(batch);
}Both take findMany's options except lock, plus batchSize (default 1000) and idleTimeoutMs. A result that fits one batch skips the cursor. Breaking out releases the connection.
Abandoned streams close themselves#
A stream holding a cursor closes after five minutes without a pull. It logs stream-idle-timeout, and the next pull throws TimeoutError (E002).
// Per call: a consumer that pauses for up to 30 minutes
for await (const row of db.events.findManyStream({ idleTimeoutMs: 30 * 60_000 })) {
await slowSink.write(row);
}
// Per client. 0 disables it.
const db = turbine({ connectionString: process.env.DATABASE_URL, streamIdleTimeoutMs: 0 });A stream inside $transaction is bounded by the transaction's timeout. On PostgreSQL the server also ends the stream's transaction if the process freezes.
See also#
- Filtering and sorting:
whereoperators, ordering and pagination. - Relations:
with, counts and load strategies. - Nested writes: create and update related rows in one call.
- Transactions: savepoints, isolation and timeouts.