Examples · 01 — The schema this page uses
Real schemas. Real SQL. No magic on the wire.
Every example below uses the schema on the right. A typed call on the model API on the left, the parameterized SQL Zanith generates on the right. Read both — the gap between them is the engine.
model User { id Int @id @default(autoincrement()) email String @unique name String? role Role @default(USER) createdAt DateTime @default(now()) posts Post[]} model Post { id Int @id @default(autoincrement()) title String body String published Boolean @default(false) authorId Int author User @relation(fields: [authorId], references: [id]) comments Comment[] createdAt DateTime @default(now())} model Comment { id Int @id @default(autoincrement()) body String postId Int post Post @relation(fields: [postId], references: [id])} enum Role { USER ADMIN EDITOR }Schema graph · sample
3 models · 1 enum · 2 relations
- 6 fields
User
1:N → Post
- 8 fields
Post
N:1 → User · 1:N → Comment
- 4 fields
Comment
N:1 → Post
- 3 variants
Role
USER · ADMIN · EDITOR
First example · canonical findMany
Match recent users by email, paginate by ten.
const users = await db.user.findMany({ where: { email: { contains: '@example.com' } }, orderBy: { createdAt: 'desc' }, take: 10,});SELECT id, email, name, role, created_atFROM usersWHERE email ILIKE $1ORDER BY created_at DESCLIMIT 10;-- $1 = '%@example.com%'Every filter shape, parameterized.
User input never reaches the SQL string. Comparison, string matching, IN lists, AND/OR/NOT — all bound as parameters by the driver, never interpolated.
ts shape
{ field: T }compiled sql
field = $1noteshorthand for `{ equals: T }`
ts shape
{ not: T }compiled sql
field <> $1notenegates equality; combine for inequality
ts shape
{ gt: T } · { gte: T }compiled sql
field > $1 · field >= $1notenumbers, dates, BigInt
ts shape
{ lt: T } · { lte: T }compiled sql
field < $1 · field <= $1notenumbers, dates, BigInt
ts shape
{ in: T[] }compiled sql
field IN ($1, $2, …)noteeach value is its own bound parameter
ts shape
{ notIn: T[] }compiled sql
field NOT IN ($1, $2, …)noteempty array compiles to `FALSE`
ts shape
{ contains: string }compiled sql
field ILIKE $1noteparam bound as `'%X%'` · case-insensitive
ts shape
{ startsWith: string }compiled sql
field ILIKE $1noteparam bound as `'X%'`
ts shape
{ endsWith: string }compiled sql
field ILIKE $1noteparam bound as `'%X'`
ts shape
{ AND: WhereInput[] }compiled sql
(a AND b AND c)noteimplicit when multiple keys at the same level
ts shape
{ OR: WhereInput[] }compiled sql
(a OR b OR c)noteexplicit; nests with AND
ts shape
{ NOT: WhereInput }compiled sql
NOT (…)notenegates the entire subtree
ts shape
{ field: null } · { equals: null }compiled sql
field IS NULLnoteshorthand allowed for the bare value
ts shape
{ not: null }compiled sql
field IS NOT NULLnoteauto-promoted from `<> NULL`
Example · 01
Comparison
gt, gte, lt, lte, equals, not
db.user.findMany({ where: { createdAt: { gte: new Date('2026-01-01') }, role: { not: 'USER' }, },});SELECT id, email, name, role, created_atFROM usersWHERE created_at >= $1 AND role <> $2;Example · 02
String matching
contains, startsWith, endsWith — case-insensitive
db.user.findMany({ where: { email: { startsWith: 'admin' }, name: { contains: 'corp', mode: 'insensitive' }, },});SELECT id, email, name, role, created_atFROM usersWHERE email ILIKE $1 AND name ILIKE $2;-- $1 = 'admin%'-- $2 = '%corp%'Example · 03
AND / OR / NOT, IN list
explicit logic operators, parameterized lists
db.user.findMany({ where: { OR: [ { role: { in: ['ADMIN', 'EDITOR'] } }, { AND: [ { name: { not: null } }, { email: { contains: '@fybyte' } }, ] }, ], },});SELECT id, email, name, role, created_atFROM usersWHERE ( role IN ($1, $2) OR (name IS NOT NULL AND email ILIKE $3));Joins that compile to a single statement. No N+1.
include does what you want it to: produces one SQL query with the necessary LEFT JOINs. Filters on related models flatten into EXISTS subqueries. The compiler picks the cheaper plan; the engine doesn't roundtrip.
Example · 01
Include — eager fetch
single SQL with LEFT JOINs, no N+1
db.post.findMany({ where: { published: true }, include: { author: true, comments: true },});SELECT p.id, p.title, p.body, p.published, p.created_at, u.id AS author_id, u.email, u.name, u.role, c.id AS comment_id, c.body AS comment_bodyFROM posts pLEFT JOIN users u ON u.id = p.author_idLEFT JOIN comments c ON c.post_id = p.idWHERE p.published = $1;Example · 02
Filter through a relation
where on the joined model · automatic JOIN
db.post.findMany({ where: { author: { role: 'ADMIN' }, comments: { some: { body: { contains: 'spam' } } }, },});SELECT p.id, p.title, p.published, p.created_atFROM posts pINNER JOIN users u ON u.id = p.author_idWHERE u.role = $1 AND EXISTS ( SELECT 1 FROM comments c WHERE c.post_id = p.id AND c.body ILIKE $2 );Example · 03
Multi-hop · 3 tables
User → Post → Comment in one statement
db.user.findMany({ where: { role: 'EDITOR' }, include: { posts: { where: { published: true }, include: { comments: true }, }, },});SELECT u.id, u.email, u.name, u.role, p.id AS post_id, p.title, c.id AS comment_id, c.bodyFROM users uLEFT JOIN posts p ON p.author_id = u.id AND p.published = $1LEFT JOIN comments c ON c.post_id = p.idWHERE u.role = $2;Counting and ranking, first-class — not bolted on.
Aggregate calls compile to standard SQL: GROUP BY, HAVING, window functions in subqueries. No client-side aggregation. No ORM running a per-row callback for what should have been a single statement.
Example · 01
count · groupBy
posts per author
db.post.groupBy({ by: ['authorId'], _count: { _all: true }, having: { _count: { _all: { gt: 5 } } },});SELECT author_id, COUNT(*) AS count_allFROM postsGROUP BY author_idHAVING COUNT(*) > $1;Example · 02
sum · avg with where
engagement metrics on a slice
db.comment.aggregate({ where: { post: { published: true } }, _count: { _all: true }, _avg: { score: true }, _max: { createdAt: true },});SELECT COUNT(*) AS count_all, AVG(c.score) AS avg_score, MAX(c.created_at) AS max_created_atFROM comments cINNER JOIN posts p ON p.id = c.post_idWHERE p.published = $1;Example · 03
Window · ROW_NUMBER
top 3 posts per author
db.post.findMany({ select: { id: true, title: true, authorId: true, rank: rowNumber({ partitionBy: ['authorId'], orderBy: { createdAt: 'desc' }, }), }, where: { rank: { lte: 3 } },});SELECT id, title, author_id, rankFROM ( SELECT id, title, author_id, ROW_NUMBER() OVER ( PARTITION BY author_id ORDER BY created_at DESC ) AS rank FROM posts) tWHERE rank <= $1;Writes return what they wrote. Transactions roll back on throw.
Every write uses RETURNING so the inserted, upserted, or updated row comes back typed. db.transaction wraps a function in BEGIN/COMMIT; any throw inside it triggers ROLLBACK.
Example · 01
Single insert · RETURNING *
the inserted row comes back as a typed result
const user = await db.user.create({ data: { email: '[email protected]', name: 'Alice', role: 'ADMIN', },});INSERT INTO users (email, name, role)VALUES ($1, $2, $3)RETURNING id, email, name, role, created_at;Example · 02
Upsert · ON CONFLICT DO UPDATE
deduplicated by the unique field
await db.user.upsert({ where: { email: '[email protected]' }, create: { email: '[email protected]', name: 'Alice' }, update: { name: 'Alice Updated' },});INSERT INTO users (email, name)VALUES ($1, $2)ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.nameRETURNING id, email, name, role, created_at;Example · 03
Transaction · all or nothing
rolled back on any throw
await db.transaction(async (tx) => { const user = await tx.user.create({ data: { email: '[email protected]', name: 'Bob' }, }); await tx.post.create({ data: { title: 'First', body: '…', authorId: user.id }, }); // throws → both rows roll back});BEGIN; INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id, email, name, role, created_at; INSERT INTO posts (title, body, author_id) VALUES ($3, $4, $5) RETURNING id, title, body, published, author_id, created_at;COMMIT;-- on throw: ROLLBACK;When you need raw SQL, the values are still bound.
Sometimes the query is too database-specific to express through the model API. The db.raw`…` tagged template handles that — and refuses to interpolate values into the SQL string. Every ${x} becomes a bound parameter.
const someDate = new Date('2026-01-01');const minScore = 0.5; const result = await db.raw<{ count: number; avg_score: number;}>` SELECT COUNT(*) AS count, AVG(score) AS avg_score FROM comments WHERE created_at > ${someDate} AND score > ${minScore}`;SELECT COUNT(*) AS count, AVG(score) AS avg_scoreFROM commentsWHERE created_at > $1 AND score > $2; -- $1 = '2026-01-01T00:00:00.000Z'-- $2 = 0.5The safety contract · what raw cannot do
- · You cannot string-concatenate values into the SQL template. The tag function rejects non-template usage at the type level.
- · Identifiers (table names, column names) must come from a fixed allowlist — they cannot be interpolated as user input.
- · The result type is
unknownunless you specify a generic. There's no automatic schema-graph inference for raw queries.