What is Drizzle?
Drizzle is a TypeScript ORM that treats SQL as a first-class citizen. Your schema is ordinary TypeScript, your queries are built from methods that mirror SQL clauses, and the runtime is thin enough to run on the edge. There is no code generation step and no binary engine: you import drizzle-orm and query.
That design shows in every API. db.select().from(users).where(eq(users.id, id)) reads in the same order as the SQL it produces. The library does not hide the database, it types it. If you already understand joins, indexes and ON CONFLICT, you already understand most of Drizzle.
Drizzle supports PostgreSQL, MySQL and SQLite, along with serverless and edge drivers such as Neon, PlanetScale, Turso, Cloudflare D1 and Bun’s built-in SQLite. The same schema and query style carry across all of them, which is why it has become a common choice for edge and serverless databases.
Your schema is TypeScript
There is no schema.prisma and no generated client. You define tables with a builder per dialect and export them.
import { pgTable, text, uuid, boolean, timestamp, integer } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
name: text("name"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const posts = pgTable("posts", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
title: text("title").notNull(),
published: boolean("published").notNull().default(false),
authorId: uuid("author_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
The first argument is the SQL table name, and each column builder takes the column name. That explicit mapping lets the database use snake_case while your code uses camelCase. Column methods such as .notNull(), .unique() and .default() add the same constraints you would write by hand.
Relations are declared separately with relations(). They do not create columns; they tell the relational query API how tables connect.
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],
}),
}));
Types come for free
Drizzle infers row types from the schema, so you rarely write one by hand. Every table exposes $inferSelect and $inferInsert:
import { users } from "./schema";
type User = typeof users.$inferSelect;
type NewUser = typeof users.$inferInsert;
function displayName(user: User) {
return user.name ?? user.email;
}
The InferSelectModel and InferInsertModel helpers do the same thing and read better in some codebases:
import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
type User = InferSelectModel<typeof users>;
type NewUser = InferInsertModel<typeof users>;
Because these types come from the same object that builds the query, a column rename updates every consumer at once. That is the payoff of keeping the schema in TypeScript instead of a separate DSL: there is no generated file that can drift out of sync.
Constraints and indexes live beside the columns
Column-level rules cover NOT NULL, UNIQUE and defaults. Table-level rules such as composite keys, indexes and checks go in the optional third argument, which returns an array.
import { sql } from "drizzle-orm";
import {
check,
index,
pgTable,
primaryKey,
text,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
export const memberships = pgTable(
"memberships",
{
userId: uuid("user_id").notNull().references(() => users.id),
orgId: uuid("org_id").notNull(),
role: text("role").notNull().default("member"),
},
(table) => [
primaryKey({ columns: [table.userId, table.orgId] }),
index("memberships_org_idx").on(table.orgId),
uniqueIndex("memberships_user_org_idx").on(table.userId, table.orgId),
check("memberships_role_check", sql`${table.role} in ('member', 'admin')`),
],
);
Keeping indexes in the schema rather than in a hand-run script means drizzle-kit generate creates them with the table, and every environment gets them in the same order. If a query is slow, the index it needs usually belongs here next to the column it covers.
Connecting to a database
Create a driver connection, wrap it with drizzle, and pass the schema so the relational API can see your relations.
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });
Swap node-postgres for the driver you actually deploy: drizzle-orm/neon-http for Neon’s HTTP endpoint, drizzle-orm/postgres-js, drizzle-orm/better-sqlite3, drizzle-orm/d1 or drizzle-orm/bun-sqlite. The query API above db is identical, which is the point.
Serverless and edge drivers
The reason Drizzle travels well to edge runtimes is that there is nothing to bundle beyond the driver. Neon over HTTP and Cloudflare D1 are the two you meet most often.
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 });
import { drizzle } from "drizzle-orm/d1";
export default {
async fetch(_request: Request, env: Env) {
const db = drizzle(env.DB);
const rows = await db.select().from(users);
return Response.json(rows);
},
};
Create the client once per isolate rather than per request, and pass { schema } so db.query keeps working. HTTP and D1 drivers can limit prepared statements or transactions depending on the protocol, so check the driver notes before depending on either in production.
The query builder reads like SQL
Queries are assembled by chaining methods that map to SQL clauses. select, from, where, orderBy, limit and offset line up one-to-one with the statement they generate.
import { eq, desc, ilike, and } from "drizzle-orm";
const rows = await db
.select({
id: posts.id,
title: posts.title,
publishedAt: posts.createdAt,
})
.from(posts)
.where(and(eq(posts.published, true), ilike(posts.title, "%drizzle%")))
.orderBy(desc(posts.createdAt))
.limit(20);
Operators are imported functions rather than symbols: eq, ne, gt, gte, lt, lte, inArray, notInArray, like, ilike, isNull, isNotNull, and the combinators and, or and not. When you need something the builder does not expose, sql drops you into raw SQL while staying parameterised.
import { sql } from "drizzle-orm";
const active = await db
.select()
.from(users)
.where(sql`${users.createdAt} > now() - interval '30 days'`);
Filtering, sorting and pagination
Conditions compose with and, or and not, and the operator functions cover the usual predicates. Reading them together is close to reading a WHERE clause.
import { and, asc, gt, inArray, isNull, or } from "drizzle-orm";
const page = await db
.select({ id: posts.id, title: posts.title, createdAt: posts.createdAt })
.from(posts)
.where(and(eq(posts.published, true), gt(posts.id, cursor)))
.orderBy(asc(posts.id))
.limit(20);
That is keyset pagination: instead of offset, which makes the database scan and discard rows, you page from the last id you saw. For smaller result sets limit and offset are perfectly fine, and for nullable or set-based filters the same builder applies:
const results = await db
.select()
.from(users)
.where(
or(
isNull(users.name),
inArray(users.email, ["[email protected]", "[email protected]"]),
),
);
Sorting uses asc and desc on columns, or sql for expressions such as desc(sqlcount(${posts.id})). Because every clause is explicit, there is no hidden ordering or default filter to surprise you.
Joins and aggregates
Joins are explicit, exactly as in SQL. You choose innerJoin, leftJoin or rightJoin and supply the condition.
const report = await db
.select({
id: users.id,
email: users.email,
postCount: sql<number>`count(${posts.id})`.mapWith(Number),
})
.from(users)
.leftJoin(posts, eq(posts.authorId, users.id))
.where(eq(posts.published, true))
.groupBy(users.id)
.orderBy(desc(sql`count(${posts.id})`))
.limit(20);
Because you write the groupBy yourself, there is no hidden query plan. If the SQL would be wrong, the TypeScript is wrong too. Subqueries, CTEs with with, window functions and set operations are all available, either through dedicated helpers or through sql fragments.
The relational query API
The query builder is precise but verbose for the common case of loading a user with their posts. The relational query API, db.query, reads more like Prisma:
const result = await db.query.users.findMany({
columns: { id: true, email: true },
with: {
posts: {
columns: { id: true, title: true },
where: (posts, { eq }) => eq(posts.published, true),
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
limit: 10,
},
},
});
db.query.users.findMany and findFirst understand the relations() you declared, and with loads them in one round trip per relation. The two APIs share the same schema and can be mixed in one codebase: use the query builder when you need control, and db.query when you want a nested result without writing the join.
Writing data
Inserts, updates and deletes are just as direct. Add .returning() to get the affected rows back in a single statement, which avoids a follow-up SELECT.
await db
.insert(users)
.values({ email: "[email protected]", name: "Ada" })
.returning();
await db
.insert(posts)
.values([
{ title: "First", authorId },
{ title: "Second", authorId, published: true },
])
.onConflictDoNothing({ target: posts.id });
await db
.update(users)
.set({ name: "Ada Lovelace" })
.where(eq(users.id, id))
.returning();
await db.delete(posts).where(eq(posts.id, postId));
onConflictDoNothing and onConflictDoUpdate map to ON CONFLICT for upserts. returning() is supported on PostgreSQL, SQLite and MariaDB; MySQL does not support it, so you re-select there. The API deliberately mirrors the dialect you are connected to rather than pretending all databases are identical.
Transactions
Transactions are a callback that receives a transaction handle. Every query inside must use that handle.
await db.transaction(async (tx) => {
await tx
.update(accounts)
.set({ balance: sql`${accounts.balance} - 5000` })
.where(eq(accounts.id, from));
await tx
.update(accounts)
.set({ balance: sql`${accounts.balance} + 5000` })
.where(eq(accounts.id, to));
});
Throwing inside the callback rolls the transaction back. Nested tx.transaction calls become savepoints where the dialect supports them, and you can set an isolation level through the options object. As with any database, keep the callback short and do not await unrelated network calls inside it.
Migrations with drizzle-kit
drizzle-kit is the companion CLI. It reads drizzle.config.ts and the schema, and manages migrations.
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: { url: process.env.DATABASE_URL! },
});
pnpm drizzle-kit generate
pnpm drizzle-kit migrate
pnpm drizzle-kit push
pnpm drizzle-kit studio
generate diffs the schema against the last snapshot, writes a numbered SQL file into out, and records a snapshot in the journal. migrate applies pending files in order. Both are safe for production because the SQL is a reviewable artefact you commit.
push is the prototyping shortcut: it makes the database match the schema without writing migration files. It is convenient in development and risky in production for the same reasons as any schema-push command. pull goes the other way, introspecting an existing database into TypeScript, and studio opens a local data browser. For HTTP-only drivers such as Neon, use the driver’s migrator instead of the CLI:
import { migrate } from "drizzle-orm/neon-http/migrator";
await migrate(db, { migrationsFolder: "./drizzle" });
Prepared statements
Drizzle can prepare a query once and execute it many times with different parameters, which saves planning overhead on hot paths.
const getUser = db
.select()
.from(users)
.where(eq(users.id, sql.placeholder("id")))
.prepare("get_user");
const user = await getUser.execute({ id: userId });
Placeholders are declared with sql.placeholder("name") and supplied at execution. Prepared statements are named so the driver can cache the plan; remember that transaction-mode connection poolers such as PgBouncer may not preserve them across connections.
One ORM, many databases
Because the query builder mirrors SQL, Drizzle adapts to each dialect instead of flattening them. You import column builders from drizzle-orm/pg-core, drizzle-orm/mysql-core or drizzle-orm/sqlite-core, and the available types and functions follow the database.
That means PostgreSQL-specific features such as jsonb, arrays, pgEnum and generated columns are first-class, while MySQL and SQLite get their own equivalents. The cost is that moving a schema between dialects is real work: the abstractions are shared, but the SQL is not. This is a deliberate trade for teams that want the database’s actual capabilities rather than a lowest common denominator.
Why it feels like SQL
Most ORMs hide the database and expose objects. Drizzle does the opposite: it gives you types around the database you already know. Three consequences follow.
First, debugging is easier. The query you wrote is the query that runs, so reading a slow log or an EXPLAIN output maps directly back to code. Second, learning transfers, because knowledge of joins, indexes and ON CONFLICT applies unchanged. Third, there is less magic to surprise you: no lazy-loading proxies that fire queries when you touch a property, and no identity map deciding what is already in memory.
The cost is that you are expected to know SQL. Drizzle will happily generate an inefficient query if you ask for one, and it will not save you from a missing index.
Performance and bundle size
Drizzle ships no Rust engine and no generated client. The runtime is plain TypeScript that tree-shakes, so a serverless function or edge worker carries only the query builder and the driver. There is no separate binary to bundle or cold-start, which is a large part of why edge platforms favour it.
On the server the story is the same as any ORM: the database does the work, and your query shapes decide performance. Use .returning() to avoid extra round trips, prepare hot queries, select only the columns you need, and let indexes do their job. Because nothing is cached or batched behind your back, the performance you measure is the performance of the SQL you wrote.
Best practices
- Keep
schema.tsas the single source of truth and export tables and relations from one place. - Use
drizzle-kit generateand commit the SQL; never letpushtouch production. - Select only the columns you need, and add
.returning()instead of a follow-up read. - Prefer
db.querywithwithfor nested reads and the query builder for anything custom. - Wrap multi-step writes in
db.transactionand use thetxhandle throughout. - Use
sqlplaceholders and prepared statements on hot paths. - Match the driver to the platform and share one
dbinstance per process. - Add indexes in the schema next to the columns they cover.
- Run migrations in CI/CD before the new code deploys.
Common mistakes
- Calling
drizzle-kit pushagainst a production database and dropping columns. - Using the outer
dbinside a transaction instead of the providedtx. - Forgetting to pass
{ schema }todrizzle, which breaksdb.query. - Writing
whereconditions with the wrong operator, such aseqwhereinArrayis needed. - Selecting every column by default in hot paths.
- Omitting indexes on foreign keys used by joins.
- Assuming MySQL supports
.returning(). - Treating
sqlfragments as automatically safe when they interpolate user strings. - Forgetting to run
generateafter editing the schema, then wondering why the migration is empty.
Where to go next
Drizzle rewards knowing the database underneath, so read the PostgreSQL guide for indexes, transactions and query planning. Compare it with Prisma, which generates a client from a schema, and TypeORM, which models entities as classes. If you are still building up the language itself, the TypeScript guide is the foundation all three rely on.