← Back to DevBytes

Drizzle ORM TypeScript: Strongly Typed Applications

What is Drizzle ORM?

Drizzle ORM is a TypeScript-first Object-Relational Mapping library designed to provide maximum type safety with zero code generation overhead. Unlike traditional ORMs that rely on decorators or runtime reflection, Drizzle leverages TypeScript's type inference to build fully typed database schemas, queries, and results at compile time. It supports PostgreSQL, MySQL, SQLite, and other SQL-based databases through dedicated driver packages.

The core philosophy of Drizzle is simple: stay as close to raw SQL as possible while giving you the full power of TypeScript's type system. Every table definition, every query, every result row is inferred with precise types — no manual type casting, no type assertions, no guesswork. The schema definitions themselves serve as both the source of truth for your database structure and the foundation for TypeScript's type narrowing across your entire application.

Key Features at a Glance

Why Strong Typing in Database Operations Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

In a typical Node.js backend, the database layer is often the largest source of runtime errors. Misspelled column names, incorrect type assumptions, or mismatched join conditions can cause subtle bugs that only surface in production. Drizzle ORM addresses this by making your database interactions fully type-checked at compile time. The compiler becomes your first line of defense against invalid queries.

The Cost of Untyped Database Access

Consider a raw SQL query executed via a generic driver like pg or mysql2:

const result = await pool.query('SELECT id, name, email FROM users WHERE active = $1', [true]);
// result.rows is typed as any[] — no guarantees about shape or field types
const userName = result.rows[0].name; // TypeScript trusts you, but did you spell 'name' correctly?

With Drizzle, the equivalent query looks like this:

const users = await db.select()
  .from(usersTable)
  .where(eq(usersTable.active, true));

// users is automatically typed as Array<{ id: number; name: string; email: string; active: boolean; }>
// Accessing users[0].name is fully safe — TypeScript knows it exists and is a string

The difference is profound. No more defensive type guards, no more optional chaining on fields that definitely exist, and no more unit tests dedicated solely to verifying query shapes. Your application logic can focus on business rules instead of data validation.

Type Safety Across the Full Stack

Drizzle's type inference propagates through your entire application. When you extract a query into a service function, the return type is automatically derived. When you pass that data to a controller or API handler, the types remain intact. This creates a type-safe pipeline from database to client, reducing the need for runtime validation middleware and enabling more confident refactoring.

Getting Started: Setting Up Drizzle ORM

Let's walk through a complete setup for a PostgreSQL-backed TypeScript project. The process is similar for MySQL and SQLite — you simply swap the driver package.

Installation

Start by installing the core Drizzle package, the PostgreSQL driver, and the migration utilities:

npm install drizzle-orm drizzle-kit pg
npm install --save-dev @types/pg

The drizzle-kit package provides CLI tools for schema introspection, migration generation, and pushing changes to your database. The pg package is the native PostgreSQL driver for Node.js.

Database Connection Setup

Create a dedicated file for your database connection. This keeps the setup centralized and reusable across your project:

// src/db/connection.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
});

export const db = drizzle(pool);

The drizzle() function wraps the connection pool and returns a typed database instance. At this point, the instance knows nothing about your schema yet — that comes next.

Defining Your First Schema

Schemas in Drizzle are plain TypeScript objects. You define tables using helper functions like pgTable (for PostgreSQL), mysqlTable, or sqliteTable. Each column is defined with its SQL type and optional constraints:

// src/db/schema/users.ts
import { pgTable, serial, varchar, boolean, timestamp } from 'drizzle-orm/pg-core';

export const usersTable = pgTable('users', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 100 }).notNull(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  active: boolean('active').default(true).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

Notice that each column function receives the actual SQL column name as its first argument. This decouples your TypeScript property names from the database column names, allowing you to follow idiomatic conventions on both sides. The TypeScript type of usersTable.id is automatically inferred as a column that holds numbers and corresponds to the id column in PostgreSQL.

Schema Validation with Zod (Optional but Recommended)

For additional runtime validation on insert and update payloads, Drizzle integrates seamlessly with Zod:

import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { z } from 'zod';

export const insertUserSchema = createInsertSchema(usersTable, {
  email: (schema) => schema.email.email(),
  name: (schema) => schema.name.min(2).max(100),
});

export const selectUserSchema = createSelectSchema(usersTable);

The createInsertSchema and createSelectSchema functions automatically generate Zod schemas that match your table definitions. You can override or extend validations for specific columns, as shown with the email and name refinements above.

Writing Typed Queries

Drizzle's query API mirrors SQL syntax closely while providing full type inference. Every clause you chain narrows the result type appropriately.

Basic SELECT Queries

// Select all active users — returns Array<{ id: number; name: string; email: string; ... }>
const activeUsers = await db.select()
  .from(usersTable)
  .where(eq(usersTable.active, true));

// Select only specific columns — returns Array<{ id: number; name: string }>
const userNames = await db.select({
  id: usersTable.id,
  name: usersTable.name,
}).from(usersTable);

The second example demonstrates column selection with a custom shape. The result type is narrowed to exactly Array<{ id: number; name: string }>. There is no over-fetching and no ambiguity about which fields are available on the returned objects.

INSERT, UPDATE, and DELETE Operations

// Insert a single user — returns the inserted row with all defaults applied
const newUser = await db.insert(usersTable)
  .values({
    name: 'Alice Johnson',
    email: 'alice@example.com',
  })
  .returning();

// TypeScript knows newUser has all columns, including auto-generated id and timestamps
console.log(newUser.id); // number, guaranteed to exist

// Bulk insert with type-checked array
const newUsers = await db.insert(usersTable)
  .values([
    { name: 'Bob Smith', email: 'bob@example.com' },
    { name: 'Carol White', email: 'carol@example.com' },
  ])
  .returning();

// Update with selective field assignment
await db.update(usersTable)
  .set({ active: false, updatedAt: new Date() })
  .where(eq(usersTable.id, 5));

// Delete with condition — TypeScript prevents you from forgetting .where()
await db.delete(usersTable)
  .where(eq(usersTable.email, 'bob@example.com'));

The .returning() method on inserts and updates returns the affected rows with their current database state, fully typed. This is particularly useful for retrieving auto-generated values like IDs and timestamps without making a separate query.

Advanced Filtering and Conditions

Drizzle provides a rich set of filter operators that compose naturally:

import { eq, ne, gt, gte, lt, lte, like, ilike, inArray, and, or, isNull } from 'drizzle-orm';

const filteredUsers = await db.select()
  .from(usersTable)
  .where(
    and(
      eq(usersTable.active, true),
      or(
        ilike(usersTable.email, '%@gmail.com'),
        ilike(usersTable.email, '%@outlook.com')
      ),
      gte(usersTable.createdAt, new Date('2024-01-01'))
    )
  );

All filter functions are fully typed — they only accept columns of matching types. You cannot accidentally compare a string column against a number or use ilike (case-insensitive pattern matching) on a numeric column. The compiler catches these mistakes immediately.

Relations and Nested Queries

One of Drizzle's most powerful features is its relational query API, which allows you to fetch related data in a single query while maintaining full type safety through the nested results.

Defining Relations Between Tables

First, let's create a related table and define the relationship:

// src/db/schema/posts.ts
import { pgTable, serial, integer, text, timestamp } from 'drizzle-orm/pg-core';
import { usersTable } from './users';
import { relations } from 'drizzle-orm';

export const postsTable = pgTable('posts', {
  id: serial('id').primaryKey(),
  userId: integer('user_id').notNull().references(() => usersTable.id),
  title: text('title').notNull(),
  content: text('content').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

// Define the relation from posts to users
export const postsRelations = relations(postsTable, ({ one }) => ({
  author: one(usersTable, {
    fields: [postsTable.userId],
    references: [usersTable.id],
  }),
}));

// Define the inverse relation from users to posts
export const usersRelations = relations(usersTable, ({ many }) => ({
  posts: many(postsTable),
}));

The relations() function creates metadata that Drizzle uses at query time to construct joins and shape nested results. These relation definitions are purely TypeScript objects — they don't affect the database schema itself, which is already defined by the table definitions and foreign key constraints.

Fetching Nested Relational Data

Drizzle offers two distinct APIs for querying relations, each with different use cases and type inference behavior.

Using db.query() — The Simplified Relational API

The db.query() method requires you to pass all your table and relation definitions to the drizzle() constructor upfront. This approach gives you a clean, high-level API:

// Updated connection setup with schema awareness
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 });

// Now you can use db.query.users.findMany() with automatic relation inclusion
const usersWithPosts = await db.query.users.findMany({
  with: {
    posts: true,
  },
  where: (users, { eq }) => eq(users.active, true),
});

// The result type is automatically:
// Array<{
//   id: number;
//   name: string;
//   email: string;
//   active: boolean;
//   createdAt: Date;
//   updatedAt: Date;
//   posts: Array<{
//     id: number;
//     userId: number;
//     title: string;
//     content: string;
//     createdAt: Date;
//   }>;
// }>

The with clause accepts a shape object where keys match your relation names. TypeScript infers the complete nested structure, so accessing usersWithPosts[0].posts[0].title is fully type-checked. You can nest relations arbitrarily deep when your schema supports it.

Using db.select() with Nested Relations

For more granular control over the shape of returned data, use the db.select() API with relation helpers. This allows you to pick exactly which columns and relations to include:

const result = await db.select({
  userName: usersTable.name,
  postTitle: postsTable.title,
  postContent: postsTable.content,
})
  .from(usersTable)
  .innerJoin(postsTable, eq(usersTable.id, postsTable.userId))
  .where(eq(usersTable.active, true));

// result is typed as Array<{ userName: string; postTitle: string; postContent: string }>

This flat join approach gives you complete control over the output shape. The type inference is precise — only the fields you explicitly select appear in the result type.

Migrations and Schema Evolution

Drizzle Kit (drizzle-kit) handles database migrations with a declarative workflow. You define your schema in TypeScript, and Drizzle Kit generates SQL migration files that transform your database to match.

Configuring Drizzle Kit

Create a drizzle.config.ts file at the root of your project:

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './src/db/schema/*.ts',
  out: './src/db/migrations',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose: true,
  strict: true,
});

The schema field points to your table definition files using a glob pattern. The out directory is where migration SQL files will be generated. Setting strict: true ensures that Drizzle Kit will alert you about destructive changes like column drops that could cause data loss.

Generating and Running Migrations

The typical migration workflow involves three steps:

# Step 1: Generate migration SQL from schema changes
npx drizzle-kit generate

# Step 2: Inspect the generated SQL file (example: src/db/migrations/0001_awesome_phantom.sql)
# Step 3: Apply migrations to your database
npx drizzle-kit migrate

For production deployments, you can run migrations programmatically using the migrator utility:

// src/db/migrate.ts
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { db } from './connection';

async function runMigrations() {
  await migrate(db, {
    migrationsFolder: './src/db/migrations',
  });
  console.log('Migrations applied successfully');
  process.exit(0);
}

runMigrations().catch((err) => {
  console.error('Migration failed:', err);
  process.exit(1);
});

This approach integrates well with CI/CD pipelines and containerized environments where you want migrations to run automatically on startup.

Best Practices for Drizzle ORM Projects

Over time, teams using Drizzle have converged on several patterns that maximize the benefits of strong typing while keeping codebases maintainable.

1. Organize Schemas by Domain

Rather than dumping all table definitions into a single file, group them by domain context:

src/db/
  schema/
    users.ts       — usersTable, usersRelations
    posts.ts       — postsTable, postsRelations
    comments.ts    — commentsTable, commentsRelations
    index.ts       — barrel export of all tables and relations
  connection.ts    — database instance
  migrations/      — auto-generated SQL files

A barrel file (index.ts) re-exports everything so consumers can import from a single path:

// src/db/schema/index.ts
export * from './users';
export * from './posts';
export * from './comments';

2. Use Dedicated Query Functions for Complex Operations

Extract frequently used queries into typed helper functions. This creates a clean data access layer and allows TypeScript to propagate return types automatically:

// src/services/userService.ts
import { db } from '../db/connection';
import { usersTable, postsTable } from '../db/schema';
import { eq, and, desc } from 'drizzle-orm';

export async function getUserWithRecentPosts(userId: number) {
  return await db.query.users.findFirst({
    where: (users, { eq }) => eq(users.id, userId),
    with: {
      posts: {
        orderBy: (posts, { desc }) => [desc(posts.createdAt)],
        limit: 10,
      },
    },
  });
}

// The return type is inferred as:
// {
//   id: number; name: string; email: string; ... 
//   posts: Array<{ id: number; title: string; ... }>
// } | undefined

Notice that TypeScript correctly infers | undefined in the return type because findFirst may not find a matching row. Callers of this function are forced to handle the null case, eliminating a common class of runtime errors.

3. Leverage Partial Selects for API Responses

When building REST or GraphQL endpoints, select only the fields the client needs. Drizzle's type system ensures that your API response types match exactly what you query:

// GET /api/users — returns only public-safe fields
export async function getPublicUserProfiles() {
  return await db.select({
    id: usersTable.id,
    displayName: usersTable.name,
    avatarUrl: usersTable.email, // simplified for example
  }).from(usersTable)
    .where(eq(usersTable.active, true));
}

// The return type is precisely Array<{ id: number; displayName: string; avatarUrl: string }>
// TypeScript guarantees no sensitive fields (like internal flags) leak into the response

4. Keep Migrations Version-Controlled

Always commit generated migration files to version control. They serve as an auditable history of your database schema changes and enable reproducible deployments. Never edit generated migration files manually — if you need custom SQL logic in a migration, use Drizzle Kit's custom migration feature or write a separate manual migration.

5. Use Transactions for Consistency-Critical Operations

Drizzle supports transactions with full type safety. The transaction object carries the same schema awareness as the main db instance:

import { db } from './connection';
import { usersTable, postsTable } from './schema';

async function createUserWithFirstPost(name: string, email: string, postTitle: string) {
  return await db.transaction(async (tx) => {
    const [newUser] = await tx.insert(usersTable)
      .values({ name, email })
      .returning();

    const [newPost] = await tx.insert(postsTable)
      .values({
        userId: newUser.id,
        title: postTitle,
        content: 'Welcome post',
      })
      .returning();

    return { user: newUser, post: newPost };
  });
}

// The return type is { user: User; post: Post } — fully typed through the transaction boundary

If any query within the transaction fails, all changes are rolled back. The tx object has the exact same typed API as db, so you lose none of the type safety when working inside transactions.

6. Combine with Zod for Input Validation at the Boundary

Use drizzle-zod to generate validation schemas for inserts and updates, then validate incoming data at your API layer before it reaches your query functions:

import { createInsertSchema } from 'drizzle-zod';
import { z } from 'zod';

const insertUserSchema = createInsertSchema(usersTable, {
  email: (col) => col.email.email(),
});

// In your API route handler
app.post('/users', async (req, res) => {
  const parsed = insertUserSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ errors: parsed.error.flatten() });
  }

  const newUser = await db.insert(usersTable)
    .values(parsed.data)
    .returning();

  res.status(201).json(newUser);
});

This pattern creates a type-safe bridge between the untyped boundary of HTTP input and the strongly typed database layer. Zod validates and coerces the raw input, and Drizzle ensures the validated data matches the database schema exactly.

Working with PostgreSQL-Specific Features

Drizzle provides first-class support for PostgreSQL-specific capabilities like JSON columns, full-text search, and array types. These features are fully typed, so you get compile-time safety even with complex column types.

Typed JSON Columns

import { pgTable, serial, jsonb, varchar } from 'drizzle-orm/pg-core';

