Drizzle ORM has emerged as the TypeScript-first database toolkit developers reach for when Prisma feels too heavy and raw SQL feels too risky. With roughly 900,000 weekly npm downloads and a lightweight bundle that runs on Cloudflare Workers, Vercel Edge, Bun, and Deno, Drizzle ORM has become the default choice for serverless and edge workloads that cannot afford the cold-start tax of a generated client.
This Drizzle ORM tutorial walks through 13 production-grade steps to build a type-safe REST API on top of PostgreSQL. By the end, you will have a working Node.js project with a typed schema, generated SQL migrations, a query layer that infers return types automatically, runtime validation through drizzle-zod, and a deployable API that survives both edge runtimes and traditional Node servers.
Every command in this guide has been executed against Drizzle ORM 0.44.x with drizzle-kit on Node.js 22 LTS and PostgreSQL 17. Where a feature only ships in the 1.0 beta line, that is called out explicitly so you can decide whether to opt in. The complete project is provided at the end as a single repository you can clone, modify, and ship.
Why Drizzle ORM in 2026: TypeScript-First, Edge-Ready, Zero Codegen
Drizzle ORM differs from Prisma, TypeORM, and Sequelize in one fundamental way: it is a thin layer over SQL that infers types from your schema definitions at compile time, with no separate code generation step required for the runtime client. The schema you write in TypeScript is the schema the database sees, and the queries you write read like SQL with full IntelliSense. There is no prisma generate command in your CI pipeline, no generated node_modules/.prisma/client directory, and no proprietary query language to learn.
The shape of the developer experience matters because Drizzle is now used in production at companies running on Neon, Supabase, Vercel Postgres, Turso, Cloudflare D1, and PlanetScale. Drizzle ORM ships with first-class support for PostgreSQL, MySQL, and SQLite, plus a 1.0 beta dialect for Microsoft SQL Server. The query builder API is intentionally close to SQL so that anyone who has written a JOIN can read a Drizzle query without consulting documentation.
The performance argument is equally compelling. Because Drizzle ORM does not parse a custom query DSL at runtime and does not maintain a separate query engine binary, query latency is dominated by the network round-trip rather than by the ORM itself. On Cloudflare Workers and Vercel Edge, where Prisma historically required either a Data Proxy or the Accelerate service, Drizzle runs natively over HTTP-based drivers like @neondatabase/serverless and Cloudflare D1’s batch API.
Type safety remains the headline feature. When you select a subset of columns, the inferred return type narrows automatically. When you join two tables, the result is a typed object whose keys are the table names. When you call .findMany({ with: { posts: true } }) in the relational query API, the response includes the related records without any manual cast. The compiler will catch a typo in a column name before the SQL ever reaches the database.
Prerequisites: Versions, Tools, and Database Setup
Before writing any code, confirm that your local environment matches the versions used in this tutorial. Mismatched runtimes are the single most common reason a Drizzle ORM tutorial fails to compile, especially around the satisfies operator and TypeScript’s NodeNext module resolution.
| Tool | Minimum Version | Recommended | Why It Matters |
|---|---|---|---|
| Node.js | 20.x LTS | 22.x LTS | Native fetch, stable test runner, ESM stability |
| TypeScript | 5.0 | 5.6+ | Required for satisfies and improved inference |
| PostgreSQL | 14 | 17 | Generated columns, JSON_TABLE, partition pruning |
| drizzle-orm | 0.40 | 0.44.x stable | Latest stable on the 0.x line as of April 2026 |
| drizzle-kit | 0.30 | latest | CLI for migrations, push, studio |
| postgres (driver) | 3.4 | 3.4.x | The recommended Postgres driver for Drizzle |
| tsx | 4.x | 4.x | Run TypeScript scripts without a build step |
For the database, you have three viable options for a Drizzle ORM tutorial. The simplest is Docker: run docker run --name drizzle-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:17 and you have a fresh PostgreSQL 17 instance bound to localhost. The second option is a managed serverless database such as Neon, which gives you a free-tier branchable Postgres with an HTTP driver suited for edge runtimes. The third option is a local installation through Homebrew, apt, or the official PostgreSQL installer.
You will also need a code editor with TypeScript support. Visual Studio Code, Cursor, and Zed all provide the IntelliSense Drizzle needs to demonstrate its type inference. Without a TypeScript-aware editor, you lose roughly half the value of using Drizzle ORM in the first place, because the typed query builder is precisely the feature that justifies the dependency.
Step 1: Initialize a TypeScript Node.js Project
Create the project directory and initialize a Node.js package. The --type=module flag tells Node to treat .js files as ECMAScript modules, which aligns with how modern TypeScript output is consumed.
mkdir drizzle-api && cd drizzle-api
npm init -y
npm pkg set type="module"
npm pkg set engines.node=">=20.0.0"
npm install --save-dev typescript tsx @types/node
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext
--outDir dist --rootDir src --strict --esModuleInterop
--skipLibCheck --forceConsistentCasingInFileNames
Open the generated tsconfig.json and verify three flags: "strict": true, "moduleResolution": "NodeNext", and "target": "ES2022". Drizzle’s type inference relies on strict mode to produce correctly nullable column types, and any of the three knobs being wrong will produce confusing inference failures later.
Create the source directory structure: mkdir -p src/db src/api. The src/db folder will hold the schema, client, and seed scripts. The src/api folder will hold the HTTP handlers in step 11. Keeping these concerns separate is essential because the schema must be importable by both the migration tool (which runs at build time) and the application (which runs at request time).
Add a .gitignore with node_modules, dist, and .env entries. The .env file will hold your DATABASE_URL and must never be committed. Add npm install dotenv so the migration scripts can read environment variables without a build step.
Step 2: Install Drizzle ORM and the Postgres Driver
Drizzle ORM is split into two packages. The runtime package drizzle-orm is the query builder you import in your application code. The CLI package drizzle-kit is a development dependency used to generate migrations, push schema changes, and launch Drizzle Studio. You also need a database driver because Drizzle does not bundle one.
npm install drizzle-orm postgres
npm install --save-dev drizzle-kit
npm install dotenv
# Optional: validation helpers
npm install drizzle-zod zod
The postgres package is the recommended driver for traditional Node.js servers because of its strong performance and simple connection API. If you are deploying to a serverless edge runtime, swap postgres for @neondatabase/serverless, which uses HTTP-based queries and avoids TCP socket constraints. Drizzle exposes both via dedicated entry points, so you can switch drivers without rewriting your queries.
Verify the install by checking the lockfile: npm list drizzle-orm should report a version on the 0.44.x line. If you want to opt into the 1.0 beta line for early access to features like the rewritten relational query API and consolidated drizzle-kit packaging, replace the install command with npm install drizzle-orm@beta drizzle-kit@beta. The beta line is API-compatible with 0.44 for the core query builder, but the kit migration folder structure changes.
For Bun and Deno users, Drizzle ORM is supported natively. With Bun: bun add drizzle-orm bun:sqlite if you want the bundled SQLite driver, or bun add postgres for Postgres. Deno users should import directly from npm: import { drizzle } from "npm:drizzle-orm/postgres-js". The runtime detection happens automatically and no extra configuration is needed.
Step 3: Configure drizzle.config.ts
The drizzle.config.ts file at the project root tells drizzle-kit where to find your schema, where to emit SQL migrations, which dialect to use, and how to connect to the database for push and studio operations. This single file is the source of truth for the CLI.
// drizzle.config.ts
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
The dialect field replaces the older driver field that you may see in pre-0.20 Drizzle tutorials. Acceptable values are "postgresql", "mysql", and "sqlite". Setting strict: true tells drizzle-kit to refuse destructive migrations, which is exactly the behavior you want in any environment that holds real data.
Create the corresponding .env file with a connection string. For local Postgres in Docker, the URL is DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres. For Neon or Supabase, copy the pooled connection string from the dashboard, including the ?sslmode=require suffix. Without TLS, managed providers will reject the connection.
Add four scripts to package.json to make the daily commands one keystroke each. These wrap the drizzle-kit binary and load environment variables consistently across operating systems.
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"dev": "tsx watch src/api/server.ts"
}
Step 4: Define a Type-Safe Schema in TypeScript
The schema file is where Drizzle ORM earns its keep. You declare tables, columns, indexes, enums, and relations using TypeScript-native helper functions, and Drizzle infers the types of every row, insert, update, and join from those declarations. There is no separate schema.prisma file, no GraphQL SDL, and no YAML.
// src/db/schema.ts
import {
pgTable,
serial,
text,
varchar,
timestamp,
boolean,
integer,
pgEnum,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
export const roleEnum = pgEnum("role", ["admin", "editor", "viewer"]);
export const users = pgTable(
"users",
{
id: serial("id").primaryKey(),
email: varchar("email", { length: 320 }).notNull(),
name: text("name").notNull(),
role: roleEnum("role").notNull().default("viewer"),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
emailIdx: uniqueIndex("users_email_idx").on(t.email),
roleIdx: index("users_role_idx").on(t.role),
}),
);
export const posts = pgTable("posts", {
id: serial("id").primaryKey(),
authorId: integer("author_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
title: varchar("title", { length: 200 }).notNull(),
body: text("body").notNull(),
published: boolean("published").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
Three patterns in this file deserve careful attention. First, the $inferSelect and $inferInsert helpers expose the typed row shapes for use in function signatures elsewhere in the codebase. Second, $onUpdate hooks a JavaScript callback to fire whenever an UPDATE statement touches the row, eliminating the need for a Postgres trigger. Third, the relations declaration is purely a TypeScript construct: it does not generate SQL and does not affect migrations.
The composite index callback at the end of users demonstrates the second-positional-argument pattern Drizzle uses for table-level constraints. Anything that cannot live on a single column, including unique indexes, composite primary keys, foreign keys spanning multiple columns, and check constraints, lives in this callback.
Step 5: Generate and Apply SQL Migrations
Drizzle ORM splits schema management into two phases. The generate command compares your TypeScript schema against the most recent snapshot in the drizzle/ folder and emits a SQL migration file plus an updated snapshot. The migrate command then applies pending SQL files against the database in order. This separation lets you review the generated SQL in code review before it touches production.
npm run db:generate
# Drizzle reads schema.ts, writes drizzle/0000_initial.sql
# It also writes drizzle/meta/_journal.json and a snapshot
# Inspect the generated SQL before applying
cat drizzle/0000_*.sql
The runner script in src/db/migrate.ts applies whatever SQL files are pending. It is intentionally short and lives outside your application code so that it can run as a standalone command in Docker entrypoints, GitHub Actions deploy steps, or release-phase commands on Heroku and Render.
// src/db/migrate.ts
import "dotenv/config";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL!, { max: 1 });
const db = drizzle(sql);
await migrate(db, { migrationsFolder: "./drizzle" });
await sql.end();
console.log("Migrations applied");
Run npm run db:migrate and confirm the tables exist: psql $DATABASE_URL -c "d users" should print the users table with seven columns. If you instead see “relation does not exist”, check that your DATABASE_URL points to the same database the migration ran against. This is the second-most-common Drizzle ORM tutorial bug, eclipsed only by mismatched tsconfig.json targets.
For rapid prototyping you can skip migration generation entirely and use drizzle-kit push, which diffs the schema and applies the changes to the live database in a single step. Push is safe in development and demos but should never be used in production because it bypasses the SQL review step. Reserve generate-and-migrate for staging and production environments, and reserve push for local iteration.
Step 6: Connect the Database Client
The runtime client is a single object that wraps the database driver and exposes the Drizzle query builder. Create it once at module load, share it across the request handlers, and let the underlying driver pool connections. The biggest performance mistake new Drizzle ORM users make is creating a fresh client per request, which exhausts the connection pool under any real load.
// src/db/index.ts
import "dotenv/config";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema.js";
const queryClient = postgres(process.env.DATABASE_URL!, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
export const db = drizzle(queryClient, { schema, logger: false });
export type Database = typeof db;
Passing the schema to the drizzle() factory is what enables the relational query API in step 8. Without it, you can still use the SQL-style query builder, but you lose the db.query.users.findMany({ with: { posts: true } }) convenience layer. The schema parameter has zero runtime cost; it merely teaches the client about your tables and their relations.
Toggling logger: true prints every SQL statement Drizzle executes, including parameter values. This is invaluable when you are unsure why a query is slower than expected or whether the WHERE clause you wrote really translates into the SQL you intended. Always disable logging in production because logging parameter values can leak personally identifiable information into stdout.
For edge runtimes, replace this file with a Neon HTTP variant: import { drizzle } from "drizzle-orm/neon-http" and import { neon } from "@neondatabase/serverless", then call drizzle(neon(process.env.DATABASE_URL!), { schema }). The query API is identical; only the driver changes.
Step 7: Insert Data with Full Type Inference
The first interaction with a Drizzle database is almost always an insert. Drizzle’s insert API is intentionally explicit: you call db.insert(table).values(row), optionally chain .returning() to retrieve the inserted row, and optionally chain .onConflictDoUpdate() for upserts. The values argument is typed against $inferInsert, so missing required columns produce a compile error rather than a runtime exception.
// src/db/seed.ts
import { db } from "./index.js";
import { users, posts } from "./schema.js";
const [alice] = await db
.insert(users)
.values({ email: "[email protected]", name: "Alice", role: "admin" })
.returning();
const [bob] = await db
.insert(users)
.values({ email: "[email protected]", name: "Bob" })
.returning();
await db.insert(posts).values([
{
authorId: alice.id,
title: "Drizzle ORM in Production",
body: "Edge-ready, zero codegen",
published: true,
},
{
authorId: bob.id,
title: "Why I Switched From Prisma",
body: "Bundle size mattered",
published: false,
},
]);
console.log("Seeded", { alice, bob });
process.exit(0);
Run the seed file with npx tsx src/db/seed.ts. Notice that you did not have to pass id, createdAt, or updatedAt to either insert. Drizzle infers from the schema that those columns have defaults and marks them optional in the insert type. If you remove the .default(true) from isActive in the schema, TypeScript will immediately flag the insert as missing a required field.
For idempotent seeds and upserts, chain .onConflictDoUpdate({ target: users.email, set: { name: row.name } }). The conflict target uses the column reference rather than a string, which means renaming a column in schema.ts automatically updates every conflict target referring to it. This is the single biggest argument for using a typed schema instead of writing raw SQL.
Bulk inserts pass an array to .values(). Drizzle splits the array into a single multi-row INSERT statement under the hood, so a 1,000-row seed completes in one round-trip rather than 1,000. There is no batching helper to learn; the array form just works.
Step 8: Query with Filters, Joins, and the Relational API
Drizzle ORM exposes two complementary query styles. The SQL-like builder mirrors the structure of a SELECT statement and is the right choice when you need fine control over JOIN order, indexes, or window functions. The relational query API is a higher-level convenience layer that returns nested objects without explicit joins, ideal for typical CRUD endpoints.
// SQL-like style: explicit joins
import { db } from "./db/index.js";
import { users, posts } from "./db/schema.js";
import { eq, and, desc, sql } from "drizzle-orm";
const recentPublished = await db
.select({
id: posts.id,
title: posts.title,
authorName: users.name,
createdAt: posts.createdAt,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(and(eq(posts.published, true), eq(users.isActive, true)))
.orderBy(desc(posts.createdAt))
.limit(10);
// Relational style: nested objects
const usersWithPosts = await db.query.users.findMany({
where: eq(users.isActive, true),
with: {
posts: {
where: eq(posts.published, true),
orderBy: desc(posts.createdAt),
limit: 5,
},
},
});
The first query returns an array of flat objects with exactly four keys. TypeScript knows the shape because the .select() projection is the source of truth. The second query returns an array of users where each user has a typed posts array. Drizzle generates a single SQL statement using lateral joins under the hood, not the N+1 sequence you might fear.
Filtering operators live in the drizzle-orm root export. The most common are eq, ne, gt, gte, lt, lte, like, ilike, inArray, notInArray, isNull, isNotNull, between, and the boolean combinators and, or, and not. They compose into arbitrarily nested predicates that read almost identically to the SQL they generate.
For aggregations, use the sql tagged template literal. sql<number>`count(*)`.as("count") gives you a typed expression you can include in any .select(). The angle bracket type parameter is the runtime type Drizzle should treat the column as, and the .as() alias becomes the key in the returned object.
Step 9: Update, Delete, and Transactions
Updates and deletes follow the same pattern as inserts: a chainable builder that produces SQL and optionally returns rows. The .where() clause is mandatory by convention; Drizzle will not stop you from writing an unconditional UPDATE or DELETE, but the omission of a WHERE clause is one of the patterns the optional strict linter flags.
// Update with returning
const [updated] = await db
.update(users)
.set({ name: "Alice Smith", role: "editor" })
.where(eq(users.id, alice.id))
.returning();
// Delete with returning
const deleted = await db
.delete(posts)
.where(eq(posts.published, false))
.returning({ id: posts.id });
// Transactions
await db.transaction(async (tx) => {
await tx.insert(users).values({ email: "[email protected]", name: "Carol" });
await tx.update(users).set({ isActive: false }).where(eq(users.role, "viewer"));
// Throw to roll back
});
The transaction callback receives a transaction-scoped tx client that exposes the same query API as db. Anything you do inside the callback rolls back if the callback throws and commits if the callback resolves. Nested transactions translate to savepoints automatically. The pattern works identically across PostgreSQL, MySQL, and SQLite, with the appropriate isolation levels mapped per dialect.
Be aware that the postgres-js driver opens a dedicated connection for the duration of the transaction. If you wrap a long-running operation, that connection is unavailable to other requests. Keep transactions short, and never await an external HTTP call inside a transaction unless you are explicitly modeling a saga.
The $count helper introduced in recent Drizzle ORM versions returns the number of rows that match a predicate without a manual sql<number>`count(*)` cast. Call it as await db.$count(users, eq(users.isActive, true)) and receive a typed number. It is a small ergonomic improvement that adds up when you are writing dashboard queries.
Step 10: Validate Inputs With drizzle-zod
Type safety at compile time does not protect you from malformed JSON arriving from an HTTP client. The drizzle-zod package generates Zod schemas from your Drizzle tables, so the same schema definition becomes both your database contract and your input validator. The result is one source of truth for both layers.
// src/db/validators.ts
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { z } from "zod";
import { users, posts } from "./schema.js";
export const insertUserSchema = createInsertSchema(users, {
email: (s) => s.email().max(320),
name: (s) => s.min(1).max(100),
}).omit({ id: true, createdAt: true, updatedAt: true });
export const insertPostSchema = createInsertSchema(posts, {
title: (s) => s.min(3).max(200),
body: (s) => s.min(1),
}).omit({ id: true, createdAt: true });
export const selectUserSchema = createSelectSchema(users);
export type InsertUserInput = z.infer<typeof insertUserSchema>;
The second argument to createInsertSchema is a refinement object. Each key targets a column and receives the auto-generated Zod schema for that column, returning a refined version. This is how you add length limits, email validation, regex constraints, or transform functions without losing the auto-generated baseline.
For consumers preferring TypeBox or Valibot, equivalent packages exist as drizzle-typebox and drizzle-valibot. The 1.0 beta line consolidates all three into the main drizzle-orm package as subpath imports, so import { createInsertSchema } from "drizzle-orm/zod" becomes the future-proof import. For now, the standalone packages remain canonical.
Wire the validator into your request handlers by parsing the body before touching the database: const data = insertUserSchema.parse(req.body). A ZodError here returns a 400 to the client with structured field-level errors. The compile-time type of data is exactly NewUser minus the omitted columns, so you can pass it straight to db.insert(users).values(data).
Step 11: Build a REST API With Hono
To prove the stack end-to-end, expose the database through an HTTP API. Hono is a tiny, edge-compatible web framework that pairs cleanly with Drizzle because both target the same modern runtimes. You can swap Hono for Express, Fastify, or Elysia without changing any database code.
// src/api/server.ts
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { users, posts } from "../db/schema.js";
import { insertUserSchema, insertPostSchema } from "../db/validators.js";
const app = new Hono();
app.get("/users", async (c) => {
const rows = await db.select().from(users).limit(50);
return c.json(rows);
});
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: true },
});
return user ? c.json(user) : c.json({ error: "not found" }, 404);
});
app.post("/users", async (c) => {
const body = await c.req.json();
const parsed = insertUserSchema.safeParse(body);
if (!parsed.success) return c.json(parsed.error.flatten(), 400);
const [created] = await db.insert(users).values(parsed.data).returning();
return c.json(created, 201);
});
app.post("/posts", async (c) => {
const body = await c.req.json();
const parsed = insertPostSchema.safeParse(body);
if (!parsed.success) return c.json(parsed.error.flatten(), 400);
const [created] = await db.insert(posts).values(parsed.data).returning();
return c.json(created, 201);
});
serve({ fetch: app.fetch, port: 3000 });
console.log("Listening on http://localhost:3000");
Install the runtime with npm install hono @hono/node-server, then start the server with npm run dev. Test the endpoints with curl: curl -X POST http://localhost:3000/users -H "content-type: application/json" -d '{"email":"[email protected]","name":"Dan"}' should return a JSON body with the created user including the auto-generated id and timestamps.
The same Hono app deploys unchanged to Cloudflare Workers, Vercel Edge, Bun, Deno Deploy, and Node. Swap the entrypoint: for Workers, replace serve() with export default app and replace the database import with the Neon HTTP variant. For Vercel, add export const runtime = "edge" in a Next.js route handler and re-export app.fetch. The query layer survives the move because Drizzle ORM detects the runtime and adapts the driver.
For traditional Express users, the only change is wrapping the same handler logic in app.get("/users", async (req, res) => res.json(await db.select().from(users))). Drizzle has zero opinion about the HTTP layer; it cares only about producing typed query results.
Step 12: Drizzle Studio for Visual Debugging
Drizzle Studio is a browser-based table explorer that drizzle-kit launches against your live database. It is the in-house alternative to TablePlus, DBeaver, and Postico for projects already using Drizzle. Run npm run db:studio and a local URL prints to the terminal, typically https://local.drizzle.studio.
Studio supports row editing with optimistic concurrency, filtered views, foreign key navigation, raw SQL execution, and CSV/XLSX export. Recent releases added a SQL console with snippets, dependency tree visualization, range selection, copy and paste between tabs, custom error pages, and column types displayed in the headers. The 1.0 beta line introduces table and view creation directly from the UI.
Studio reads from the same drizzle.config.ts file as the rest of the CLI, so the connection string is whatever you configured in step 3. Production databases should be accessed only over a tunnel; the Studio UI does not enforce read-only mode by default and a slip of the keyboard could update or delete rows. Treat Studio as a developer tool, not an admin console.
For team workflows where multiple developers need to inspect the same staging database, Drizzle ships a hosted Studio at drizzle.studio that connects via a short-lived HTTPS tunnel. The tunneled connection is encrypted in transit, but the same caveat about read-write access applies: provide a read-only Postgres role to anyone who only needs to inspect, not modify.
Step 13: Deploy to Edge Runtimes
The final step is to ship the application. Drizzle ORM is deliberately runtime-agnostic, but the database driver you choose dictates which runtimes will accept it. For traditional Node.js servers (Render, Railway, Fly.io, AWS Fargate), the postgres driver is fastest and easiest. For Cloudflare Workers and Vercel Edge, swap to @neondatabase/serverless or @vercel/postgres, both of which use HTTP rather than TCP.
| Target Platform | Recommended Driver | Drizzle Import Path | Notes |
|---|---|---|---|
| Node.js (Render, Fly.io) | postgres | drizzle-orm/postgres-js | Best raw throughput, native TCP |
| Vercel Serverless | @vercel/postgres | drizzle-orm/vercel-postgres | Edge-aware connection pool |
| Vercel Edge / Workers | @neondatabase/serverless | drizzle-orm/neon-http | HTTP, no socket |
| Cloudflare D1 | built-in | drizzle-orm/d1 | SQLite-compatible, batch API |
| Bun | bun:sqlite or postgres | drizzle-orm/bun-sqlite | Native bindings |
| Deno | postgres or postgresjs | npm:drizzle-orm | Use npm: specifier |
| AWS Lambda | postgres + RDS Proxy | drizzle-orm/postgres-js | Pool through RDS Proxy |
For a Vercel deployment, add vercel.json with a build step that runs migrations before the deploy completes. The release-phase pattern looks like "buildCommand": "npm run db:migrate && vercel build". This guarantees that the database schema is at the right version before any new code starts serving requests, eliminating the “column does not exist” class of post-deploy bugs.
For Cloudflare Workers, the migration path is slightly different because Workers cannot run a Node script during deploy. Either run wrangler d1 migrations apply from your CI pipeline (for D1), or run the migration script as a one-off Node job from a CI runner before the worker deploy (for Neon HTTP). The principle is the same: migrate first, then ship code that depends on the new schema.
Health checks should hit a route that does a trivial query like SELECT 1. Add this to your Hono app: app.get("/healthz", async (c) => { await db.execute(sql`select 1`); return c.text("ok"); }). This single endpoint catches both connectivity failures and credential rotations, and it is the recommended pattern for both Kubernetes liveness probes and Vercel cron checks.
Common Pitfalls When Learning Drizzle ORM
Five specific traps catch nearly every newcomer to Drizzle, and all of them are simple to avoid once you know to look for them.
- Forgetting the
.jsextension in imports. Because the project uses"module": "NodeNext", every relative import must include.jseven though the source file is.ts. The TypeScript compiler resolves the extension at build time, but Node at runtime needs the literal path. - Mixing the schema and migration into one transaction. Migrations should run before the application starts, not on the first request. Wrapping schema bootstrapping inside a request handler causes connection pool starvation and race conditions when multiple instances start simultaneously.
- Calling
drizzle-kit pushin production. Push is a development convenience that can drop columns silently. Use generate-and-migrate instead, and review every emitted SQL file in code review. - Treating
relations()as a foreign key. Therelationshelper exists only for the relational query API. The actual foreign key constraint is the.references()call inside the column definition. If you forget the.references(), the database has no FK at all and orphaned rows become possible. - Reusing one client across all environments. The Node client uses TCP sockets and connection pools that do not survive the Cloudflare Workers isolate model. Use the runtime-appropriate driver per deployment target rather than fighting the pool.
A sixth pitfall worth flagging: enums in PostgreSQL are not free to alter. Adding a value to roleEnum requires a separate ALTER TYPE ... ADD VALUE statement that drizzle-kit will generate but cannot run inside a transaction. If your CI pipeline wraps migrations in a transaction, enum additions will fail. Either run them outside transactions or use a varchar column with a check constraint.
Troubleshooting Eight Common Drizzle ORM Errors
- “Cannot find module ‘./db/schema'” – Add the
.jsextension to the import path. NodeNext module resolution requires it. - “Type ‘undefined’ is not assignable to type ‘string'” – The schema column is
.notNull()but the insert object omits it. Either add a.default()or pass the value at insert time. - “relation ‘users’ does not exist” – Migrations never ran. Run
npm run db:migrate, or verify that theDATABASE_URLin your shell is the one drizzle-kit migrated against. - “connect ECONNREFUSED 127.0.0.1:5432” – Postgres is not running locally. For Docker, start the container with
docker start drizzle-pg; for Homebrew, runbrew services start postgresql@17. - “password authentication failed” – Wrong credentials in
DATABASE_URL. Triple-check the password and confirm Postgres’pg_hba.confallows the auth method you are using. - “SSL connection required” – Managed providers like Neon require TLS. Append
?sslmode=requireto the connection string. - “too many clients already” – The connection pool is exhausted. Lower the
maxon the postgres client, share a single Drizzle client across requests, and ensureidle_timeoutis configured. - “Cannot use ‘await’ outside of an async function” – Top-level await requires
"target": "ES2022"and"module": "NodeNext"or higher. Updatetsconfig.jsonaccordingly.
If you encounter an error not in the list above, set logger: true on the Drizzle client and inspect the actual SQL Drizzle emitted. Nine times out of ten, the bug is visible in the SQL: a missing JOIN, a typo in a column reference, or a parameter binding that resolved to undefined because of an unhandled null upstream.
Drizzle ORM vs Prisma: Bundle Size, Cold Start, and Edge Compatibility
The most-asked Drizzle ORM tutorial question is when to choose Drizzle over Prisma. The honest answer is that the two tools optimize for different developer experiences, and the right choice depends on your runtime constraints.
| Dimension | Drizzle ORM | Prisma |
|---|---|---|
| TypeScript inference | Native, no codegen | Generated client, runs prisma generate |
| Bundle size (typical app) | Small, tree-shakeable | Larger; bundles query engine binary on Node |
| Edge runtime | Native via Neon HTTP, D1, Vercel Postgres | Requires Accelerate or Data Proxy historically |
| Migrations | SQL files, full diff visibility | SQL files, full diff visibility |
| Query API | SQL-like + relational | Fluent client API |
| Schema language | TypeScript | schema.prisma DSL |
| Runtime support | Node, Bun, Deno, Workers, Edge | Node, Bun, Deno, Edge via Accelerate |
| Studio | Drizzle Studio (free) | Prisma Studio (free) |
| Weekly npm downloads | ~900,000 (Drizzle data) | Significantly higher (mature ecosystem) |
Pick Drizzle when bundle size or cold-start latency matters, when you are deploying to Workers or Edge, when you want to read SQL in your application code, or when you want one schema source of truth in TypeScript. Pick Prisma when you have a large existing Prisma codebase, when your team prefers a fluent client to a SQL-like builder, or when Prisma Accelerate’s caching layer is doing serious work for you.
For a deeper data-store comparison, our PostgreSQL vs MySQL 2026 deep dive covers the underlying database choices that flow into either ORM. Drizzle ORM works equally well with both, but feature support around JSON, full-text search, and replication differs.
Advanced Tips: Performance, Observability, and Patterns
Once the basics are working, three optimizations consistently pay off in production Drizzle applications. The first is prepared statements. Wrapping a frequent query in db.select(...).prepare("name") precompiles the SQL plan and binds positional parameters, eliminating the per-call parsing overhead. For high-throughput endpoints, this is a measurable win.
import { sql } from "drizzle-orm";
const findUserById = db
.select()
.from(users)
.where(eq(users.id, sql.placeholder("id")))
.prepare("find_user_by_id");
const user = await findUserById.execute({ id: 42 });
The second optimization is observability. Replace logger: true with a custom logger that emits OpenTelemetry spans: { logQuery: (query, params) => tracer.startSpan("db.query", { attributes: { sql: query } }).end() }. That feeds Drizzle queries into the same tracing pipeline as your HTTP requests, which is invaluable when debugging tail-latency issues.
The third optimization is read-replica routing. Drizzle’s withReplicas() helper accepts a primary client and an array of replica clients, then routes reads to a randomly selected replica while sending writes to the primary. Connection pools per replica still need to be sized correctly, and the helper does not handle replication lag, but for read-heavy workloads it is a one-line scaling win.
For testing, Drizzle plays nicely with both real databases (via Testcontainers) and in-memory SQLite. The same schema.ts can target Postgres in production and SQLite in tests if you avoid Postgres-specific column types like jsonb and tstzrange. For a more permissive testing strategy, run a per-test Postgres database via pg-mem or spin up a fresh Docker container per test suite.
Output Examples: Real Requests, Real Responses
The final acceptance test for any Drizzle ORM tutorial is whether the API returns sensible JSON to a real client. The block below shows curl invocations and the responses you should expect after seeding the database in step 7.
# List users
$ curl -s http://localhost:3000/users | jq
[
{
"id": 1,
"email": "[email protected]",
"name": "Alice",
"role": "admin",
"isActive": true,
"createdAt": "2026-04-07T09:14:22.481Z",
"updatedAt": "2026-04-07T09:14:22.481Z"
}
]
# Fetch user with nested posts via the relational API
$ curl -s http://localhost:3000/users/1 | jq
{
"id": 1,
"email": "[email protected]",
"name": "Alice",
"role": "admin",
"isActive": true,
"posts": [
{
"id": 1,
"title": "Drizzle ORM in Production",
"body": "Edge-ready, zero codegen",
"published": true
}
]
}
# Reject invalid input via drizzle-zod
$ curl -s -X POST http://localhost:3000/users
-H "content-type: application/json"
-d '{"email":"bad","name":""}' | jq
{
"fieldErrors": {
"email": ["Invalid email"],
"name": ["String must contain at least 1 character(s)"]
}
}
The shape of these responses is dictated by your schema and validators, not by the framework. If you want camelCase keys in the API response and snake_case in the database, that is exactly what the varchar("created_at") mapping handles: the database column is created_at, the JavaScript field is createdAt, and the API JSON inherits the JavaScript naming.
Putting It All Together: The Complete Project
The complete repository ends with the following structure, where every file has appeared in the steps above:
drizzle-api/
├── drizzle/
│ ├── 0000_initial.sql
│ └── meta/
│ ├── _journal.json
│ └── 0000_snapshot.json
├── src/
│ ├── db/
│ │ ├── index.ts # Drizzle client
│ │ ├── schema.ts # Tables + relations
│ │ ├── validators.ts # drizzle-zod schemas
│ │ ├── seed.ts # Initial data
│ │ └── migrate.ts # Migration runner
│ └── api/
│ └── server.ts # Hono routes
├── drizzle.config.ts
├── tsconfig.json
├── package.json
└── .env
To verify the full project, run the following sequence on a fresh checkout: npm install && npm run db:generate && npm run db:migrate && npx tsx src/db/seed.ts && npm run dev. In a second terminal, hit the endpoints with curl and confirm the responses match the examples above. If anything fails, the troubleshooting section above covers eight of the nine possible reasons.
From here, the natural next steps are layering authentication (Clerk, Auth.js, or Lucia all integrate cleanly), wrapping the API with rate limiting, and adding background jobs with a queue. For a comparison of frontend frameworks that consume this API, see our companion Next.js full-stack tutorial.
Frequently Asked Questions About Drizzle ORM
Is Drizzle ORM production-ready in 2026?
Yes. The 0.44.x line is stable and is what most Drizzle production deployments run today. Companies running on Neon, Vercel Postgres, Turso, and Supabase have shipped Drizzle to production at scale. The 1.0 beta is a forward-looking release that consolidates the package layout and refines the relational query API; if you start a new project today, 0.44.x is the conservative choice and 1.0 beta is acceptable if you can tolerate occasional API churn before the stable cut.
Does Drizzle ORM support migrations like Prisma Migrate?
Yes. drizzle-kit generate emits SQL files based on diffs between your TypeScript schema and the latest snapshot, and drizzle-kit migrate applies pending files in order. The workflow is intentionally similar to Prisma Migrate, with the difference that Drizzle stores its journal as a single JSON file rather than as one row per migration in the database. Both approaches have rollback patterns; neither generates rollback SQL automatically.
Can Drizzle ORM run on Cloudflare Workers?
Yes, with one of three drivers: drizzle-orm/d1 for Cloudflare D1 (SQLite-compatible), drizzle-orm/neon-http for Neon Postgres over HTTP, or drizzle-orm/postgres-proxy for self-hosted HTTP proxies. The native postgres-js driver does not run on Workers because Workers cannot open raw TCP sockets to most ports. Choose the driver that matches your database, and the rest of your Drizzle code is identical.
How does Drizzle compare to TypeORM and Sequelize?
TypeORM relies on decorators and active-record patterns that have aged poorly in modern TypeScript codebases, particularly around strict mode and ESM. Sequelize is mature but its TypeScript support has historically been bolted on, with weaker inference than either Drizzle or Prisma. Drizzle’s flat function-based schema definitions and native inference put it ahead of both for new TypeScript projects, especially serverless ones.
What database providers integrate with Drizzle ORM?
Drizzle ORM ships first-class drivers for vanilla PostgreSQL, MySQL, SQLite, Neon, Vercel Postgres, Supabase, Turso (LibSQL), Cloudflare D1, AWS Data API, PlanetScale, Bun SQLite, and Expo SQLite. The 1.0 beta line adds a Microsoft SQL Server dialect and PGlite for in-memory Postgres testing. Coverage is comparable to Prisma’s connector ecosystem.
How do I write JSON queries with Drizzle?
Use the jsonb column type and Drizzle’s sql tag for path operators. A column declared as data: jsonb("data").$type<{ tags: string[] }>() exposes the strongly-typed payload to your queries, and a filter such as sql`${users.data}->'tags' ? ${tag}` drops into the raw Postgres operator without giving up type safety. For complex JSON aggregation, the sql tag is the escape hatch.
Can I use Drizzle without TypeScript?
Technically yes; Drizzle is a regular npm package with JavaScript output. Practically no, because the entire value proposition is the TypeScript inference, and the schema definition relies on TypeScript-specific features that read awkwardly in plain JS. If you are committed to JavaScript, Sequelize or Knex are better fits. If you have a JavaScript codebase you want to migrate, our TypeScript vs JavaScript 2026 guide covers the path.
Does Drizzle ORM support multi-tenant patterns like row-level security?
Drizzle does not abstract row-level security into a special API; it relies on the underlying database. Define your RLS policies in SQL via a migration file, set the appropriate session variable in your request handler with db.execute(sql`set local app.tenant_id = ${tenantId}`), and Postgres applies the policies transparently to every Drizzle query. This is the same pattern Supabase recommends and works equally well with Neon and Vercel Postgres.
Related Coverage
- How to Build a Full-Stack App with Next.js: 13-Step Tutorial with App Router
- PostgreSQL vs MySQL 2026: 3.7x JSON Speed Gap and 300 vs 0 Extensions
- Supabase vs Firebase 2026: 3x Cost Gap and 10x Cold-Start Divide
- TypeScript vs JavaScript 2026: 73% Adoption, 15% Salary Gap
- Bun JavaScript Tutorial: Build a REST API in 13 Steps
- How to Master SQLite with Python: 13-Step Tutorial
- AI Coding Tools Guide
For ongoing reference, the canonical sources beyond this Drizzle ORM tutorial are the official documentation at orm.drizzle.team, the GitHub repository at drizzle-team/drizzle-orm, the Neon Drizzle integration guide, the PostgreSQL 17 docs, and the State of JS 2025 survey for adoption context. Bookmark them; you will return to all five.
Sofia Lindström
Sofia Lindström is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.
View all articles