or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

examples

edge-cases.mdreal-world-scenarios.md
index.md
tile.json

relations.mddocs/reference/

Drizzle ORM - Relational Query API

Complete guide to defining table relations and using the relational query API in Drizzle ORM.

Overview

Drizzle ORM provides a powerful relational query API that allows you to:

  • Define relationships between tables using the relations() function
  • Load related data with automatic joins using db.query API
  • Nest related data to arbitrary depth
  • Filter, sort, and paginate both primary and related data
  • Maintain full TypeScript type safety throughout

The 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.

Table of Contents

  1. Defining Relations
  2. Relation Types
  3. Configuring the Database
  4. Relational Query API
  5. Query Configuration Options
  6. Nested Relations
  7. Filtering and Sorting
  8. Selecting Columns
  9. Computed Fields
  10. Type Inference
  11. Comparison with Query Builder

Defining Relations

The relations() Function { .api }

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 for
  • relations - Callback function that receives helper functions and returns relation definitions

Returns: A Relations object containing the relation configuration

Relation Helpers:

  • one(table, config?) - Define a one-to-one or many-to-one relation
  • many(table, config?) - Define a one-to-many relation

Basic Example

import { 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],
  }),
}));

Relation Types

One-to-One Relations

A one-to-one relation exists when one record in a table relates to exactly one record in another table.

Example: User Profile

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],
  }),
}));

Query Example

// 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 }

One-to-Many Relations

A one-to-many relation exists when one record in a table can relate to multiple records in another table.

Example: User Posts

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],
  }),
}));

Query Example

// 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 }> }

Many-to-One Relations

A many-to-one relation is the inverse of one-to-many. Multiple records reference a single record.

Example: Posts to Author

// 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 } }

Many-to-Many Relations

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.

Example: Users and Groups

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],
  }),
}));

Query Example

// 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);

Alternative: Bidirectional Many-to-Many

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,
  },
});

Relation Helpers

one() Helper { .api }

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 table
  • config (optional) - Relation configuration
    • fields - Array of columns in the source table
    • references - Array of columns in the referenced table
    • relationName (optional) - Custom name for the relation

Returns: A One relation object

Explicit Configuration

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],
  }),
}));

Implicit Configuration

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),
}));

Nullable Relations

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],
  }),
}));

many() Helper { .api }

Define a one-to-many relation.

function many<TForeignTable extends Table>(
  table: TForeignTable,
  config?: { relationName?: string }
): Many<TForeignTable['_']['name']>

Parameters:

  • table - The referenced table
  • config (optional)
    • relationName (optional) - Custom name for disambiguating multiple relations

Returns: A Many relation object

Basic Usage

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.

Multiple Relations to Same Table

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',
  }),
}));

Configuring the Database

To use the relational query API, you must provide your schema to the database configuration.

Schema 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 tables

DrizzleConfig Interface { .api }

interface DrizzleConfig {
  schema?: Record<string, unknown>;
  logger?: Logger | boolean;
  casing?: 'snake_case' | 'camelCase';
  cache?: Cache;
}

Properties:

  • schema - Object containing all table and relation definitions
  • logger - Logger instance or boolean to enable/disable logging
  • casing - Automatic column name case conversion
  • cache - Cache instance for query result caching

Complete Example

// 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 });

Relational Query API

Once configured, the relational query API is accessible via db.query.<tableName>.

findMany() Method { .api }

Find multiple records with optional relations and filters.

db.query.tableName.findMany(config?: DBQueryConfig): Promise<Result[]>

Parameters:

  • config (optional) - Query configuration object

Returns: Promise resolving to array of results

Basic Usage

// 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,
  },
});

With Filters

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'),
});

With Pagination

// Get 10 users, skip first 20
const users = await db.query.users.findMany({
  limit: 10,
  offset: 20,
});

With Sorting

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),
});

findFirst() Method { .api }

Find the first record matching the query.

db.query.tableName.findFirst(config?: DBQueryConfig): Promise<Result | undefined>

Parameters:

  • config (optional) - Query configuration object

Returns: Promise resolving to first result or undefined if no match

Basic Usage

// 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,
  },
});

With Relations and Filters

// 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,
    },
  },
});

Query Configuration Options

DBQueryConfig Interface { .api }

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
}

columns Option

Select or exclude specific columns.

Including 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 }>

Excluding Columns

// 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)

With Relations

// 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 }>
// }>

with Option

Load related data (eager loading).

Simple Relations

// 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,
  },
});

Nested Relations

// Load nested relations
const users = await db.query.users.findMany({
  with: {
    posts: {
      with: {
        comments: {
          with: {
            author: true,
          },
        },
      },
    },
  },
});

// Result includes: users -> posts -> comments -> author

Filtered Relations

// 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,
    },
  },
});

where Option

Filter results using conditions.

Static 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'),
});

Callback Conditions

// 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)
  ),
});

Available Operators

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
}

orderBy Option

Sort query results.

Static Ordering

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)],
});

Callback Ordering

// 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)],
});

Ordering Relations

// Order main query and relations
const users = await db.query.users.findMany({
  orderBy: desc(users.name),
  with: {
    posts: {
      orderBy: desc(posts.createdAt),
    },
  },
});

limit and offset Options

Paginate query results.

Basic Pagination

// 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,
});

Pagination Function

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
);

With Relations

// 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
    },
  },
});

Using Placeholders

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 });

extras Option

Add computed fields to results.

Static Extras

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
// }>

Callback Extras

const users = await db.query.users.findMany({
  extras: (fields, { sql }) => ({
    lowercaseName: sql<string>`lower(${fields.name})`.as('lowercase_name'),
  }),
});

Complex Computations

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'),
  },
});

Nested Relations

The relational query API supports loading relations to arbitrary depth.

Deep Nesting Example

// 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[]
//       -> follower

Circular Relations

Circular 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
          },
        },
      },
    },
  },
});

Filtering Nested Relations

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,
              },
            },
          },
        },
      },
    },
  },
});

Selecting Columns at Each Level

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,
              },
            },
          },
        },
      },
    },
  },
});

Filtering and Sorting

Complex Filtering Examples

Combining Multiple Conditions

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)
  ),
});

Array Operations

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']),
});

Range Queries

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'))
  ),
});

Pattern Matching

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'),
});

Subquery Filters

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))
  ),
});

Advanced Sorting Examples

Conditional Sorting

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
  `,
});

Sorting by Related Data

// 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),
});

Null Handling

// 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`,
});

Selecting Columns

Including Specific Columns

// Minimal user data
const users = await db.query.users.findMany({
  columns: {
    id: true,
    name: true,
    email: true,
  },
});

Excluding Sensitive Data

// All columns except password and secret
const users = await db.query.users.findMany({
  columns: {
    password: false,
    secretToken: false,
  },
});

Per-Relation Column Selection

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,
              },
            },
          },
        },
      },
    },
  },
});

Combining with Extras

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 }

Computed Fields

The extras option allows adding computed fields to query results.

String Operations

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'),
  },
});

Numeric Computations

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'),
  },
});

Date/Time Operations

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'),
  },
});

Aggregations

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'),
  },
});

JSON Operations (PostgreSQL)

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'),
  },
});

Using Field References in Extras

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'),
  }),
});

Type Inference

Drizzle's relational query API provides full TypeScript type inference.

Automatic 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

Column Selection Type Inference

// Selecting specific columns
const user = await db.query.users.findFirst({
  columns: {
    id: true,
    name: true,
  },
});

// Type: {
//   id: number;
//   name: string;
// } | undefined

Nested Relation Type Inference

const 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;
//     }>;
//   }>;
// } | undefined

Extras Type Inference

const 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>
// }>

Nullable Relation Type Inference

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

Reusable Type Definitions

// 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);
    });
  });
}

BuildQueryResult Type Helper

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;
        };
      };
    };
  }
>;

Comparison with Query Builder

The relational query API is an alternative to the SQL-like query builder. Here's a comparison:

One-to-Many: Users with Posts

Using Relational Query API

const usersWithPosts = await db.query.users.findMany({
  with: {
    posts: true,
  },
});

// Result: Array<{
//   id: number;
//   name: string;
//   posts: Array<{
//     id: number;
//     title: string;
//     authorId: number;
//   }>;
// }>

Using Query Builder

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 joins

Or 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 assertions

Many-to-One: Posts with Author

Using Relational Query API

const postsWithAuthor = await db.query.posts.findMany({
  with: {
    author: true,
  },
});

// Result: Array<{
//   id: number;
//   title: string;
//   authorId: number;
//   author: {
//     id: number;
//     name: string;
//   };
// }>

Using Query Builder

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 flattening

Nested Relations

Using Relational Query API

const users = await db.query.users.findMany({
  with: {
    posts: {
      with: {
        comments: {
          with: {
            author: true,
          },
        },
      },
    },
  },
});

// Single query, nested structure

Using Query Builder

// 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 queries

Filtering Relations

Using Relational Query API

const users = await db.query.users.findMany({
  with: {
    posts: {
      where: eq(posts.published, true),
      orderBy: desc(posts.createdAt),
      limit: 5,
    },
  },
});

// Clean, declarative syntax

Using Query Builder

// 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 assembly

Performance Considerations

Relational Query API

  • Generates optimized queries with JSON aggregation
  • Single query for nested relations (in most cases)
  • Efficient for loading related data
  • May generate complex SQL for deep nesting

Query Builder

  • Full control over generated SQL
  • Can optimize specific cases manually
  • More flexible for complex joins
  • Better for complex aggregations and window functions
  • Preferred for raw performance optimization

When to Use Each Approach

Use Relational Query API when:

  • Loading related data (eager loading)
  • Working with nested relations
  • Prefer cleaner, more maintainable code
  • Type safety is important
  • Building typical CRUD operations

Use Query Builder when:

  • Need precise control over SQL
  • Complex aggregations or window functions
  • Performance-critical queries
  • Custom joins or advanced SQL features
  • Working with raw SQL constructs

Combining Both Approaches

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));

Complete Examples

Blog Application

// 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) ?? [];
}

E-Commerce Application

// 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),
  });
}

Best Practices

1. Always Define Bidirectional Relations

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],
  }),
}));

2. Use Relation Names for Multiple Relations

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' }),
}));

3. Select Only Required Columns

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,
      },
    },
  },
});

4. Use Filters on Relations

Filter relations to reduce data transfer:

const users = await db.query.users.findMany({
  with: {
    posts: {
      where: eq(posts.published, true),
      limit: 10,
    },
  },
});

5. Leverage Type Inference

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 },
  });
}

6. Use Prepared Statements for Repeated Queries

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 });

7. Consider Query Builder for Complex Analytics

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);

Summary

The Drizzle ORM relational query API provides:

  • Type-safe relation definitions using relations() and helper functions
  • Intuitive query API with db.query.tableName.findMany/findFirst
  • Nested relation loading to arbitrary depth
  • Flexible filtering at every level
  • Column selection to minimize data transfer
  • Computed fields with the extras option
  • Full TypeScript inference for all query results
  • Performance with optimized query generation

The 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.