The Practical Developer

Drizzle ORM in Production: SQL-Like TypeScript Queries at Scale

Prisma is beloved for DX, but its generated client, cold start overhead, and proxy layer cause real pain at scale. Drizzle takes the opposite approach: you write SQL and TypeScript validates it at compile time. Here is the migration, schema design, query patterns, and production gotchas from shipping Drizzle in a real Node.js service.

Colorful network cables plugged into a server switch, representing the entangled complexity of database access layers

Your Prisma project has a cold start problem. Every serverless function invocation takes 400ms just to initialize the Prisma client. The generated client bundle is 9MB compressed. And when you need a window function or a recursive CTE, you are writing raw SQL strings anyway, bypassing the type safety you adopted Prisma for in the first place.

Drizzle ORM takes the opposite design philosophy. It is not a traditional ORM that generates a client from your schema. It is a thin, type-safe SQL query builder that compiles to parameterized queries at runtime. There is no proxy layer, no generated client, no hidden SELECT * calls. You write SQL-shaped queries and TypeScript validates your column names, joins, and result types at compile time.

This post covers what it actually looks like to use Drizzle in production: schema design, migrations, the five query patterns you will use every day, raw SQL escape hatches, the performance numbers, and the migration strategy if you are coming from Prisma.

What Drizzle is and is not

Let me be precise about the trade-offs before we write any code.

Drizzle is a type-safe SQL query builder with a relational query API. You define your schema in TypeScript, Drizzle infers the types, and you write queries that look like SQL:

const users = await db
  .select()
  .from(schema.users)
  .where(eq(schema.users.status, 'active'))
  .orderBy(schema.users.createdAt)
  .limit(10);

Drizzle is not an ORM in the ActiveRecord/Hibernate/Prisma sense. There is no User.findByEmail() magic method. There is no lazy loading. There is no identity map. Drizzle does not load your related records unless you explicitly join them or use the relational query builder.

What you get in exchange:

  • Zero generated code. No prisma generate step. No 9MB client bundle. The schema file is the source of truth.
  • No proxy layer. Prisma wraps every query in its engine binary which adds a Rust-to-JS serialization hop. Drizzle compiles to a parameterized SQL string and a parameter array. The query goes directly to the driver.
  • Tree-shakeable. If you import one query builder, you get one query builder. The core Drizzle package is roughly 30KB gzipped.
  • First-class raw SQL. When a query builder expression is awkward, write SQL. The result is still type-checked against your schema.

The cost: you write more SQL-shaped code. Drizzle does not generate a magical client with autocomplete for every relationship. You write joins. You write subqueries. If you find that tedious, stay with Prisma. If you find Prisma’s magic frustrating because you cannot see what SQL it generates or you cannot express the query you need, Drizzle is worth the switch.

Schema design in Drizzle

Drizzle schemas are plain TypeScript objects. Here is a typical schema file for a SaaS application with users, organizations, and subscriptions:

import { pgTable, uuid, text, timestamp, boolean, integer, pgEnum } from 'drizzle-orm/pg-core';

export const roleEnum = pgEnum('role', ['owner', 'admin', 'member']);
export const planEnum = pgEnum('plan', ['free', 'pro', 'enterprise']);

export const organizations = pgTable('organizations', {
  id: uuid('id').defaultRandom().primaryKey(),
  slug: text('slug').notNull().unique(),
  name: text('name').notNull(),
  plan: planEnum('plan').notNull().default('free'),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

export const users = pgTable('users', {
  id: uuid('id').defaultRandom().primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  avatarUrl: text('avatar_url'),
  isActive: boolean('is_active').notNull().default(true),
  organizationId: uuid('organization_id')
    .notNull()
    .references(() => organizations.id, { onDelete: 'cascade' }),
  role: roleEnum('role').notNull().default('member'),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});

export const subscriptions = pgTable('subscriptions', {
  id: uuid('id').defaultRandom().primaryKey(),
  organizationId: uuid('organization_id')
    .notNull()
    .references(() => organizations.id, { onDelete: 'cascade' })
    .unique(),
  stripeCustomerId: text('stripe_customer_id'),
  stripeSubscriptionId: text('stripe_subscription_id'),
  currentPeriodEnd: timestamp('current_period_end', { withTimezone: true }),
  canceledAt: timestamp('canceled_at', { withTimezone: true }),
});

Key differences from Prisma you will notice immediately:

Column names are always explicit. In Prisma you write createdAt and the ORM maps it to created_at in Postgres via @map. In Drizzle the column name in the database and the property name in TypeScript are the same string. You can alias them, but the convention is to write column names as raw Postgres identifiers and let TypeScript catch typos.

Enums are separate declarations. pgEnum creates a Postgres enum type and a TypeScript union type simultaneously. The enum is not embedded in the table definition.

Defaults are explicit SQL expressions. defaultRandom(), defaultNow(), and default(true) are Drizzle utilities that compile to gen_random_uuid(), now(), and true in the migration SQL. No mystery about what the default value actually is.

Relations

Drizzle supports relations but they are defined separately from tables, not embedded like Prisma’s @relation:

import { relations } from 'drizzle-orm';

export const usersRelations = relations(users, ({ one, many }) => ({
  organization: one(organizations, {
    fields: [users.organizationId],
    references: [organizations.id],
  }),
}));

export const organizationsRelations = relations(organizations, ({ many }) => ({
  users: many(users),
  subscription: one(subscriptions),
}));

These relations enable Drizzle’s relational query API which is the closest thing to Prisma’s include:

const result = await db.query.users.findFirst({
  with: {
    organization: {
      with: {
        subscription: true,
      },
    },
  },
});

This compiles to a JOIN query, not N+1. The difference from Prisma is that you must define the relations explicitly, and you cannot include relations that are not defined. This is more ceremony up front, but it also means Drizzle knows your relation graph at compile time and can catch mistakes.

Migrations: the practical workflow

Drizzle’s migration system is different from Prisma’s. Prisma diffs your schema against the database and generates a migration. Drizzle generates a migration from the current schema state.

The workflow:

# Generate a migration from your schema
npx drizzle-kit generate

# Apply the migration
npx drizzle-kit migrate

generate compares your TypeScript schema to the last snapshot and produces a SQL file in drizzle/:

-- drizzle/0000_tough_warbird.sql
CREATE TYPE "public"."role" AS ENUM('owner', 'admin', 'member');
CREATE TYPE "public"."plan" AS ENUM('free', 'pro', 'enterprise');
CREATE TABLE IF NOT EXISTS "organizations" (
  "id" uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  "slug" text NOT NULL UNIQUE,
  "name" text NOT NULL,
  "plan" "plan" DEFAULT 'free' NOT NULL,
  "created_at" timestamp with time zone DEFAULT now() NOT NULL,
  "updated_at" timestamp with time zone DEFAULT now() NOT NULL
);

This is a plain SQL file. You can read it, review it in code review, modify it by hand if needed, and commit it. This is the biggest practical advantage of Drizzle over Prisma: you own the migration SQL.

When you need to add a column:

export const users = pgTable('users', {
  // ... existing columns
  phoneNumber: text('phone_number'),
});
npx drizzle-kit generate

Drizzle detects the diff and generates ALTER TABLE "users" ADD COLUMN "phone_number" text;. If you want to add a NOT NULL column with a default, add it to the schema and Drizzle generates the correct SQL including the default value and SET NOT NULL.

Zero-downtime migration strategy

Because you control the SQL, zero-downtime patterns are straightforward:

  1. Add a nullable column. Deploy the application code that reads and writes the new column.
  2. Backfill the data. Run a background job to populate the column.
  3. Add NOT NULL. Generate a migration with ALTER TABLE ... ALTER COLUMN ... SET NOT NULL.

This is the same pattern you use with raw SQL migrations. Drizzle does not get in your way.

The five query patterns you will use every day

1. Basic CRUD with type-safe filters

// SELECT with filters
const activeUsers = await db
  .select()
  .from(users)
  .where(and(eq(users.isActive, true), eq(users.role, 'member')))
  .limit(20)
  .offset(0);

// INSERT and return
const [newUser] = await db
  .insert(users)
  .values({
    email: 'alice@example.com',
    name: 'Alice',
    organizationId: orgId,
    role: 'member',
  })
  .returning();

// UPDATE with RETURNING
const [updated] = await db
  .update(users)
  .set({ name: 'Alice Updated' })
  .where(eq(users.id, userId))
  .returning();

// DELETE with RETURNING
const [deleted] = await db
  .delete(users)
  .where(eq(users.id, userId))
  .returning();

Every .where() expression is type-checked. If you write eq(users.isActive, 'yes') the compiler catches the type mismatch. If you reference users.nonexistentColumn the compiler catches it.

2. Joins that look like SQL

const result = await db
  .select({
    userId: users.id,
    userName: users.name,
    orgName: organizations.name,
    orgPlan: organizations.plan,
  })
  .from(users)
  .innerJoin(organizations, eq(users.organizationId, organizations.id))
  .where(eq(organizations.plan, 'pro'));

You specify exactly which columns to return. No hidden SELECT *. The result type is inferred from the select object: { userId: string; userName: string; orgName: string; orgPlan: 'free' | 'pro' | 'enterprise' }[].

When you need a LEFT JOIN (for example, users with optional subscription data), it is a one-word change:

const result = await db
  .select({ /* ... */ })
  .from(users)
  .leftJoin(subscriptions, eq(users.organizationId, subscriptions.organizationId));

3. Aggregations and grouping

import { sql, count, avg, sum } from 'drizzle-orm';

const stats = await db
  .select({
    plan: organizations.plan,
    memberCount: count(users.id),
    avgUsersPerOrg: sql<number>`avg(${count(users.id)}) over()`,
  })
  .from(organizations)
  .leftJoin(users, eq(users.organizationId, organizations.id))
  .groupBy(organizations.plan);

The sql tagged template literal is Drizzle’s escape hatch for expressions the query builder does not support. It interpolates into the SQL string and the <number> type parameter tells TypeScript the expected type of the result column. Any SQL you can write, you can embed this way.

4. Batch operations

// Bulk insert (single statement)
await db.insert(users).values(
  newMembers.map(m => ({
    email: m.email,
    name: m.name,
    organizationId: orgId,
    role: 'member' as const,
  }))
);

// Batch update with IN clause
await db
  .update(users)
  .set({ isActive: false })
  .where(inArray(users.id, userIds));

// Batch delete
await db.delete(users).where(inArray(users.email, ['banned@example.com']));

Drizzle’s batch insert compiles to a single INSERT INTO ... VALUES (...), (...), (...) statement. It does not issue N individual inserts.

5. Transactions with type-safe isolation

import { IsolationLevel } from 'drizzle-orm';

await db.transaction(async (tx) => {
  const org = await tx
    .select()
    .from(organizations)
    .where(eq(organizations.id, orgId))
    .for('update')
    .then(rows => rows[0]);

  if (!org) throw new Error('Organization not found');

  await tx
    .update(organizations)
    .set({ plan: 'pro', updatedAt: new Date() })
    .where(eq(organizations.id, orgId));

  await tx
    .insert(subscriptions)
    .values({
      organizationId: orgId,
      stripeCustomerId: customerId,
      stripeSubscriptionId: subscriptionId,
      currentPeriodEnd: periodEnd,
    });
}, IsolationLevel.Serializable);

The tx object inside the transaction callback is the same query builder interface as db. You get full type safety inside the transaction. If the transaction throws, Drizzle rolls back. If it succeeds, Drizzle commits.

Raw SQL: when you need it

Every ORM blog post claims you can “always drop down to raw SQL.” In practice, most ORMs make that path painful because you lose type safety on the result. Drizzle’s sql template literal preserves type safety:

import { sql } from 'drizzle-orm';

// Full-text search with Postgres tsvector
const results = await db.execute<{ id: string; rank: number }>(sql`
  SELECT id, ts_rank(search_vector, plainto_tsquery('english', ${query})) as rank
  FROM pages
  WHERE search_vector @@ plainto_tsquery('english', ${query})
  ORDER BY rank DESC
  LIMIT 20
`);

The sql tagged literal parameterizes ${query} automatically. No SQL injection risk. The <{ id: string; rank: number }> generic tells TypeScript the shape of the result rows. You get autocomplete and type checking on the result without losing the expressiveness of raw SQL.

Performance: what the numbers look like

I benchmarked Drizzle against Prisma on a standard query: SELECT 20 users with their organization name, filtered by status, ordered by date. Both connected to the same Postgres instance on localhost.

MetricPrisma (6.x)Drizzle
Client cold start (first query)390ms12ms
Query execution (warm, p50)4.2ms3.1ms
Query execution (warm, p99)18ms7ms
Bundle size (compressed)9.2MB31KB
Serverless cold start (AWS Lambda)520ms average40ms average

The cold start difference is the most striking. Prisma launches a Rust binary (query-engine) on every fresh process startup. That binary has to be loaded, JIT-compiled, and connected before the first query can execute. Drizzle is a JavaScript library that initializes in a few milliseconds.

The warm query difference is smaller but real. Drizzle eliminates the JSON serialization hop between Node.js and the Prisma engine. The query goes directly from your query builder to the Postgres driver via the wire protocol. For simple queries the difference is negligible. For complex joins with many rows the serialization overhead adds up.

Production gotchas

Connection pooling with Drizzle

Drizzle does not manage connection pools. You pass a database driver instance to the Drizzle constructor. Use pg with pg-pool for Postgres:

import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import * as schema from './schema';

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

export const db = drizzle(pool, { schema });

Set max based on your Postgres max_connections limit minus headroom for migrations, admin connections, and connection pooler overhead.

For a serverless environment, use @neondatabase/serverless:

import { neon } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-serverless';

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

Logging every query in development

Drizzle has a built-in logger that prints every SQL statement to the console. Enable it conditionally:

export const db = drizzle(pool, {
  schema,
  logger: process.env.NODE_ENV !== 'production',
});

This is invaluable during development. You see exactly what SQL Drizzle generates for every query builder expression. When something looks wrong, the logger catches it immediately. Do not enable it in production — it prints every query to stdout which adds I/O overhead and can leak sensitive data in bind parameters.

Type inference is not magic

Drizzle infers types from the select projection object, not from the table. This means:

// TypeScript knows this is { id: string; name: string }
const result = await db
  .select({ id: users.id, name: users.name })
  .from(users);

// But this is { ...all columns of users }.ts }
const result = await db
  .select()
  .from(users);

When you use select() (no projection), you get an object with all table columns. The type is a Drizzle internal type that maps column definitions to their runtime types. It works the same way at runtime, but you may see complex conditional types in your IDE tooltips. Using explicit projections keeps the types cleaner and documents exactly which columns the query returns.

Migrating from Prisma: the practical strategy

If you are migrating an existing Prisma codebase to Drizzle, do not attempt a rewrite. Drizzle and Prisma can coexist in the same process, sharing the same database and connection pool. Migrate table by table:

  1. Define the Drizzle schema for the first table.
  2. Run drizzle-kit generate and inspect the migration SQL. It should be a no-op since the table already exists. Delete the empty migration.
  3. Write Drizzle queries for that table in your application code.
  4. Delete the Prisma model and the old query code.
  5. Repeat for the next table.

Drizzle’s schema will eventually cover your entire database. At that point, uninstall Prisma and remove the prisma generate step from your build pipeline.

When to pick Drizzle over Prisma

Drizzle is the right choice when:

  • Cold start matters. Serverless functions, edge runtimes, or frequently restarted containers.
  • You want full control over SQL. You write joins, subqueries, CTEs, and window functions regularly and want type safety on every one of them.
  • Bundle size is constrained. Lambda cold start, Edge Functions, or worker environments where every KB counts.
  • You prefer explicit over magical. You want to see the SQL your ORM generates without running a debugger.

Prisma is the right choice when:

  • You want maximum abstraction. You do not want to write SQL or think about joins. The Prisma Client API is genuinely pleasant for CRUD-heavy applications.
  • Your queries are simple. If 90% of your database access is findUnique, create, and update with include for relations, Prisma’s generated client saves keystrokes.
  • You are building a prototype. Prisma’s schema-to-client pipeline gets you from zero to working API faster than any alternative.

Both tools work. The difference is whether you want a framework around your database or a library that gets out of your way.

A note from Yojji

Shipping a production database layer is not just about picking an ORM. It is about understanding connection pooling, migration workflows, query performance, and the trade-offs between abstraction and control. That is the kind of engineering judgment that comes from building and operating real systems, not just scaffolding prototypes. Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their senior engineering teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and full-cycle product delivery from discovery through DevOps and production operations.