Skip to content

Web Development · Backend

Drizzle ORM: TypeScript-First Database Access

Drizzle is the TypeScript-first alternative to Prisma: schema in TS, migrations in SQL, no query engine binary. How it works, and when to pick it.

Anurag Verma

Anurag Verma

7 min read

Watercolor of a small grey cloud drizzling into two rows of glass jars, each filled to the same level

Sponsored

Share

Prisma made type-safe database access mainstream in the Node.js ecosystem. But it came with trade-offs: a non-standard schema format, a heavy query engine binary, and limited flexibility when you needed to write SQL that its abstractions didn’t cover.

Drizzle ORM takes a different approach. Your schema is TypeScript. Your migrations are SQL. Your queries look close to SQL. The library stays thin: no separate binary, no generated files to check into git, no runtime code generation. You write TypeScript and get fully typed database calls.

It’s gained enough traction to be worth understanding, especially for projects where you want to run on edge runtimes like Cloudflare Workers or Vercel Edge, where Prisma’s query engine has historically caused problems.

How Drizzle Thinks About Schema

Drizzle defines your database schema as plain TypeScript objects instead of a custom DSL. Where Prisma requires a separate .prisma file and a code-generation step to produce types, Drizzle’s schema file is directly importable, type-checked by your editor, and usable in tests and utilities without any build step in between. Here’s what that looks like in practice:

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

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  isActive: boolean('is_active').default(true).notNull(),
});

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  body: text('body'),
  authorId: integer('author_id').references(() => users.id).notNull(),
  publishedAt: timestamp('published_at'),
});

This file is just TypeScript. Your editor understands it. You can import it, write utilities around it, and use it in tests without any special tooling. The schema types flow through into your queries automatically.

Querying

Drizzle provides two query APIs: the SQL-like query builder and a relational query API for joins.

Query builder:

import { db } from './db';
import { users, posts } from './schema';
import { eq, and, gte, desc } from 'drizzle-orm';

// Select with filter
const activeUsers = await db
  .select()
  .from(users)
  .where(eq(users.isActive, true));

// The return type is inferred: { id: number; email: string; name: string | null; ... }[]

// Insert
const [newUser] = await db
  .insert(users)
  .values({ email: 'dev@example.com', name: 'Alice' })
  .returning();

// Update
await db
  .update(users)
  .set({ name: 'Alice Smith' })
  .where(eq(users.id, newUser.id));

// Delete
await db
  .delete(users)
  .where(and(
    eq(users.isActive, false),
    gte(users.createdAt, cutoffDate),
  ));

Relational queries (joins without writing JOIN):

For this to work, you define relations in the schema:

import { relations } from 'drizzle-orm';

export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
}));

Then query with nested includes:

const usersWithPosts = await db.query.users.findMany({
  with: {
    posts: {
      where: (posts, { isNull }) => isNull(posts.publishedAt),
      columns: {
        id: true,
        title: true,
      },
    },
  },
  where: (users, { eq }) => eq(users.isActive, true),
});

// Type: { id: number; email: string; posts: { id: number; title: string }[] }[]

The return type is precisely typed based on what you included. If you only select id and title, you won’t get body in the result type.

Migrations With Drizzle Kit

Schema changes generate SQL migrations rather than running them automatically. This is intentional: you can review the migration before running it.

# Install Drizzle Kit (dev dependency)
npm install -D drizzle-kit

# Generate migration from schema changes
npx drizzle-kit generate

# Apply migrations
npx drizzle-kit migrate

The generated migrations are plain SQL files you commit to your repo:

-- migrations/0001_add_user_roles.sql
ALTER TABLE "users" ADD COLUMN "role" text DEFAULT 'user' NOT NULL;
CREATE INDEX "users_role_idx" ON "users" ("role");

You can edit the SQL before running it, which is useful when you need to backfill data as part of a migration. Drizzle doesn’t regenerate the file unless you delete it.

Database and Runtime Support

Drizzle supports PostgreSQL, MySQL, and SQLite. The adapters cover most connection options:

AdapterUse Case
drizzle-orm/node-postgresStandard Postgres (pg library)
drizzle-orm/neon-httpNeon serverless Postgres over HTTP
drizzle-orm/postgres-jspostgres.js driver
drizzle-orm/better-sqlite3Local SQLite
drizzle-orm/libsqlTurso / LibSQL (SQLite at the edge)
drizzle-orm/d1Cloudflare D1
drizzle-orm/planetscale-serverlessPlanetScale

Setting up for Neon (common for serverless Postgres):

// src/db/index.ts
import { neon } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-http';
import * as schema from './schema';

const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });

Setting up for Cloudflare D1 (SQLite at the edge):

// In a Cloudflare Worker
import { drizzle } from 'drizzle-orm/d1';
import * as schema from './schema';

export default {
  async fetch(request: Request, env: Env) {
    const db = drizzle(env.DB, { schema });
    const result = await db.select().from(schema.users).limit(10);
    return Response.json(result);
  },
};

This is where Drizzle has a concrete advantage over Prisma. The Prisma query engine doesn’t run on Cloudflare Workers or similar edge environments. Drizzle does. It’s pure TypeScript with no native binary.

Migrating From Prisma

If you have a Prisma schema, migration is mostly straightforward. The main translation:

// Prisma schema
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
}

Becomes:

// Drizzle schema
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

The query syntax changes are bigger. Prisma’s API:

// Prisma
const user = await prisma.user.findUnique({ where: { id: 1 } });
const users = await prisma.user.findMany({
  where: { posts: { some: { published: true } } },
  include: { posts: true },
});

Drizzle equivalents:

// Drizzle
const user = await db.query.users.findFirst({
  where: (users, { eq }) => eq(users.id, 1),
});

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

Plan for a few hours per model in a complex schema. The relational query API covers most Prisma patterns.

Raw SQL When Needed

You can drop to raw SQL at any time without giving up type safety:

import { sql } from 'drizzle-orm';

const result = await db.execute(sql`
  SELECT u.id, u.email, COUNT(p.id) AS post_count
  FROM users u
  LEFT JOIN posts p ON p.author_id = u.id
  GROUP BY u.id, u.email
  HAVING COUNT(p.id) > ${minPosts}
`);

The tagged template literal is SQL injection-safe. Values are parameterized, not interpolated.

Transactions

Multi-step writes that need to succeed or fail together go through db.transaction(). Drizzle wraps the callback in a database transaction and rolls back automatically if anything inside throws:

await db.transaction(async (tx) => {
  const [order] = await tx
    .insert(orders)
    .values({ userId, total: cartTotal })
    .returning();

  await tx.insert(orderItems).values(
    cartItems.map((item) => ({
      orderId: order.id,
      productId: item.productId,
      quantity: item.quantity,
    }))
  );

  await tx
    .update(inventory)
    .set({ quantity: sql`${inventory.quantity} - 1` })
    .where(eq(inventory.productId, cartItems[0].productId));
});

Every query inside the callback uses tx instead of db. If the inventory update fails because a CHECK constraint rejects a negative quantity, the order and order items inserted earlier in the same callback roll back with it. This is the same guarantee you’d get from a raw BEGIN/COMMIT block, just without writing the transaction control statements yourself.

Drizzle Studio

drizzle-kit studio runs a local database GUI in the browser, similar in purpose to Prisma Studio: you can browse tables, edit rows, and run ad hoc queries against your actual schema without opening a separate SQL client. It reads the same drizzle.config.ts your migrations use, so it always reflects the schema you’re currently working against rather than a stale export. For serverless Postgres providers like Neon, this is often faster than switching to a web dashboard just to check whether a migration applied correctly.

When Not to Use Drizzle

Drizzle’s relational query API doesn’t yet match Prisma’s where nesting for complex nested filters. If your queries involve deep nested conditions across multiple relations, Prisma or raw SQL may be easier.

If your team is coming from a backend-agnostic ORM background (Sequelize, TypeORM) and needs familiarity, Prisma’s API is closer to those patterns.

For most projects starting fresh in TypeScript in 2026, Drizzle is worth the default choice: it’s lighter, more flexible, and the edge runtime support is a genuine differentiator as more infrastructure moves to serverless environments.

Frequently asked questions

Why does Drizzle work on Cloudflare Workers when Prisma doesn't?
Because it is pure TypeScript with no native binary. Prisma's query engine is a compiled Rust binary that the Node process shells out to, and edge runtimes do not let you run one. Drizzle compiles queries in JavaScript and hands SQL to a driver, so anything with a driver that speaks HTTP or the platform's own binding works, which is what makes D1, Neon over HTTP, and Turso viable targets.
Is generating migrations instead of applying them a downside?
It is the point. drizzle-kit generate writes a plain SQL file you read before anything touches the database, and because it will not regenerate that file unless you delete it, you can edit it. That matters for the case every team eventually hits: a schema change that needs a data backfill in the same migration. An ORM that applies changes automatically gives you nowhere to put that.
How long does migrating from Prisma take?
Plan a few hours per model on a complex schema. The schema definitions translate almost mechanically. The queries take the time, because Prisma's findMany with nested where and include maps onto Drizzle's relational API differently enough that you rewrite rather than rename. The relational API covers most Prisma patterns; the ones it does not are usually simpler as raw SQL anyway.
Do I lose type safety when I write raw SQL?
No, and you do not lose injection safety either. The sql tagged template parameterizes every interpolated value rather than concatenating it into the string, so a user-supplied value cannot alter the query structure. You can annotate the expected result type, which means the escape hatch behaves like the rest of the library instead of dropping you back to untyped rows.
When should I stay on Prisma?
When your queries involve deeply nested conditions across several relations, because Drizzle's relational query API does not match Prisma's where nesting there yet. Also when the team's mental model comes from Sequelize or TypeORM, since Prisma's API is closer to those patterns and familiarity is a real cost. For a fresh TypeScript project in 2026, particularly one that might deploy to the edge, Drizzle is the better default.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored