Pick up Drizzle for the first time after a few years of Prisma and the surprise is how little is hidden. There is no separate schema language. There is no generated client. Your queries look like SQL written in TypeScript. The library is roughly a thin layer that turns typed builder calls into prepared statements and gives you back rows with the right shape.

That sounds underwhelming, and that is the point. The teams I have seen migrate to Drizzle did it because they were tired of the Prisma engine being in their stack trace, tired of guessing what the engine was doing, or trying to deploy to an edge runtime where a Rust binary was not welcome.

Drizzle is not "Prisma but lighter." It is a different bet about what an ORM should give you in 2025: types and migrations, yes; opinions and abstraction, no.

Schema Lives In Your TypeScript

The schema is plain TypeScript files. No DSL, no generation step you have to remember.

TypeScript
// schema.ts
import { pgTable, text, timestamp, uuid, integer } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: text('email').notNull().unique(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export const posts = pgTable('posts', {
  id: uuid('id').primaryKey().defaultRandom(),
  authorId: uuid('author_id').notNull().references(() => users.id),
  title: text('title').notNull(),
  views: integer('views').default(0).notNull(),
});

Drizzle infers TypeScript types from those table definitions — typeof users.$inferSelect is the row type, typeof users.$inferInsert is the insert shape. You never run a generate step to keep your client in sync with your schema, because the schema is the source of types.

Because schema is code, you can compose it: a tenantScoped helper that adds tenant_id and a partial index, shared columns via spread, conditional indexes by environment. Things that are awkward in a declarative DSL are normal here.

Queries Look Like SQL Because They Are SQL

The query API is intentionally close to SQL. If you can read a query, you can read Drizzle.

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

const recent = await db
  .select({ id: users.id, email: users.email })
  .from(users)
  .where(and(eq(users.tenantId, tenantId), isNull(users.deletedAt)))
  .orderBy(desc(users.createdAt))
  .limit(50);

The shape of the returned object is inferred from the select. If you add name: users.name, the type updates without you doing anything. If you misspell a column or compare a UUID to an integer, it is a compile error.

Joins are explicit, exactly like SQL:

TypeScript
const rows = await db
  .select({
    postId: posts.id,
    title: posts.title,
    authorEmail: users.email,
  })
  .from(posts)
  .innerJoin(users, eq(users.id, posts.authorId))
  .where(eq(posts.published, true));

This is good and bad. Good: there is no hidden N+1, because there is no relation magic. Bad: you write the joins yourself, every time, until you reach for the relations API.

The Relations API For Nested Reads

When you do want Prisma-style nested reads, Drizzle has the relations() helper and a separate db.query API:

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

const withPosts = await db.query.users.findMany({
  with: { posts: { columns: { id: true, title: true } } },
  where: eq(users.tenantId, tenantId),
});

Drizzle generates a single SQL query for this — typically using a LEFT JOIN LATERAL with json_agg on Postgres so the nested data comes back already grouped. That is a different shape from what Prisma does (two batched queries), and it shows when you look at your query logs.

Use the relations API for reads that match a nested shape. Use the SQL-shaped builder for everything else.

Diagram of a Drizzle workflow: schema.ts as the single source of truth, drizzle-kit generating SQL migrations into a migrations/ folder, the type-safe builder issuing parameterized SQL to Postgres, and an edge-runtime arrow showing the same code running on Node and the edge with different drivers.
schema.ts → drizzle-kit → migrations → driver — the whole loop fits in your head

Migrations With drizzle-kit

drizzle-kit is the CLI that diffs your schema.ts against the database and generates SQL migrations.

Bash
# Generate a new migration from schema changes
npx drizzle-kit generate

# Apply pending migrations (you can also run them from code)
npx drizzle-kit migrate

The generated files are plain SQL with an up/down style structure stored as numbered .sql files plus a JSON metadata snapshot. You commit them, you read them, you can edit them. There is no opaque artifact in the loop.

For programmatic migration on app startup, Drizzle exposes a migrate() helper:

TypeScript
import { migrate } from 'drizzle-orm/node-postgres/migrator';
await migrate(db, { migrationsFolder: './drizzle' });

A small but real win: because migrations are plain SQL, they review well in pull requests. Reviewers can spot a DROP COLUMN that nobody intended without running anything.

The Edge Runtime Story

Drizzle was built with multiple runtimes in mind. The same query code runs against:

  • pg / node-postgres for classic Node.
  • postgres (the postgres.js library) for a smaller, faster Node client.
  • @neondatabase/serverless for HTTP-over-WebSocket from edge functions.
  • @vercel/postgres, Cloudflare D1, libSQL, PlanetScale's MySQL HTTP API.

You change the driver import. Your queries do not move. That is the practical advantage when you deploy to Vercel Edge or Cloudflare Workers — there is no Rust binary to ship, no engine to start, no cold-start tax on the first query.

TypeScript
// Node
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
export const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }));

// Cloudflare Workers / Neon HTTP
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
export const db = drizzle(neon(process.env.DATABASE_URL!));

Where Prisma Is Still The Better Fit

Drizzle is honest about being closer to a query builder than a classic ORM. That means:

  • No prisma studio equivalent that ships in the box. Drizzle Studio exists and works well, but the polish is younger.
  • Schema migrations from a non-empty database are less guided. Prisma's introspection of an existing database is more turnkey.
  • Less hand-holding for newcomers who do not know SQL. That is by design, but if your team is full-stack JavaScript engineers without database depth, Prisma's safety net is real.

If your team writes SQL fluently or you ship to the edge, Drizzle pays. If your team wants to think in models and let the ORM handle the SQL, Prisma is still a great choice.

A One-Sentence Mental Model

Drizzle is a typed SQL builder with a migration tool — you keep the database visible in your code, and the abstraction never gets between you and a query plan.