Blog/Deep Dives/Drizzle ORM: Type-Safe Database Access for TypeScript
POST
August 02, 2025
LAST UPDATEDAugust 02, 2025

Drizzle ORM: Type-Safe Database Access for TypeScript

A practical guide to Drizzle ORM covering schema definition, type-safe queries, migrations, relational queries, and performance in TypeScript projects.

Tags

Drizzle ORMTypeScriptPostgreSQLDatabase
Drizzle ORM: Type-Safe Database Access for TypeScript
7 min read

Drizzle ORM: Type-Safe Database Access for TypeScript

Drizzle ORM is a TypeScript-first database toolkit where your schema is TypeScript, your queries look like SQL, and every result is fully typed. It sits in a unique position between raw SQL drivers and heavy ORMs: you get the safety and developer experience of a typed query builder without the runtime overhead or abstraction distance of traditional ORMs. If you know SQL, you already know Drizzle's query API.

TL;DR

Drizzle ORM provides type-safe database access with an API that mirrors SQL, zero runtime overhead, and schema defined entirely in TypeScript. It generates migrations from schema diffs, supports relational queries for convenient data fetching, and works seamlessly in serverless and edge environments. It is the right choice when you want type safety without giving up control over your queries.

Why This Matters

Most TypeScript backend projects face the same dilemma: write raw SQL and lose type safety, or use an ORM and lose control over query generation. Raw SQL strings have no compile-time checking. A column rename silently breaks queries that you discover only at runtime. Traditional ORMs solve the type safety problem but introduce their own: opaque query generation, the N+1 query problem, and an abstraction layer that makes it hard to reason about performance.

Drizzle occupies the space between these extremes. Your schema is TypeScript, so types flow from your table definitions through your queries to your application code. Your queries use a SQL-like API, so you can read the code and understand what SQL will execute. There is no query engine binary, no generated client to keep in sync, and no runtime reflection.

This matters especially in the serverless and edge computing era, where cold start times and bundle sizes directly affect user experience. Drizzle's minimal runtime makes it well-suited for environments where every kilobyte and millisecond counts.

How It Works

Schema Definition

Drizzle schemas are TypeScript files that define your tables using builder functions. The schema is the single source of truth for both your application types and your database migrations.

typescript
// src/db/schema.ts
import {
  pgTable,
  uuid,
  varchar,
  text,
  timestamp,
  boolean,
  integer,
  decimal,
  pgEnum,
  uniqueIndex,
  index,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
 
// Enums
export const userRoleEnum = pgEnum('user_role', ['admin', 'editor', 'viewer']);
export const postStatusEnum = pgEnum('post_status', ['draft', 'published', 'archived']);
 
// Users table
export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: varchar('email', { length: 255 }).notNull(),
  name: varchar('name', { length: 255 }).notNull(),
  role: userRoleEnum('role').default('viewer').notNull(),
  avatarUrl: text('avatar_url'),
  isActive: boolean('is_active').default(true).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => [
  uniqueIndex('users_email_idx').on(table.email),
]);
 
// Posts table
export const posts = pgTable('posts', {
  id: uuid('id').primaryKey().defaultRandom(),
  title: varchar('title', { length: 500 }).notNull(),
  slug: varchar('slug', { length: 500 }).notNull(),
  content: text('content'),
  excerpt: text('excerpt'),
  status: postStatusEnum('status').default('draft').notNull(),
  authorId: uuid('author_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
  viewCount: integer('view_count').default(0).notNull(),
  publishedAt: timestamp('published_at'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => [
  uniqueIndex('posts_slug_idx').on(table.slug),
  index('posts_author_idx').on(table.authorId),
  index('posts_status_idx').on(table.status),
]);
 
// Tags table
export const tags = pgTable('tags', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: varchar('name', { length: 100 }).notNull(),
  slug: varchar('slug', { length: 100 }).notNull(),
}, (table) => [
  uniqueIndex('tags_slug_idx').on(table.slug),
]);
 
// Post-Tags junction table
export const postTags = pgTable('post_tags', {
  postId: uuid('post_id').references(() => posts.id, { onDelete: 'cascade' }).notNull(),
  tagId: uuid('tag_id').references(() => tags.id, { onDelete: 'cascade' }).notNull(),
}, (table) => [
  uniqueIndex('post_tags_idx').on(table.postId, table.tagId),
]);

Relations for Convenient Queries

Drizzle separates schema (the database structure) from relations (how you want to query connected data). Relations do not affect the database schema but enable the relational query API.

typescript
// src/db/relations.ts
import { relations } from 'drizzle-orm';
import { users, posts, tags, postTags } from './schema';
 
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));
 
export const postsRelations = relations(posts, ({ one, many }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
  postTags: many(postTags),
}));
 
export const tagsRelations = relations(tags, ({ many }) => ({
  postTags: many(postTags),
}));
 
export const postTagsRelations = relations(postTags, ({ one }) => ({
  post: one(posts, {
    fields: [postTags.postId],
    references: [posts.id],
  }),
  tag: one(tags, {
    fields: [postTags.tagId],
    references: [tags.id],
  }),
}));

Type-Safe Queries

Drizzle's query API mirrors SQL syntax. If you can write a SELECT statement, you can write a Drizzle query.

typescript
// src/db/index.ts
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
import * as schema from './schema';
import * as relations from './relations';
 
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema: { ...schema, ...relations } });
typescript
// Basic SELECT with WHERE, ORDER BY, LIMIT
import { eq, desc, and, like, sql } from 'drizzle-orm';
 
// Simple query - fully typed result
const publishedPosts = await db
  .select()
  .from(posts)
  .where(eq(posts.status, 'published'))
  .orderBy(desc(posts.publishedAt))
  .limit(10);
// Type: { id: string; title: string; slug: string; content: string | null; ... }[]
 
// Select specific columns
const postSummaries = await db
  .select({
    id: posts.id,
    title: posts.title,
    slug: posts.slug,
    authorName: users.name,
    publishedAt: posts.publishedAt,
  })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id))
  .where(eq(posts.status, 'published'))
  .orderBy(desc(posts.publishedAt));
// Type: { id: string; title: string; slug: string; authorName: string; publishedAt: Date | null }[]
 
// Aggregations
const authorStats = await db
  .select({
    authorId: posts.authorId,
    authorName: users.name,
    totalPosts: sql<number>`count(${posts.id})::int`,
    totalViews: sql<number>`coalesce(sum(${posts.viewCount}), 0)::int`,
  })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id))
  .where(eq(posts.status, 'published'))
  .groupBy(posts.authorId, users.name)
  .orderBy(desc(sql`count(${posts.id})`));
 
// Complex WHERE conditions
const filteredPosts = await db
  .select()
  .from(posts)
  .where(
    and(
      eq(posts.status, 'published'),
      like(posts.title, `%${searchTerm}%`)
    )
  );
typescript
// Relational queries - Drizzle's convenience API for nested data
const postsWithAuthors = await db.query.posts.findMany({
  where: eq(posts.status, 'published'),
  with: {
    author: {
      columns: { name: true, avatarUrl: true },
    },
    postTags: {
      with: {
        tag: true,
      },
    },
  },
  orderBy: [desc(posts.publishedAt)],
  limit: 10,
});
// Returns nested objects with full type safety:
// { id, title, ..., author: { name, avatarUrl }, postTags: [{ tag: { id, name, slug } }] }[]

Mutations: Insert, Update, Delete

typescript
// Insert with returning
const newPost = await db
  .insert(posts)
  .values({
    title: 'Getting Started with Drizzle',
    slug: 'getting-started-with-drizzle',
    content: 'Full article content here...',
    authorId: currentUser.id,
    status: 'draft',
  })
  .returning();
// Type: the full post row
 
// Batch insert
await db.insert(postTags).values(
  selectedTagIds.map((tagId) => ({
    postId: newPost[0].id,
    tagId,
  }))
);
 
// Update with conditions
const updated = await db
  .update(posts)
  .set({
    status: 'published',
    publishedAt: new Date(),
    updatedAt: new Date(),
  })
  .where(eq(posts.id, postId))
  .returning({ id: posts.id, status: posts.status });
 
// Delete
await db
  .delete(posts)
  .where(
    and(
      eq(posts.authorId, currentUser.id),
      eq(posts.status, 'draft')
    )
  );

Migrations with Drizzle Kit

Drizzle Kit generates SQL migration files by comparing your current schema to the previous snapshot.

typescript
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
 
export default defineConfig({
  schema: './src/db/schema.ts',
  out: './drizzle/migrations',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});
bash
# Generate a migration after schema changes
npx drizzle-kit generate
 
# Apply pending migrations
npx drizzle-kit migrate
 
# Push schema directly (for development/prototyping)
npx drizzle-kit push
 
# Open Drizzle Studio (database browser)
npx drizzle-kit studio

The generated migration files are plain SQL that you can review, modify, and version control:

sql
-- drizzle/migrations/0001_add_posts_table.sql
CREATE TYPE "post_status" AS ENUM ('draft', 'published', 'archived');
 
CREATE TABLE "posts" (
  "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  "title" varchar(500) NOT NULL,
  "slug" varchar(500) NOT NULL,
  "content" text,
  "status" "post_status" DEFAULT 'draft' NOT NULL,
  "author_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
  "view_count" integer DEFAULT 0 NOT NULL,
  "published_at" timestamp,
  "created_at" timestamp DEFAULT now() NOT NULL,
  "updated_at" timestamp DEFAULT now() NOT NULL
);
 
CREATE UNIQUE INDEX "posts_slug_idx" ON "posts" ("slug");
CREATE INDEX "posts_author_idx" ON "posts" ("author_id");

Practical Implementation

Connection Pooling with Neon

For serverless deployments, Drizzle integrates with Neon's serverless driver, which uses HTTP-based connections that do not suffer from the connection pooling issues that plague traditional PostgreSQL drivers in serverless environments.

typescript
// For serverless/edge - HTTP-based (no persistent connections)
import { neon } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-http';
 
const sql = neon(process.env.DATABASE_URL!);
const db = drizzle(sql);
 
// For traditional server environments - connection pool
import { Pool } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-serverless';
 
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);

Prepared Statements

For frequently executed queries, prepared statements improve performance by parsing the query plan once and reusing it.

typescript
import { eq, placeholder } from 'drizzle-orm';
 
// Define prepared statement
const getPostBySlug = db
  .select()
  .from(posts)
  .where(eq(posts.slug, placeholder('slug')))
  .prepare('get_post_by_slug');
 
// Execute with parameters - query plan is cached
const post = await getPostBySlug.execute({ slug: 'my-post-slug' });

Schema Sharing in Monorepos

In monorepo architectures, Drizzle schemas can be shared across packages. Define your schema in a shared package and import it in multiple services.

typescript
// packages/db-schema/src/index.ts
export * from './schema';
export * from './relations';
export type { InferSelectModel, InferInsertModel } from 'drizzle-orm';
 
// Inferred types for use across packages
export type User = InferSelectModel<typeof users>;
export type NewUser = InferInsertModel<typeof users>;
export type Post = InferSelectModel<typeof posts>;
export type NewPost = InferInsertModel<typeof posts>;
typescript
// apps/web/src/lib/db.ts
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
import * as schema from '@myorg/db-schema';
 
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });
 
// Full type safety flows from the shared schema
// apps/api/src/routes/posts.ts
import { posts, type Post, type NewPost } from '@myorg/db-schema';

Performance Comparison with Prisma

Drizzle and Prisma take fundamentally different approaches. Drizzle compiles to direct SQL calls with no intermediate query engine. Prisma routes queries through a Rust-based query engine binary. This architectural difference has practical implications:

Drizzle generates SQL directly in your Node.js process. Prisma generates a query AST, sends it to the query engine binary via IPC, which then generates and executes SQL. Drizzle's approach means smaller deployments (no binary to bundle), faster cold starts (no engine initialization), and more predictable query performance.

However, Prisma offers advantages in other areas: a more polished migration workflow, better documentation, a larger community, and features like Prisma Accelerate for global edge caching. The choice depends on whether you prioritize runtime performance and SQL control (Drizzle) or ecosystem maturity and developer tooling (Prisma).

Common Pitfalls

Forgetting to export relations alongside schema. Drizzle's relational query API (db.query.posts.findMany) only works if you pass both schema and relations to the drizzle() constructor. Missing relations means the with clause silently does nothing or throws confusing errors.

Using sql template literals without type annotations. When using raw SQL fragments with sql<Type>, always provide the generic type parameter. Without it, the result type defaults to unknown, defeating the purpose of type-safe queries.

Not indexing columns used in WHERE clauses. Drizzle does not create indexes automatically. If you query by status or authorId frequently, define indexes explicitly in your schema. Drizzle Kit will generate the migration to create them.

Overusing relational queries for complex joins. The relational query API is convenient for simple nested data loading, but for complex aggregations, GROUP BY, or advanced JOINs, the SQL-like select API gives you more control and generates more efficient queries.

Ignoring migration review. Drizzle Kit generates migrations automatically, but always review the SQL before applying it to production. Schema changes like column renames can be misinterpreted as drop-and-create, which causes data loss.

When to Use (and When Not To)

Use Drizzle ORM when:

  • ›You want type-safe queries that map directly to SQL
  • ›You are deploying to serverless or edge environments where bundle size and cold starts matter
  • ›You are comfortable with SQL and want an ORM that enhances rather than replaces your SQL knowledge
  • ›You need schema sharing across packages in a monorepo
  • ›You want to incrementally adopt an ORM without rewriting existing raw SQL queries

Consider alternatives when:

  • ›Your team is unfamiliar with SQL and would benefit from a more abstracted query language
  • ›You need Prisma's mature ecosystem features like Accelerate, Pulse, or Studio
  • ›You are working on a project where Prisma's generated client and documentation would reduce onboarding time
  • ›You need database introspection to generate schema from an existing database (Drizzle supports this but Prisma's is more mature)

Drizzle ORM represents a shift in how TypeScript developers interact with databases: toward tools that enhance SQL rather than hide it. For teams that value SQL literacy and type safety equally, it is an excellent choice.

FAQ

What is Drizzle ORM?

Drizzle ORM is a TypeScript-first database toolkit that provides type-safe query building with a SQL-like API. Your schema is defined in TypeScript, types are inferred automatically, and queries map directly to the SQL they generate. It supports PostgreSQL, MySQL, and SQLite with zero runtime overhead.

How does Drizzle compare to Prisma?

Drizzle uses a SQL-like query API while Prisma uses a custom query language. Drizzle generates types directly from your TypeScript schema while Prisma generates them from a separate .prisma file. Drizzle has a smaller runtime footprint with no binary engine dependency, making it better suited for serverless and edge deployments. Prisma has a more mature ecosystem with better documentation and additional platform services.

Does Drizzle ORM support migrations?

Yes, Drizzle Kit generates SQL migration files by comparing your current TypeScript schema to the previous snapshot. You can review and modify the generated SQL before applying it. Drizzle also supports a push mode that applies schema changes directly for rapid prototyping, and Drizzle Studio provides a visual database browser.

Can Drizzle ORM work with serverless and edge environments?

Yes, Drizzle works exceptionally well in serverless and edge environments because it has no heavy runtime dependencies or binary engines to bundle. It supports HTTP-based database drivers like Neon's serverless driver and PlanetScale's serverless driver, which eliminate traditional connection pooling issues in serverless deployments.

What databases does Drizzle ORM support?

Drizzle supports PostgreSQL (via node-postgres, Neon, Supabase, Vercel Postgres), MySQL (via mysql2, PlanetScale), and SQLite (via better-sqlite3, Turso/libSQL, Cloudflare D1). Each database dialect has its own set of schema builders and query capabilities that match the features available in that database.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

How to Design API Contracts Between Micro-Frontends and BFFs
Mar 21, 20266 min read
Micro-Frontends
BFF
API Design

How to Design API Contracts Between Micro-Frontends and BFFs

Learn how to design stable API contracts between Micro-Frontends and Backend-for-Frontend layers with versioning, ownership boundaries, error handling, and schema governance.

Next.js BFF Architecture
Mar 21, 20261 min read
Next.js
BFF
Architecture

Next.js BFF Architecture

An architectural deep dive into using Next.js as a Backend-for-Frontend, including route handlers, server components, auth boundaries, caching, and service orchestration.

Next.js Cache Components and PPR in Real Apps
Mar 21, 20266 min read
Next.js
Performance
Caching

Next.js Cache Components and PPR in Real Apps

A practical guide to using Next.js Cache Components and Partial Prerendering in real applications, with tradeoffs, cache strategy, and freshness considerations.