interface UserPreferences {
  theme: 'light' | 'dark';
  notifications: boolean;
  language: string;
}

export const userProfilesTable = pgTable('user_profiles', {
  id: serial('id').primaryKey(),
  userId: varchar('user_id').notNull(),
  preferences: jsonb('preferences').$type().notNull(),
});

// When querying, preferences is typed as UserPreferences, not as unknown
const profiles = await db.select()
  .from(userProfilesTable)
  .where(eq(userProfilesTable.userId, 'abc123'));

// profiles[0].preferences.theme — TypeScript knows this is 'light' | 'dark'

The .$type() method tells TypeScript the shape of the JSON data. Drizzle stores and retrieves it as a native PostgreSQL jsonb column, but your application code interacts with it as a typed object.

Full-Text Search Vectors

import { pgTable, text, tsvector } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';

export const articlesTable = pgTable('articles', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  body: text('body').notNull(),
  searchVector: tsvector('search_vector'),
});

// Populate the tsvector column with weighted text
await db.insert(articlesTable)
  .values({
    title: 'Drizzle ORM Guide',
    body: 'A comprehensive guide to using Drizzle ORM...',
    searchVector: sql`setweight(to_tsvector('english', ${'Drizzle ORM Guide'}), 'A') || setweight(to_tsvector('english', ${'A comprehensive guide...'}), 'B')`,
  })
  .returning();

// Query using full-text search with ranking
const searchResults = await db.select({
  id: articlesTable.id,
  title: articlesTable.title,
  rank: sql`ts_rank(${articlesTable.searchVector}, plainto_tsquery('english', ${'orm guide'}))`.as('rank'),
})
  .from(articlesTable)
  .where(sql`${articlesTable.searchVector} @@ plainto_tsquery('english', ${'orm guide'})`)
  .orderBy(sql`rank DESC`);

The sql template literal allows you to embed raw SQL expressions while maintaining type safety. The .as('rank') method gives the computed expression a typed alias that appears in the result type.

Performance Considerations

Drizzle is designed with performance as a primary goal. It generates SQL queries directly from your chained method calls without intermediate AST representations or runtime query builders. The library itself has a small footprint (approximately 10KB gzipped for the core) and adds negligible overhead to query execution.

Query Execution Model

Every Drizzle query method returns a Promise that resolves to the typed result. There is no lazy evaluation, no change tracking, and no entity state management — Drizzle is purely a query builder and executor. When you call await db.select()..., it builds the SQL string immediately, executes it against the driver, and returns the typed rows. This direct model means performance is bounded primarily by your database and driver, not by the ORM layer.

Connection Pooling

Drizzle delegates connection management entirely to the underlying driver (like pg's Pool or mysql2's pool). You configure pooling at the driver level, and Drizzle uses the pool transparently. This separation of concerns lets you tune connection parameters independently of query logic.

Comparison with Other TypeScript ORMs

Understanding how Drizzle differs from alternatives helps clarify when it's the right choice for your project.

Drizzle vs. Prisma

Drizzle vs. TypeORM

Drizzle vs. Kysely

Conclusion

Drizzle ORM represents a compelling evolution in TypeScript database tooling. By treating schema definitions as first-class TypeScript objects and leveraging the compiler's type inference engine, it eliminates the friction between database schemas and application types. You write your tables once — in TypeScript — and get automatic, precise typing across every query, insert, update, and relation in your application.

The result is a development experience where database errors are caught at compile time rather than in production logs. Refactoring becomes safer because renaming a column in your schema definition immediately surfaces every affected query through TypeScript errors. The close-to-SQL query API means you retain the full expressiveness of your database while gaining the safety net of static typing.

Whether you're building a small side project or a large-scale production system, Drizzle's combination of type safety, performance, and developer ergonomics makes it a strong candidate for any TypeScript application that interacts with a SQL database. The zero-code-generation philosophy keeps your build pipeline simple, and the first-class migration tooling ensures your database stays synchronized with your evolving application code.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles