MervCodes

Tech Reviews From A Programmer

Drizzle ORM Guide for TypeScript Developers

1 min read

Drizzle ORM Guide for TypeScript Developers

If you have spent any time wrestling with database access in TypeScript, you know the trade-offs. Raw SQL gives you control but no type safety. Heavyweight ORMs give you convenience but hide the SQL, generate surprising queries, and bloat your bundle. Drizzle ORM sits in a sweet spot: it is a thin, fully type-safe layer that feels like writing SQL, ships almost no runtime overhead, and gives you end-to-end inference from your schema to your query results.

This guide walks through the concepts and patterns you actually need to be productive with Drizzle in a real codebase.

Why Choose Drizzle

Drizzle's core philosophy is "if you know SQL, you know Drizzle." Instead of inventing a new query language, its API mirrors SQL clauses (select, from, where, leftJoin, groupBy). This has a few practical consequences worth understanding before you commit:

  • Zero-cost abstraction. Drizzle is a query builder, not a running process. It compiles to prepared SQL statements. There is no N+1 magic silently firing extra queries behind your back—what you write is what runs.
  • Serverless-friendly. It has no heavy connection dependencies and works cleanly on edge runtimes (Cloudflare Workers, Vercel Edge) and serverless platforms where cold-start time matters.
  • Type inference, not codegen. Your TypeScript types are derived directly from the schema you define in code. There is no separate generate step you must remember to run after every schema change (though there is one for SQL migrations).

If your team values explicitness and wants to keep SQL knowledge relevant, Drizzle is a strong fit. If you want an ORM that hides the database entirely, a different tool may suit you better.

Setting Up Your Project

Install the core package plus a driver for your database and the Drizzle Kit CLI for migrations:

npm install drizzle-orm postgres
npm install -D drizzle-kit

Create a drizzle.config.ts at your project root to tell Drizzle Kit where your schema and migrations live:

import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

Then initialize a database client. Keep this in a single module so you import one shared instance everywhere:

import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

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

Passing { schema } into drizzle() is what unlocks the relational query API discussed later, so do not skip it.

Defining Your Schema

Schemas are plain TypeScript objects. Each table is a first-class value you import and reuse. Here is a typical two-table setup with a foreign key:

import { pgTable, serial, text, timestamp, integer, boolean } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: serial("id").primaryKey(),
  email: text("email").notNull().unique(),
  name: text("name"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

export const posts = pgTable("posts", {
  id: serial("id").primaryKey(),
  title: text("title").notNull(),
  content: text("content"),
  published: boolean("published").default(false).notNull(),
  authorId: integer("author_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

A powerful convenience: you can derive TypeScript types straight from the table definitions, so your application types never drift from the database.

export type User = typeof users.$inferSelect;      // shape returned by queries
export type NewUser = typeof users.$inferInsert;   // shape accepted by inserts

Use $inferInsert for function parameters that create rows and $inferSelect for return values. This one pattern eliminates a whole category of "the column is nullable but my type says it isn't" bugs.

Running Migrations

Drizzle Kit generates SQL migration files by diffing your schema against the last known state. The workflow is two commands:

npx drizzle-kit generate   # create SQL migration files from schema changes
npx drizzle-kit migrate    # apply pending migrations to the database

For rapid prototyping you can use npx drizzle-kit push to sync the schema directly without generating files—but never use push against production. In production, always generate versioned migration files, review the SQL, commit them to source control, and apply them through migrate as part of your deploy pipeline. Reviewing the generated SQL is important: a rename can be interpreted as a drop-and-add, which would lose data.

Writing Queries

The query builder reads like SQL. Selects, inserts, updates, and deletes all return fully typed results:

import { eq, and, desc, gt } from "drizzle-orm";

// Insert and get the row back
const [user] = await db
  .insert(users)
  .values({ email: "[email protected]", name: "Ada" })
  .returning();

// Filtered, ordered select
const recentPosts = await db
  .select()
  .from(posts)
  .where(and(eq(posts.published, true), gt(posts.authorId, 0)))
  .orderBy(desc(posts.createdAt))
  .limit(10);

// Update
await db
  .update(posts)
  .set({ published: true })
  .where(eq(posts.id, 42));

// Delete
await db.delete(posts).where(eq(posts.id, 42));

Operators like eq, and, or, gt, inArray, and like are imported functions rather than magic strings, which means typos become compile errors instead of runtime surprises.

Relations and the Query API

For joins you have two options. The classic SQL-style join gives you flat rows:

const rows = await db
  .select({ postTitle: posts.title, authorName: users.name })
  .from(posts)
  .leftJoin(users, eq(posts.authorId, users.id));

But when you want nested, object-shaped results—closer to what a traditional ORM returns—define relations and use the relational query API. First declare the relations:

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] }),
}));

Now you can fetch a user with their posts nested inside, all fully typed, in a single efficient query:

const result = await db.query.users.findMany({
  with: { posts: true },
  where: (users, { eq }) => eq(users.id, 1),
});
// result[0].posts is a typed array

The relational API generates optimized SQL under the hood and avoids the N+1 problem—it does not fire one query per parent row.

Transactions

Wrap multiple statements in a transaction to guarantee atomicity. If the callback throws, everything rolls back:

await db.transaction(async (tx) => {
  const [user] = await tx.insert(users).values({ email: "[email protected]" }).returning();
  await tx.insert(posts).values({ title: "Hello", authorId: user.id });
});

Always use the tx handle inside the callback, not the outer db, or those statements will run outside the transaction.

Production Tips

  • Use prepared statements for hot paths. Call .prepare() on frequently executed queries with sql.placeholder() for parameters to cut query-planning overhead.
  • Keep one client instance. Instantiate db once and import it; creating a new connection per request exhausts your pool.
  • Watch nullability. Columns without .notNull() infer as nullable in TypeScript. Let the compiler force you to handle those cases.
  • Pair Drizzle with a validation library. drizzle-zod can generate Zod schemas from your tables so API input validation stays in lockstep with your database.
  • Review generated migrations. Treat the drizzle/ folder as code: read the SQL, especially around renames and type changes.

Frequently Asked Questions

Is Drizzle production-ready? Yes. It is widely used in production across Postgres, MySQL, SQLite, and their serverless variants (Neon, PlanetScale, Turso, Cloudflare D1). Its small footprint makes it especially popular on edge and serverless platforms.

How does Drizzle compare to Prisma? Prisma uses a separate schema DSL and a generated client with a heavier runtime, offering a more abstracted developer experience. Drizzle keeps everything in TypeScript, stays close to SQL, and has a much smaller runtime. Choose Drizzle when you want SQL transparency and edge compatibility; choose Prisma when you prefer a fully managed abstraction and its tooling ecosystem.

Does Drizzle require a code generation step? Not for types—those are inferred directly from your TypeScript schema. Code generation only applies to SQL migration files via Drizzle Kit, and even that is optional if you use push for prototyping.

Can I write raw SQL when I need to? Absolutely. The sql template tag lets you drop into raw SQL for anything the builder does not cover, while still parameterizing values safely against injection.

Which databases are supported? PostgreSQL, MySQL, and SQLite, along with their serverless drivers. You select the dialect in drizzle.config.ts and import the matching pg-core, mysql-core, or sqlite-core column helpers.

Do I need the relational query API, or are joins enough? Plain joins are sufficient for flat result sets and full control. Reach for the relational query API when you want nested objects and cleaner code for parent-child data—it is a convenience layer, not a requirement.

Conclusion

Drizzle gives TypeScript developers a rare combination: the transparency and control of SQL with the safety of full type inference and almost no runtime cost. Start by modeling your schema in code, derive your types with $inferSelect and $inferInsert, generate versioned migrations for production, and lean on the relational query API when nested data makes your code cleaner. Once the schema-to-query type flow clicks, you will find yourself trusting the compiler to catch database mistakes long before they reach runtime.

Sources

Related Articles

How to Set Up AI Code Review in GitHub Actions (2026 Guide)

Wire an AI code reviewer into GitHub Actions the right way — trigger on pull requests, post inline comments, keep secrets safe, and avoid the noisy-bot trap. Complete working workflow included.

AI Code Review Prompts That Actually Work (With Examples)

The quality of an AI code review is decided almost entirely by the prompt. Review prompt patterns that produce signal instead of noise — copy-paste examples for bugs, security, and PR-level review.

AI Code Review vs Human Code Review: When to Use Each (2026)

AI code review and human review aren't competitors — they're a division of labour. What each is good at, where each fails, and how to combine them so you ship faster without lowering the bar.