TypeScript ORM

Drizzle ORM

Drizzle is a TypeScript-first ORM that keeps SQL in plain sight. Define tables in TypeScript, compose queries that mirror the SQL they generate, and run it all on a thin, edge-friendly runtime.

intermediate14 min readUpdated Sep 16, 2026
src/db/schema.ts
ts
// src/db/schema.ts
import { pgTable, text, uuid, timestamp } 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: uuid("id").primaryKey().defaultRandom(),
  title: text("title").notNull(),
  authorId: uuid("author_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
});
Released
2021
License
Apache-2.0
Language
TypeScript
Runtime
Thin, no Rust engine or codegen
Migration tool
drizzle-kit
Databases
PostgreSQL, MySQL, SQLite and edge variants

Why it matters

Why developers pick Drizzle

Queries that mirror SQL

select, from, where, join and groupBy line up one-to-one with the statement Drizzle generates, so the code reads like the query.

A tiny runtime

There is no binary query engine and no generated client, so a serverless function carries only the query builder and its driver.

drizzle-kit for migrations

Generate reviewable SQL migrations, apply them in order, or push a schema straight to a throwaway database while prototyping.

The big picture

Three ideas behind Drizzle

Your schema is TypeScript, your queries read like SQL, and the database keeps every feature it shipped with.

Schema in TypeScript

Define

Tables are declared with typed builders such as pgTable, and column methods add the same constraints you would write in DDL.

SQL-shaped queries

Query

The query builder composes clauses rather than hiding them, and a sql template covers anything the builder does not expose.

Your database, unchanged

Connect

Dialect-specific features stay available instead of being flattened, so PostgreSQL, MySQL and SQLite each keep their own strengths.

Data model

The users table, exactly as written

Each builder call maps to a real column and constraint. The database sees snake_case names while the code keeps camelCase.

The users table, exactly as writtenPostgreSQL table
  • iduuidPrimary key; defaultRandom() becomes gen_random_uuid()
  • emailtextNOT NULL with a UNIQUE constraint
  • nametextNullable display name
  • createdAttimestamptzNOT NULL, defaulting to now() in UTC

Each builder call maps to a real column and constraint. The database sees snake_case names while the code keeps camelCase.

A short history

A short history of a young ORM

  1. 2021

    Drizzle is open-sourced

    The project begins as a small TypeScript SQL toolkit rather than a full ORM.

    21
  2. 2022

    drizzle-kit arrives

    A companion CLI adds schema generation, migrations and database introspection.

    22
  3. 2023

    Serverless and edge first

    Drivers for Neon, PlanetScale, Turso and Cloudflare D1 make it a natural fit for edge runtimes.

    23
  4. 2024

    Relational queries mature

    The db.query API and relations system grow into a first-class way to load nested data.

    24
  5. 2025

    A default for edge databases

    Drizzle becomes a common choice wherever a small bundle and fast cold starts matter.

    25

The complete guide

Drizzle ORM: Everything you need to know

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.ts as the single source of truth and export tables and relations from one place.
  • Use drizzle-kit generate and commit the SQL; never let push touch production.
  • Select only the columns you need, and add .returning() instead of a follow-up read.
  • Prefer db.query with with for nested reads and the query builder for anything custom.
  • Wrap multi-step writes in db.transaction and use the tx handle throughout.
  • Use sql placeholders and prepared statements on hot paths.
  • Match the driver to the platform and share one db instance 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 push against a production database and dropping columns.
  • Using the outer db inside a transaction instead of the provided tx.
  • Forgetting to pass { schema } to drizzle, which breaks db.query.
  • Writing where conditions with the wrong operator, such as eq where inArray is needed.
  • Selecting every column by default in hot paths.
  • Omitting indexes on foreign keys used by joins.
  • Assuming MySQL supports .returning().
  • Treating sql fragments as automatically safe when they interpolate user strings.
  • Forgetting to run generate after 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.

In practice

Schema, join, relational query, transaction

Four tabs covering the schema, both query styles and an atomic write.

src/db/schema.ts
import { relations } from "drizzle-orm";
import { pgTable, text, uuid, boolean, timestamp } 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: uuid("id").primaryKey().defaultRandom(),
  title: text("title").notNull(),
  published: boolean("published").notNull().default(false),
  authorId: uuid("author_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
});

export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
}));

SQL-shaped query versus object query

Drizzle keeps the clauses visible and returns typed rows; Prisma hides the SQL behind an object and generates a client. Both are valid, but only one lets you read the query as SQL.

Drizzle
const rows = await db
  .select({ id: users.id, email: users.email })
  .from(users)
  .where(eq(users.email, email))
  .limit(1);
Prisma
const rows = await prisma.user.findMany({
  where: { email },
  select: { id: true, email: true },
  take: 1,
});

Generate versus push

generate writes versioned SQL you can review and replay. push makes the database match the schema with no history, which is fine for a prototype and unsafe for production.

Prefer
pnpm drizzle-kit generate
pnpm drizzle-kit migrate
Avoid
# No files, no journal, no audit trail.
pnpm drizzle-kit push

Trade-offs

Is Drizzle the right ORM?

Drizzle trades hand-holding for control. That suits teams who know SQL and value a small runtime, and it asks more of everyone else.

Strengths

  • You always know the SQL

    Queries map to clauses, so a slow log or an EXPLAIN output points straight back at the code that produced it.

  • Almost no runtime overhead

    No Rust engine and no code generation means small bundles, fast cold starts and comfortable deployment to edge runtimes.

  • Types without a build step

    Schema and query types are inferred from ordinary TypeScript, so there is no generate command to forget in CI.

Trade-offs

  • More SQL knowledge required

    The library will not stop you writing an inefficient query. Joins, indexes and conflict handling are yours to get right.

  • Relations are wired by hand

    You declare relations separately and choose between the query builder and db.query, which is more explicit but also more setup.

  • A younger ecosystem

    There are fewer third-party plugins and less long-form material than for older ORMs, though the core is stable and well documented.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning Drizzle ORM?

Our interactive tutorial walks you through Drizzle ORM step by step — with quizzes and real code you can run in the browser.