Drizzle ORM has become the right TypeScript ORM for new backend projects in 2026. Lightweight, schema-first, generates no binary, uses SQL-shaped queries, and the type inference is end-to-end — your IDE knows the shape of every row before you run anything.
This post is the deep dive. Schema design, queries, migrations, relations, transactions, JSON, and the production patterns.
Why Drizzle won
The TypeScript ORM market through 2024 was a Prisma monopoly with Sequelize and TypeORM trailing. Drizzle’s bet — “thin SQL builder + first-class TypeScript inference, no runtime engine” — paid off. By 2026:
- Drizzle ships zero binaries; it’s pure TypeScript.
- It’s drop-in for any pg, mysql, sqlite, or libSQL driver.
- The query syntax is SQL-shaped, not method-chained — the IDE autocompletes columns from your schema.
- Migrations are generated from schema diffs, just like Atlas / sqlx.
- It runs on Bun, Node, Deno, Workers, and edge runtimes equivalently.
For a new TypeScript backend, my default stack is Bun + Hono + Drizzle. See Modern TypeScript Backend with Hono on Bun for the surrounding shape.
Schema first
A Drizzle schema is plain TypeScript:
// db/schema.ts
import { pgTable, serial, text, integer, boolean, timestamp, jsonb, uniqueIndex, index } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
export const users = pgTable(
"users",
{
id: serial("id").primaryKey(),
email: text("email").notNull(),
fullName: text("full_name").notNull(),
isActive: boolean("is_active").notNull().default(true),
metadata: jsonb("metadata").$type<{ source?: string; locale?: string }>().notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
emailIdx: uniqueIndex("users_email_idx").on(t.email),
activeIdx: index("users_active_idx").on(t.isActive).where(eq(t.isActive, true)),
})
);
export const posts = pgTable(
"posts",
{
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull(),
body: text("body").notNull(),
publishedAt: timestamp("published_at", { withTimezone: true }),
},
(t) => ({
userIdx: index("posts_user_idx").on(t.userId),
})
);
// Type derivation — this is the killer feature
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;
What you get for free:
UserandNewUsertypes match the schema exactly. Add a column → types update.metadatais typed as{ source?: string; locale?: string }thanks to$type<...>(). JSON columns aren’tanyanymore.- Partial index on
isActive— small, fast, only indexes the rows that matter. - Cascade delete declared in the schema; drizzle-kit generates the right SQL.
Relations
Two ways to relate. Use both.
Foreign keys (storage layer)
posts: { userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }) }
Defines the FK in SQL.
Relations API (query layer)
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, { fields: [posts.userId], references: [users.id] }),
}));
Now you can query graph-shaped:
const user = await db.query.users.findFirst({
where: eq(users.id, 42),
with: {
posts: {
where: isNotNull(posts.publishedAt),
orderBy: desc(posts.publishedAt),
limit: 10,
},
},
});
// user is fully typed:
// { id: number; email: string; ...; posts: Array<{ id: number; title: string; ... }> }
Drizzle’s relational query builder is the joinful API. The IDE infers shape from your with clauses.
Two query styles
Drizzle gives you both query builder (low-level SQL) and relational query (high-level object graph). Use whichever fits.
Query builder
import { db } from "./db";
import { users, posts } from "./schema";
import { eq, and, desc, ilike, sql } from "drizzle-orm";
const list = await db
.select({
id: users.id,
email: users.email,
postCount: sql<number>`count(${posts.id})::int`,
})
.from(users)
.leftJoin(posts, eq(posts.userId, users.id))
.where(and(eq(users.isActive, true), ilike(users.email, "%@example.com")))
.groupBy(users.id)
.orderBy(desc(sql`count(${posts.id})`))
.limit(10);
Reads almost like SQL. Every column reference is type-checked. sql<number> lets you escape to raw SQL when needed and types it.
Relational query
const list = await db.query.users.findMany({
where: eq(users.isActive, true),
with: { posts: true },
limit: 10,
});
Cleaner for object-graph reads. Behind the scenes, Drizzle generates a single optimized SQL with joins or sub-selects.
Rule of thumb: relational query for reads that walk the graph; query builder for aggregations, complex filters, or anything that’s “really a SQL query.”
Inserts, updates, deletes
// Insert one
const [user] = await db.insert(users).values({ email: "[email protected]", fullName: "A B" }).returning();
// Insert many
await db.insert(users).values([
{ email: "[email protected]", fullName: "X" },
{ email: "[email protected]", fullName: "Y" },
]);
// Upsert
await db
.insert(users)
.values({ email: "[email protected]", fullName: "A B" })
.onConflictDoUpdate({ target: users.email, set: { fullName: "A B" } });
// Update
const [updated] = await db
.update(users)
.set({ isActive: false })
.where(eq(users.id, 42))
.returning({ id: users.id });
// Delete
await db.delete(users).where(eq(users.id, 42));
Note .returning() — Postgres-style. Get the affected row(s) without a separate SELECT.
Transactions
await db.transaction(async (tx) => {
const [user] = await tx.insert(users).values({ ... }).returning();
await tx.insert(posts).values({ userId: user.id, title: "...", body: "..." });
});
The transaction is a context — every tx.insert / tx.update is on the same DB connection. Throw an error and the transaction rolls back. Nest them for savepoints if your driver supports it.
Migrations with drizzle-kit
bun add -D drizzle-kit
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./db/schema.ts",
out: "./db/migrations",
dialect: "postgresql",
dbCredentials: { url: process.env.DATABASE_URL! },
});
# Generate a migration from the diff between schema.ts and current DB
bun drizzle-kit generate
# Apply pending migrations
bun drizzle-kit migrate
# Or push (no migration files; for prototyping only)
bun drizzle-kit push
The output is a versioned SQL file:
-- 0003_add_users_metadata.sql
ALTER TABLE "users" ADD COLUMN "metadata" jsonb NOT NULL DEFAULT '{}';
Commit these files. Code review them. Apply in CI. Standard discipline.
drizzle-kit studio opens a local web UI for exploring the DB. Cheap admin panel.
JSON columns done right
metadata: jsonb("metadata").$type<{ source?: string; locale?: string }>().notNull().default({})
// Reading
const user = await db.query.users.findFirst({ where: eq(users.id, 42) });
user.metadata.source // typed: string | undefined
// Filtering by JSON path (Postgres)
import { sql } from "drizzle-orm";
const us = await db.query.users.findMany({
where: sql`${users.metadata}->>'locale' = 'en-US'`,
});
// Updating a single JSON key
await db.update(users)
.set({ metadata: sql`${users.metadata} || ${JSON.stringify({ source: "newsletter" })}::jsonb` })
.where(eq(users.id, 42));
Pair with the indexing strategies in PostgreSQL JSONB Advanced Patterns .
Vector columns
For the AI workload — Drizzle has vector support for pgvector:
import { vector } from "drizzle-orm/pg-core";
export const chunks = pgTable("chunks", {
id: serial("id").primaryKey(),
content: text("content").notNull(),
embedding: vector("embedding", { dimensions: 1536 }).notNull(),
});
// Cosine similarity search
import { cosineDistance, sql } from "drizzle-orm";
const similar = await db
.select({ id: chunks.id, content: chunks.content, distance: cosineDistance(chunks.embedding, queryEmbedding) })
.from(chunks)
.orderBy(cosineDistance(chunks.embedding, queryEmbedding))
.limit(8);
For tuning the index, see pgvector Deep Dive .
Connection pooling
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
const sql = postgres(env.DATABASE_URL, { max: 10 });
export const db = drizzle(sql);
postgres-js has a built-in pool. max: 10 is a reasonable default per process. For serverless / edge:
// Cloudflare Workers / D1
import { drizzle } from "drizzle-orm/d1";
const db = drizzle(env.DB);
// Turso embedded replica
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
const client = createClient({ url: "file:replica.db", syncUrl: env.TURSO_URL, authToken: env.TURSO_AUTH });
const db = drizzle(client);
Same query API across drivers. Truly portable code.
A real router
Putting it together with Hono:
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db } from "./db";
import { users } from "./db/schema";
const app = new Hono();
const Create = z.object({ email: z.string().email(), fullName: z.string().min(1) });
app.post("/users", zValidator("json", Create), async (c) => {
const payload = c.req.valid("json");
try {
const [user] = await db.insert(users).values(payload).returning();
return c.json(user, 201);
} catch (err: any) {
if (err.code === "23505") return c.json({ error: "conflict", message: "email taken" }, 409);
throw err;
}
});
app.get("/users/:id", async (c) => {
const id = Number(c.req.param("id"));
const user = await db.query.users.findFirst({
where: eq(users.id, id),
with: { posts: { limit: 10 } },
});
if (!user) return c.json({ error: "not_found" }, 404);
return c.json(user);
});
Type inference end-to-end: payload typed by Zod, user typed by Drizzle, JSON response typed for the client.
Drizzle vs Prisma in 2026
| Drizzle | Prisma | |
|---|---|---|
| Runtime | TS only | Rust query engine binary |
| Schema | TS file | .prisma DSL |
| Query style | SQL-shaped + relational | Method-chaining |
| Migration | drizzle-kit (SQL files) | prisma migrate |
| Edge runtime | First-class | Limited (data proxy or accelerate) |
| Bundle size | Small | Larger (Rust binary) |
| Type inference depth | Excellent | Excellent |
| Maturity | High | Higher (longer history) |
| Studio / admin UI | drizzle-kit studio | Prisma Studio (richer) |
For Workers, edge, Bun, or anywhere you care about runtime size, Drizzle wins. For teams already on Prisma with no specific edge requirement, stay.
Common mistakes
1. Not declaring $type<...>() on JSON
metadata: jsonb("metadata").notNull() // ❌ typed as Record<string, unknown>
metadata: jsonb("metadata").$type<{ ... }>().notNull() // ✅ typed
You lose your best type-checker leverage if you skip this.
2. Forgetting .returning()
insert/update/delete return { rowCount: number } by default. .returning() gets you the affected rows. 90% of the time you want them.
3. Mixing query builder and relational query expectations
db.query.users.findMany({ where: eq(users.isActive, true) }) returns objects. db.select(...).from(users) returns the columns you selected. Don’t expect a query-builder result to have relations included unless you joined them.
4. Cross-driver sql quirks
Postgres jsonb operators don’t exist in SQLite. sql template literals are powerful but can break portability. Centralize raw SQL into a helper module.
5. Migration drift
drizzle-kit push is fine for prototyping but skips the migration file. In production, always generate → review → migrate. Otherwise, your DB and your team’s checked-in schema diverge.
What I’d skip on day one
- Custom column types. Use the built-ins until you outgrow them.
- Schema-per-tenant in code. Use the schema feature only when truly multi-tenant; until then, a
tenant_idcolumn is simpler. - Generated columns unless you have the access pattern proven.
Read this next
- Modern TypeScript Backend with Hono on Bun — the surrounding stack.
- Bun vs Node.js in 2026 — runtime context.
- PostgreSQL Indexing and EXPLAIN — the indexing under your queries.
- SQLite at the Edge in 2026 — Drizzle pairs perfectly with Turso/libSQL.
If you want a starter Hono + Drizzle + Postgres + Zod + Docker template, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .