Complete guide to defining table relations and using the relational query API in Drizzle ORM.
Drizzle ORM provides a powerful relational query API that allows you to:
relations() functiondb.query APIThe relational query API is an alternative to manually writing joins with the SQL-like query builder. It provides a more intuitive API for working with related data while maintaining type safety and performance.
Define relationships between tables using the relations() function.
function relations<
TTableName extends string,
TRelations extends Record<string, Relation<any>>
>(
table: AnyTable<{ name: TTableName }>,
relations: (helpers: TableRelationsHelpers<TTableName>) => TRelations
): Relations<TTableName, TRelations>Parameters:
table - The table to define relations forrelations - Callback function that receives helper functions and returns relation definitionsReturns: A Relations object containing the relation configuration
Relation Helpers:
one(table, config?) - Define a one-to-one or many-to-one relationmany(table, config?) - Define a one-to-many relationimport { pgTable, serial, text, integer, relations } from 'drizzle-orm/pg-core';
// Define tables
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content'),
authorId: integer('author_id').notNull(),
});
// Define relations
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));A one-to-one relation exists when one record in a table relates to exactly one record in another table.
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
});
export const profiles = pgTable('profiles', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().unique(),
bio: text('bio'),
avatarUrl: text('avatar_url'),
});
export const usersRelations = relations(users, ({ one }) => ({
profile: one(profiles, {
fields: [users.id],
references: [profiles.userId],
}),
}));
export const profilesRelations = relations(profiles, ({ one }) => ({
user: one(users, {
fields: [profiles.userId],
references: [users.id],
}),
}));// Find user with profile
const userWithProfile = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
profile: true,
},
});
// Result type: { id: number, name: string, profile: { id: number, userId: number, bio: string | null, avatarUrl: string | null } | null }A one-to-many relation exists when one record in a table can relate to multiple records in another table.
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content'),
authorId: integer('author_id').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));// Find user with all posts
const userWithPosts = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
posts: true,
},
});
// Result type: { id: number, name: string, posts: Array<{ id: number, title: string, content: string | null, authorId: number, createdAt: Date }> }A many-to-one relation is the inverse of one-to-many. Multiple records reference a single record.
// Using the same tables as above, query from the "many" side
const postWithAuthor = await db.query.posts.findFirst({
where: eq(posts.id, 1),
with: {
author: true,
},
});
// Result type: { id: number, title: string, content: string | null, authorId: number, createdAt: Date, author: { id: number, name: string } }A many-to-many relation exists when multiple records in one table can relate to multiple records in another table. This requires a join table.
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull(),
});
export const groups = pgTable('groups', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
description: text('description'),
});
// Join table
export const usersToGroups = pgTable('users_to_groups', {
userId: integer('user_id').notNull().references(() => users.id),
groupId: integer('group_id').notNull().references(() => groups.id),
}, (t) => ({
pk: primaryKey({ columns: [t.userId, t.groupId] }),
}));
// Define relations
export const usersRelations = relations(users, ({ many }) => ({
usersToGroups: many(usersToGroups),
}));
export const groupsRelations = relations(groups, ({ many }) => ({
usersToGroups: many(usersToGroups),
}));
export const usersToGroupsRelations = relations(usersToGroups, ({ one }) => ({
user: one(users, {
fields: [usersToGroups.userId],
references: [users.id],
}),
group: one(groups, {
fields: [usersToGroups.groupId],
references: [groups.id],
}),
}));// Find user with all groups
const userWithGroups = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
usersToGroups: {
with: {
group: true,
},
},
},
});
// Result type: {
// id: number,
// name: string,
// email: string,
// usersToGroups: Array<{
// userId: number,
// groupId: number,
// group: { id: number, name: string, description: string | null }
// }>
// }
// Transform to simpler structure
const userGroups = userWithGroups.usersToGroups.map(utg => utg.group);For more convenient querying, you can define direct many-to-many relations:
export const usersRelations = relations(users, ({ many }) => ({
usersToGroups: many(usersToGroups),
groups: many(groups),
}));
export const groupsRelations = relations(groups, ({ many }) => ({
usersToGroups: many(usersToGroups),
users: many(users),
}));
// Simpler query (note: this is a convenience pattern, the join table is still used internally)
const userWithGroups = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
groups: true,
},
});Define a one-to-one or many-to-one relation.
function one<TForeignTable extends Table>(
table: TForeignTable,
config?: RelationConfig
): One<TForeignTable['_']['name']>Parameters:
table - The referenced tableconfig (optional) - Relation configuration
fields - Array of columns in the source tablereferences - Array of columns in the referenced tablerelationName (optional) - Custom name for the relationReturns: A One relation object
When defining a one() relation, you should typically provide explicit configuration:
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));If the reverse relation is defined with explicit configuration, you can omit the config:
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
// Config can be omitted because usersRelations.posts is defined
author: one(users),
}));The one() helper automatically infers nullability based on the foreign key columns:
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
// Nullable foreign key
categoryId: integer('category_id'),
});
export const postsRelations = relations(posts, ({ one }) => ({
// This relation will have type: Category | null
category: one(categories, {
fields: [posts.categoryId],
references: [categories.id],
}),
}));Define a one-to-many relation.
function many<TForeignTable extends Table>(
table: TForeignTable,
config?: { relationName?: string }
): Many<TForeignTable['_']['name']>Parameters:
table - The referenced tableconfig (optional)
relationName (optional) - Custom name for disambiguating multiple relationsReturns: A Many relation object
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));The many() helper doesn't require fields/references configuration because it's defined on the "one" side of the relation.
When you have multiple relations to the same table, use relationName:
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
authorId: integer('author_id').notNull(),
reviewerId: integer('reviewer_id'),
});
export const usersRelations = relations(users, ({ many }) => ({
authoredPosts: many(posts, { relationName: 'author' }),
reviewedPosts: many(posts, { relationName: 'reviewer' }),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
relationName: 'author',
}),
reviewer: one(users, {
fields: [posts.reviewerId],
references: [users.id],
relationName: 'reviewer',
}),
}));To use the relational query API, you must provide your schema to the database configuration.
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const db = drizzle(pool, {
schema, // Pass entire schema object
});
// Now db.query is available with type-safe access to all tablesinterface DrizzleConfig {
schema?: Record<string, unknown>;
logger?: Logger | boolean;
casing?: 'snake_case' | 'camelCase';
cache?: Cache;
}Properties:
schema - Object containing all table and relation definitionslogger - Logger instance or boolean to enable/disable loggingcasing - Automatic column name case conversioncache - Cache instance for query result caching// schema.ts
import { pgTable, serial, text, integer, relations } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
authorId: integer('author_id').notNull(),
});
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
// db.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool, { schema });Once configured, the relational query API is accessible via db.query.<tableName>.
Find multiple records with optional relations and filters.
db.query.tableName.findMany(config?: DBQueryConfig): Promise<Result[]>Parameters:
config (optional) - Query configuration objectReturns: Promise resolving to array of results
// Find all users
const allUsers = await db.query.users.findMany();
// Find all users with their posts
const usersWithPosts = await db.query.users.findMany({
with: {
posts: true,
},
});import { eq, gt } from 'drizzle-orm';
// Find users with ID greater than 5
const users = await db.query.users.findMany({
where: gt(users.id, 5),
});
// Using callback for filters
const activeUsers = await db.query.users.findMany({
where: (users, { eq }) => eq(users.status, 'active'),
});// Get 10 users, skip first 20
const users = await db.query.users.findMany({
limit: 10,
offset: 20,
});import { desc, asc } from 'drizzle-orm';
// Sort by creation date descending
const users = await db.query.users.findMany({
orderBy: desc(users.createdAt),
});
// Multiple sort columns
const users = await db.query.users.findMany({
orderBy: [desc(users.createdAt), asc(users.name)],
});
// Using callback
const users = await db.query.users.findMany({
orderBy: (users, { desc }) => desc(users.createdAt),
});Find the first record matching the query.
db.query.tableName.findFirst(config?: DBQueryConfig): Promise<Result | undefined>Parameters:
config (optional) - Query configuration objectReturns: Promise resolving to first result or undefined if no match
// Find first user
const firstUser = await db.query.users.findFirst();
// Find user by ID
const user = await db.query.users.findFirst({
where: eq(users.id, 1),
});
// Find user with posts
const userWithPosts = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
posts: true,
},
});// Find user with recent posts
const user = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
posts: {
where: gt(posts.createdAt, new Date('2024-01-01')),
orderBy: desc(posts.createdAt),
limit: 5,
},
},
});Configuration object for relational queries.
interface DBQueryConfig<TRelationType extends 'one' | 'many' = 'one' | 'many'> {
columns?: { [columnName: string]: boolean };
with?: { [relationName: string]: true | DBQueryConfig };
extras?: Record<string, SQL.Aliased> | ((fields, operators) => Record<string, SQL.Aliased>);
where?: SQL | ((fields, operators) => SQL | undefined);
orderBy?: ValueOrArray<AnyColumn | SQL> | ((fields, operators) => ValueOrArray<AnyColumn | SQL>);
limit?: number | Placeholder;
offset?: number | Placeholder; // Only for root queries
}Select or exclude specific columns.
// Only select id and name
const users = await db.query.users.findMany({
columns: {
id: true,
name: true,
},
});
// Result type: Array<{ id: number, name: string }>// Select all columns except password
const users = await db.query.users.findMany({
columns: {
password: false,
},
});
// Result type: Array<{ id: number, name: string, email: string, ... }> (all except password)// Select specific columns from both user and posts
const users = await db.query.users.findMany({
columns: {
id: true,
name: true,
},
with: {
posts: {
columns: {
id: true,
title: true,
},
},
},
});
// Result type: Array<{
// id: number,
// name: string,
// posts: Array<{ id: number, title: string }>
// }>Load related data (eager loading).
// Load single relation
const users = await db.query.users.findMany({
with: {
profile: true,
},
});
// Load multiple relations
const users = await db.query.users.findMany({
with: {
profile: true,
posts: true,
comments: true,
},
});// Load nested relations
const users = await db.query.users.findMany({
with: {
posts: {
with: {
comments: {
with: {
author: true,
},
},
},
},
},
});
// Result includes: users -> posts -> comments -> author// Load only recent posts
const users = await db.query.users.findMany({
with: {
posts: {
where: gt(posts.createdAt, new Date('2024-01-01')),
orderBy: desc(posts.createdAt),
limit: 5,
},
},
});Filter results using conditions.
import { eq, gt, and, or, like } from 'drizzle-orm';
// Single condition
const users = await db.query.users.findMany({
where: eq(users.status, 'active'),
});
// Multiple conditions with AND
const users = await db.query.users.findMany({
where: and(
eq(users.status, 'active'),
gt(users.age, 18)
),
});
// OR conditions
const users = await db.query.users.findMany({
where: or(
eq(users.role, 'admin'),
eq(users.role, 'moderator')
),
});
// Pattern matching
const users = await db.query.users.findMany({
where: like(users.email, '%@example.com'),
});// Using callback for cleaner syntax
const users = await db.query.users.findMany({
where: (users, { eq, and, gt }) => and(
eq(users.status, 'active'),
gt(users.age, 18)
),
});The operators object includes:
{
eq, ne, gt, gte, lt, lte, // Comparison
and, or, not, // Logical
isNull, isNotNull, // Null checks
inArray, notInArray, // Array membership
like, notLike, ilike, notIlike, // Pattern matching
between, notBetween, // Range
exists, notExists, // Subqueries
sql // Raw SQL
}Sort query results.
import { asc, desc } from 'drizzle-orm';
// Single column ascending
const users = await db.query.users.findMany({
orderBy: asc(users.name),
});
// Single column descending
const users = await db.query.users.findMany({
orderBy: desc(users.createdAt),
});
// Multiple columns
const users = await db.query.users.findMany({
orderBy: [desc(users.createdAt), asc(users.name)],
});// Using callback
const users = await db.query.users.findMany({
orderBy: (users, { desc }) => desc(users.createdAt),
});
// Multiple columns with callback
const users = await db.query.users.findMany({
orderBy: (users, { desc, asc }) => [desc(users.createdAt), asc(users.name)],
});// Order main query and relations
const users = await db.query.users.findMany({
orderBy: desc(users.name),
with: {
posts: {
orderBy: desc(posts.createdAt),
},
},
});Paginate query results.
// First page (10 items)
const firstPage = await db.query.users.findMany({
limit: 10,
});
// Second page
const secondPage = await db.query.users.findMany({
limit: 10,
offset: 10,
});
// Third page
const thirdPage = await db.query.users.findMany({
limit: 10,
offset: 20,
});function paginate<T>(
query: (limit: number, offset: number) => Promise<T[]>,
page: number,
pageSize: number
): Promise<T[]> {
return query(pageSize, (page - 1) * pageSize);
}
// Usage
const page1 = await paginate(
(limit, offset) => db.query.users.findMany({ limit, offset }),
1,
10
);// Limit main query and relations separately
const users = await db.query.users.findMany({
limit: 10,
offset: 0,
with: {
posts: {
limit: 5, // Only 5 posts per user
},
},
});import { placeholder } from 'drizzle-orm';
// Prepared statement with dynamic pagination
const stmt = db.query.users.findMany({
limit: placeholder('limit'),
offset: placeholder('offset'),
}).prepare();
// Execute with different values
const page1 = await stmt.execute({ limit: 10, offset: 0 });
const page2 = await stmt.execute({ limit: 10, offset: 10 });Add computed fields to results.
import { sql } from 'drizzle-orm';
const users = await db.query.users.findMany({
extras: {
lowercaseName: sql<string>`lower(${users.name})`.as('lowercase_name'),
fullName: sql<string>`${users.firstName} || ' ' || ${users.lastName}`.as('full_name'),
},
});
// Result type: Array<{
// id: number,
// name: string,
// lowercaseName: string,
// fullName: string
// }>const users = await db.query.users.findMany({
extras: (fields, { sql }) => ({
lowercaseName: sql<string>`lower(${fields.name})`.as('lowercase_name'),
}),
});const posts = await db.query.posts.findMany({
extras: {
// Calculate reading time
readingTime: sql<number>`
CEIL(LENGTH(${posts.content}) / 200.0)
`.as('reading_time'),
// Format date
formattedDate: sql<string>`
TO_CHAR(${posts.createdAt}, 'YYYY-MM-DD')
`.as('formatted_date'),
},
});The relational query API supports loading relations to arbitrary depth.
// Complex multi-level query
const users = await db.query.users.findMany({
with: {
posts: {
with: {
comments: {
with: {
author: {
with: {
profile: true,
},
},
},
},
categories: {
with: {
parent: true,
},
},
},
},
profile: true,
followers: {
with: {
follower: {
columns: {
id: true,
name: true,
},
},
},
},
},
});
// Result structure:
// users
// -> posts[]
// -> comments[]
// -> author
// -> profile
// -> categories[]
// -> parent
// -> profile
// -> followers[]
// -> followerCircular relations are handled correctly:
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
managerId: integer('manager_id'),
});
export const usersRelations = relations(users, ({ one, many }) => ({
manager: one(users, {
fields: [users.managerId],
references: [users.id],
relationName: 'manager',
}),
subordinates: many(users, { relationName: 'manager' }),
}));
// Query organizational hierarchy
const manager = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
subordinates: {
with: {
subordinates: {
with: {
subordinates: true, // 3 levels deep
},
},
},
},
},
});Each level can have its own filters:
const users = await db.query.users.findMany({
where: eq(users.role, 'author'),
with: {
posts: {
where: eq(posts.published, true),
orderBy: desc(posts.createdAt),
limit: 5,
with: {
comments: {
where: eq(comments.status, 'approved'),
orderBy: desc(comments.createdAt),
with: {
author: {
columns: {
id: true,
name: true,
},
},
},
},
},
},
},
});Control which columns are returned at each level:
const users = await db.query.users.findMany({
columns: {
id: true,
name: true,
email: true,
},
with: {
posts: {
columns: {
id: true,
title: true,
createdAt: true,
},
with: {
comments: {
columns: {
id: true,
content: true,
},
with: {
author: {
columns: {
id: true,
name: true,
},
},
},
},
},
},
},
});import { and, or, eq, gt, lt, like, isNull } from 'drizzle-orm';
// Complex AND/OR conditions
const users = await db.query.users.findMany({
where: and(
eq(users.status, 'active'),
or(
eq(users.role, 'admin'),
eq(users.role, 'moderator')
),
gt(users.createdAt, new Date('2024-01-01'))
),
});
// Null checks
const users = await db.query.users.findMany({
where: and(
eq(users.status, 'active'),
isNull(users.deletedAt)
),
});import { inArray, notInArray } from 'drizzle-orm';
// Users with specific IDs
const users = await db.query.users.findMany({
where: inArray(users.id, [1, 2, 3, 4, 5]),
});
// Users excluding certain roles
const users = await db.query.users.findMany({
where: notInArray(users.role, ['banned', 'suspended']),
});import { between, gte, lte } from 'drizzle-orm';
// Using between
const users = await db.query.users.findMany({
where: between(users.age, 18, 65),
});
// Using gte and lte
const users = await db.query.users.findMany({
where: and(
gte(users.createdAt, new Date('2024-01-01')),
lte(users.createdAt, new Date('2024-12-31'))
),
});import { like, ilike, notLike } from 'drizzle-orm';
// Case-sensitive pattern
const users = await db.query.users.findMany({
where: like(users.email, '%@example.com'),
});
// Case-insensitive pattern (PostgreSQL)
const users = await db.query.users.findMany({
where: ilike(users.name, 'john%'),
});
// Exclude pattern
const users = await db.query.users.findMany({
where: notLike(users.email, '%@spam.com'),
});import { exists, notExists } from 'drizzle-orm';
// Users who have posts
const usersWithPosts = await db.query.users.findMany({
where: exists(
db.select().from(posts).where(eq(posts.authorId, users.id))
),
});
// Users without posts
const usersWithoutPosts = await db.query.users.findMany({
where: notExists(
db.select().from(posts).where(eq(posts.authorId, users.id))
),
});import { sql } from 'drizzle-orm';
// Sort with CASE statement
const posts = await db.query.posts.findMany({
orderBy: sql`
CASE
WHEN ${posts.featured} = true THEN 1
ELSE 2
END,
${posts.createdAt} DESC
`,
});// Sort users by post count
const users = await db.query.users.findMany({
extras: {
postCount: sql<number>`(
SELECT COUNT(*)
FROM ${posts}
WHERE ${posts.authorId} = ${users.id}
)`.as('post_count'),
},
orderBy: (fields) => desc(fields.postCount),
});// Nulls first
const users = await db.query.users.findMany({
orderBy: sql`${users.lastLogin} DESC NULLS FIRST`,
});
// Nulls last
const users = await db.query.users.findMany({
orderBy: sql`${users.lastLogin} DESC NULLS LAST`,
});// Minimal user data
const users = await db.query.users.findMany({
columns: {
id: true,
name: true,
email: true,
},
});// All columns except password and secret
const users = await db.query.users.findMany({
columns: {
password: false,
secretToken: false,
},
});const users = await db.query.users.findMany({
columns: {
id: true,
name: true,
},
with: {
posts: {
columns: {
id: true,
title: true,
createdAt: true,
},
with: {
comments: {
columns: {
id: true,
content: true,
},
with: {
author: {
columns: {
id: true,
name: true,
},
},
},
},
},
},
},
});const users = await db.query.users.findMany({
columns: {
id: true,
firstName: true,
lastName: true,
},
extras: {
fullName: sql<string>`${users.firstName} || ' ' || ${users.lastName}`.as('full_name'),
},
});
// Result: { id: number, firstName: string, lastName: string, fullName: string }The extras option allows adding computed fields to query results.
const users = await db.query.users.findMany({
extras: {
// Concatenation
displayName: sql<string>`
COALESCE(${users.nickname}, ${users.firstName})
`.as('display_name'),
// Uppercase
upperName: sql<string>`UPPER(${users.name})`.as('upper_name'),
// Substring
initials: sql<string>`
SUBSTRING(${users.firstName}, 1, 1) || SUBSTRING(${users.lastName}, 1, 1)
`.as('initials'),
},
});const products = await db.query.products.findMany({
extras: {
// Price with tax
priceWithTax: sql<number>`${products.price} * 1.2`.as('price_with_tax'),
// Discount percentage
discountPercent: sql<number>`
CASE
WHEN ${products.originalPrice} > 0
THEN ((${products.originalPrice} - ${products.price}) / ${products.originalPrice}) * 100
ELSE 0
END
`.as('discount_percent'),
// Rounded value
roundedPrice: sql<number>`ROUND(${products.price}, 2)`.as('rounded_price'),
},
});const posts = await db.query.posts.findMany({
extras: {
// Days since creation
ageInDays: sql<number>`
EXTRACT(DAY FROM NOW() - ${posts.createdAt})
`.as('age_in_days'),
// Formatted date
formattedDate: sql<string>`
TO_CHAR(${posts.createdAt}, 'YYYY-MM-DD HH24:MI')
`.as('formatted_date'),
// Is recent
isRecent: sql<boolean>`
${posts.createdAt} > NOW() - INTERVAL '7 days'
`.as('is_recent'),
},
});const users = await db.query.users.findMany({
extras: {
// Subquery aggregation
postCount: sql<number>`(
SELECT COUNT(*)
FROM ${posts}
WHERE ${posts.authorId} = ${users.id}
)`.as('post_count'),
// Latest post date
latestPostDate: sql<Date>`(
SELECT MAX(${posts.createdAt})
FROM ${posts}
WHERE ${posts.authorId} = ${users.id}
)`.as('latest_post_date'),
},
});const users = await db.query.users.findMany({
extras: {
// Extract JSON field
city: sql<string>`${users.metadata}->>'city'`.as('city'),
// JSON array length
tagsCount: sql<number>`jsonb_array_length(${users.tags})`.as('tags_count'),
// Check JSON key existence
hasAvatar: sql<boolean>`${users.metadata} ? 'avatar'`.as('has_avatar'),
},
});const users = await db.query.users.findMany({
extras: (fields, { sql }) => ({
// Reference fields from the callback parameter
fullName: sql<string>`${fields.firstName} || ' ' || ${fields.lastName}`.as('full_name'),
// Complex expression
displayInfo: sql<string>`
${fields.name} || ' (' || ${fields.email} || ')'
`.as('display_info'),
}),
});Drizzle's relational query API provides full TypeScript type inference.
// The result type is automatically inferred
const user = await db.query.users.findFirst({
with: {
posts: true,
},
});
// Type: {
// id: number;
// name: string;
// email: string;
// posts: Array<{
// id: number;
// title: string;
// content: string | null;
// authorId: number;
// }>;
// } | undefined// Selecting specific columns
const user = await db.query.users.findFirst({
columns: {
id: true,
name: true,
},
});
// Type: {
// id: number;
// name: string;
// } | undefinedconst user = await db.query.users.findFirst({
columns: {
id: true,
name: true,
},
with: {
posts: {
columns: {
id: true,
title: true,
},
with: {
comments: {
columns: {
content: true,
},
},
},
},
},
});
// Type: {
// id: number;
// name: string;
// posts: Array<{
// id: number;
// title: string;
// comments: Array<{
// content: string;
// }>;
// }>;
// } | undefinedconst users = await db.query.users.findMany({
extras: {
fullName: sql<string>`${users.firstName} || ' ' || ${users.lastName}`.as('full_name'),
postCount: sql<number>`(SELECT COUNT(*) FROM ${posts} WHERE ${posts.authorId} = ${users.id})`.as('post_count'),
},
});
// Type: Array<{
// id: number;
// firstName: string;
// lastName: string;
// email: string;
// fullName: string; // Inferred from sql<string>
// postCount: number; // Inferred from sql<number>
// }>export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
categoryId: integer('category_id'), // Nullable
});
export const postsRelations = relations(posts, ({ one }) => ({
category: one(categories, {
fields: [posts.categoryId],
references: [categories.id],
}),
}));
const post = await db.query.posts.findFirst({
with: {
category: true,
},
});
// Type: {
// id: number;
// title: string;
// categoryId: number | null;
// category: {
// id: number;
// name: string;
// } | null; // Automatically inferred as nullable
// } | undefined// Extract query result types
type UserWithPosts = NonNullable<
Awaited<ReturnType<typeof getUserWithPosts>>
>;
async function getUserWithPosts(userId: number) {
return db.query.users.findFirst({
where: eq(users.id, userId),
with: {
posts: {
with: {
comments: true,
},
},
},
});
}
// Use the extracted type
function processUser(user: UserWithPosts) {
console.log(user.name);
user.posts.forEach(post => {
console.log(post.title);
post.comments.forEach(comment => {
console.log(comment.content);
});
});
}For more complex scenarios, use the BuildQueryResult type:
import { type BuildQueryResult, type DBQueryConfig } from 'drizzle-orm';
import type * as schema from './schema';
type RelationalQueryConfig = DBQueryConfig<
'many',
true,
typeof schema.usersRelations,
typeof schema.users
>;
type UserWithRelations = BuildQueryResult<
typeof schema,
typeof schema.users,
{
columns: {
id: true;
name: true;
};
with: {
posts: {
columns: {
id: true;
title: true;
};
};
};
}
>;The relational query API is an alternative to the SQL-like query builder. Here's a comparison:
const usersWithPosts = await db.query.users.findMany({
with: {
posts: true,
},
});
// Result: Array<{
// id: number;
// name: string;
// posts: Array<{
// id: number;
// title: string;
// authorId: number;
// }>;
// }>import { eq } from 'drizzle-orm';
const usersResult = await db.select().from(users);
const usersWithPosts = await Promise.all(
usersResult.map(async (user) => ({
...user,
posts: await db.select()
.from(posts)
.where(eq(posts.authorId, user.id)),
}))
);
// Same result structure, but requires manual joinsOr using a single query with aggregation:
import { eq } from 'drizzle-orm';
import { sql } from 'drizzle-orm';
const result = await db
.select({
user: users,
posts: sql<string>`json_agg(${posts})`,
})
.from(users)
.leftJoin(posts, eq(users.id, posts.authorId))
.groupBy(users.id);
// Requires manual JSON parsing and type assertionsconst postsWithAuthor = await db.query.posts.findMany({
with: {
author: true,
},
});
// Result: Array<{
// id: number;
// title: string;
// authorId: number;
// author: {
// id: number;
// name: string;
// };
// }>import { eq } from 'drizzle-orm';
const postsWithAuthor = await db
.select()
.from(posts)
.leftJoin(users, eq(posts.authorId, users.id));
// Result: Array<{
// posts: { id: number; title: string; authorId: number };
// users: { id: number; name: string } | null;
// }>
// Different structure, requires flatteningconst users = await db.query.users.findMany({
with: {
posts: {
with: {
comments: {
with: {
author: true,
},
},
},
},
},
});
// Single query, nested structure// Requires multiple queries and manual assembly
const usersResult = await db.select().from(users);
const usersWithRelations = await Promise.all(
usersResult.map(async (user) => {
const userPosts = await db.select()
.from(posts)
.where(eq(posts.authorId, user.id));
const postsWithComments = await Promise.all(
userPosts.map(async (post) => {
const postComments = await db.select()
.from(comments)
.where(eq(comments.postId, post.id));
const commentsWithAuthors = await Promise.all(
postComments.map(async (comment) => ({
...comment,
author: await db.select()
.from(users)
.where(eq(users.id, comment.authorId))
.then(r => r[0]),
}))
);
return {
...post,
comments: commentsWithAuthors,
};
})
);
return {
...user,
posts: postsWithComments,
};
})
);
// Much more complex, many queriesconst users = await db.query.users.findMany({
with: {
posts: {
where: eq(posts.published, true),
orderBy: desc(posts.createdAt),
limit: 5,
},
},
});
// Clean, declarative syntax// Requires subquery or multiple queries
const usersResult = await db.select().from(users);
const usersWithFilteredPosts = await Promise.all(
usersResult.map(async (user) => ({
...user,
posts: await db.select()
.from(posts)
.where(and(
eq(posts.authorId, user.id),
eq(posts.published, true)
))
.orderBy(desc(posts.createdAt))
.limit(5),
}))
);
// Multiple queries, manual assemblyUse Relational Query API when:
Use Query Builder when:
You can mix both approaches in the same application:
// Use relational API for typical data fetching
const userWithPosts = await db.query.users.findFirst({
where: eq(users.id, userId),
with: {
posts: true,
},
});
// Use query builder for complex analytics
const postStats = await db
.select({
userId: posts.authorId,
totalPosts: count(posts.id),
avgLength: avg(sql`LENGTH(${posts.content})`),
latestPost: max(posts.createdAt),
})
.from(posts)
.groupBy(posts.authorId)
.having(gt(count(posts.id), 5));// Schema
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
bio: text('bio'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
content: text('content'),
published: boolean('published').default(false).notNull(),
authorId: integer('author_id').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const comments = pgTable('comments', {
id: serial('id').primaryKey(),
content: text('content').notNull(),
postId: integer('post_id').notNull(),
authorId: integer('author_id').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const tags = pgTable('tags', {
id: serial('id').primaryKey(),
name: text('name').notNull().unique(),
});
export const postsToTags = pgTable('posts_to_tags', {
postId: integer('post_id').notNull().references(() => posts.id),
tagId: integer('tag_id').notNull().references(() => tags.id),
}, (t) => ({
pk: primaryKey({ columns: [t.postId, t.tagId] }),
}));
// Relations
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
comments: many(comments),
}));
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
comments: many(comments),
postsToTags: many(postsToTags),
}));
export const commentsRelations = relations(comments, ({ one }) => ({
post: one(posts, {
fields: [comments.postId],
references: [posts.id],
}),
author: one(users, {
fields: [comments.authorId],
references: [users.id],
}),
}));
export const tagsRelations = relations(tags, ({ many }) => ({
postsToTags: many(postsToTags),
}));
export const postsToTagsRelations = relations(postsToTags, ({ one }) => ({
post: one(posts, {
fields: [postsToTags.postId],
references: [posts.id],
}),
tag: one(tags, {
fields: [postsToTags.tagId],
references: [tags.id],
}),
}));
// Queries
// Get blog post with all related data
async function getBlogPost(slug: string) {
return db.query.posts.findFirst({
where: and(
eq(posts.slug, slug),
eq(posts.published, true)
),
with: {
author: {
columns: {
id: true,
name: true,
bio: true,
},
},
comments: {
orderBy: desc(comments.createdAt),
with: {
author: {
columns: {
id: true,
name: true,
},
},
},
},
postsToTags: {
with: {
tag: true,
},
},
},
extras: {
readingTime: sql<number>`CEIL(LENGTH(${posts.content}) / 200.0)`.as('reading_time'),
},
});
}
// Get author's published posts
async function getAuthorPosts(authorId: number) {
return db.query.users.findFirst({
where: eq(users.id, authorId),
with: {
posts: {
where: eq(posts.published, true),
orderBy: desc(posts.createdAt),
columns: {
id: true,
title: true,
slug: true,
createdAt: true,
},
extras: {
commentCount: sql<number>`(
SELECT COUNT(*)
FROM ${comments}
WHERE ${comments.postId} = ${posts.id}
)`.as('comment_count'),
},
},
},
});
}
// Get recent posts with pagination
async function getRecentPosts(page: number, pageSize: number) {
return db.query.posts.findMany({
where: eq(posts.published, true),
orderBy: desc(posts.createdAt),
limit: pageSize,
offset: (page - 1) * pageSize,
with: {
author: {
columns: {
id: true,
name: true,
},
},
postsToTags: {
with: {
tag: true,
},
},
},
extras: {
excerpt: sql<string>`SUBSTRING(${posts.content}, 1, 200)`.as('excerpt'),
},
});
}
// Get posts by tag
async function getPostsByTag(tagName: string) {
const tag = await db.query.tags.findFirst({
where: eq(tags.name, tagName),
with: {
postsToTags: {
with: {
post: {
where: eq(posts.published, true),
with: {
author: {
columns: {
id: true,
name: true,
},
},
},
},
},
},
},
});
return tag?.postsToTags.map(ptt => ptt.post) ?? [];
}// Schema
export const customers = pgTable('customers', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const orders = pgTable('orders', {
id: serial('id').primaryKey(),
customerId: integer('customer_id').notNull(),
status: text('status').notNull(),
total: numeric('total', { precision: 10, scale: 2 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const orderItems = pgTable('order_items', {
id: serial('id').primaryKey(),
orderId: integer('order_id').notNull(),
productId: integer('product_id').notNull(),
quantity: integer('quantity').notNull(),
price: numeric('price', { precision: 10, scale: 2 }).notNull(),
});
export const products = pgTable('products', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
description: text('description'),
price: numeric('price', { precision: 10, scale: 2 }).notNull(),
stock: integer('stock').notNull(),
});
// Relations
export const customersRelations = relations(customers, ({ many }) => ({
orders: many(orders),
}));
export const ordersRelations = relations(orders, ({ one, many }) => ({
customer: one(customers, {
fields: [orders.customerId],
references: [customers.id],
}),
items: many(orderItems),
}));
export const orderItemsRelations = relations(orderItems, ({ one }) => ({
order: one(orders, {
fields: [orderItems.orderId],
references: [orders.id],
}),
product: one(products, {
fields: [orderItems.productId],
references: [products.id],
}),
}));
export const productsRelations = relations(products, ({ many }) => ({
orderItems: many(orderItems),
}));
// Queries
// Get order with all details
async function getOrderDetails(orderId: number) {
return db.query.orders.findFirst({
where: eq(orders.id, orderId),
with: {
customer: {
columns: {
id: true,
name: true,
email: true,
},
},
items: {
with: {
product: true,
},
},
},
});
}
// Get customer order history
async function getCustomerOrders(customerId: number) {
return db.query.customers.findFirst({
where: eq(customers.id, customerId),
with: {
orders: {
orderBy: desc(orders.createdAt),
with: {
items: {
with: {
product: {
columns: {
id: true,
name: true,
},
},
},
},
},
},
},
});
}
// Get popular products
async function getPopularProducts(limit: number) {
return db.query.products.findMany({
limit,
extras: {
totalSold: sql<number>`(
SELECT COALESCE(SUM(${orderItems.quantity}), 0)
FROM ${orderItems}
WHERE ${orderItems.productId} = ${products.id}
)`.as('total_sold'),
},
orderBy: (fields) => desc(fields.totalSold),
});
}Define relations on both sides for better type inference and flexibility:
// Good
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));When multiple relations exist between tables, always use relationName:
export const usersRelations = relations(users, ({ many }) => ({
authoredPosts: many(posts, { relationName: 'author' }),
reviewedPosts: many(posts, { relationName: 'reviewer' }),
}));Avoid loading unnecessary data by selecting specific columns:
// Good - only load needed data
const users = await db.query.users.findMany({
columns: {
id: true,
name: true,
},
with: {
posts: {
columns: {
id: true,
title: true,
},
},
},
});Filter relations to reduce data transfer:
const users = await db.query.users.findMany({
with: {
posts: {
where: eq(posts.published, true),
limit: 10,
},
},
});Extract reusable types from queries:
type UserWithPosts = Awaited<ReturnType<typeof getUserWithPosts>>;
async function getUserWithPosts(id: number) {
return db.query.users.findFirst({
where: eq(users.id, id),
with: { posts: true },
});
}For queries executed multiple times:
const getUserById = db.query.users.findFirst({
where: eq(users.id, placeholder('id')),
with: { posts: true },
}).prepare();
// Execute multiple times
const user1 = await getUserById.execute({ id: 1 });
const user2 = await getUserById.execute({ id: 2 });Use the SQL query builder for complex aggregations:
// Use query builder for analytics
const stats = await db
.select({
userId: posts.authorId,
postCount: count(),
avgLength: avg(sql`LENGTH(${posts.content})`),
})
.from(posts)
.groupBy(posts.authorId);The Drizzle ORM relational query API provides:
relations() and helper functionsdb.query.tableName.findMany/findFirstextras optionThe relational API is ideal for typical CRUD operations and loading related data, while the SQL query builder provides more control for complex queries. Both can be used together in the same application.