Contents

5 Reasons to Choose Drizzle ORM Over Traditional JavaScript ORMs

The TypeScript ecosystem is rapidly expanding, bringing along improved and more secure replacements for traditional JavaScript ORMs (Object Relational Mapping). Among the standout solutions are Drizzle and Prisma. While both enable developers to interact with databases in a type-safe way, they represent different philosophies.

In this article, I will focus on 5 reasons why Drizzle ORM is worth considering, especially regarding:

  • performance,
  • full control over queries,
  • clean TypeScript architecture.

1. First-Class Developer Experience

Drizzle ORM is designed for developers who want full visibility and control over their data layer without giving up on developer comfort.

It offers:

  • excellent IntelliSense support, including auto-completion, type inference, and inline documentation,
  • compile-time type safety, meaning that if the query is wrong, TypeScript will give a warning,
  • no need to generate a client, which means fewer moving parts and better iteration speed.

As for the last point, unlike ORMs like Prisma, Drizzle does not require a separate step to generate a client library from the schema. In Prisma, the database schema is defined in a .prisma file, and then, to create a custom TypeScript client, it is necessary to run the command:

npx prisma generate

That client becomes an interface to the database and must be regenerated whenever the schema changes.

Another popular library in the TypeScript ecosystem is Kysely. It is a lightweight and type-safe SQL query builder that also aims to improve developer experience. Similar to Prisma, Kysely requires a separate step to bring type information into the code. Developers usually define interfaces manually or use external tools like kysely-codegen, which introspects the database schema and generates TypeScript definitions based on actual tables:

This means that while Kysely offers great flexibility and strong typings, it also introduces additional steps and tooling to keep types in sync with the database. The schema is often defined outside the application code and maintaining that connection can be error-prone or lead to inconsistent typings if the generation step is skipped.

Drizzle skips this entirely. Instead of relying on schema files and code generation, Drizzle lets developers define database schema directly in TypeScript, and queries are written against that schema using native TypeScript expressions. There is no intermediate client layer, no compilation step, and no risk of types getting out of sync with the actual schema.

This results in:

  • faster iteration (developers don't have to wait for the client to be generated),
  • simpler toolchains,
  • fewer bugs due to desynchronized schemas and clients,
  • easier onboarding for new developers (because what they see in the code is what they get).

Drizzle’s approach makes the developer experience more transparent, more direct, and better integrated with modern TypeScript workflows.

Here is how a table definition might look:

import { pgTable, uuid, varchar, timestamp } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: varchar('email').notNull().unique()
  createdAt: timestamp('created_at')
.notNull()
.defaultNow(),
});

And this is what a query against the schema might look like:

import { eq } from 'drizzle-orm';
import db from '../db';
import { users } from '../db/schema';

export const getAllUsers = async () => {
  return await db.select().from(users)
};

2. Queries API - typed and simple

Drizzle adopts an SQL-first approach, but makes queries feel ergonomic and readable. It does not force the developer to learn a domain schema language. As in the case of Prisma, developers describe databases using Prisma Schema Language. Prisma uses that schema to generate a TypeScript client. Read more about it on the Prisma website.

Prisma hides SQL and gives a query API focused on working with entities. Writing queries is like using objects that look like regular JavaScript:

// Prisma query API

const users = await prisma.users.findMany({
  where: {
    OR: [
      { email: { contains: 'mail.com' } },
      { name: { startsWith: 'C' } }
    ],
    articles: { some: { published: true } }
  },
  select: {
    id: true,
    email: true,
    _count: { select: { articles: true } }
  },
  take: 5
});

This kind of query allows developers to work at a high level. They do not think about joins or raw SQL - just describe what they need, and Prisma handles the rest behind the scenes.

Drizzle leans into SQL concepts and provides a query API that resembles writing SQL directly in TypeScript, meaning expressive, predictable, and close to the database layer.

In contrast to solutions like Prisma, which rely on a separate domain-specific language (DSL) and a code generation step to produce a typed client, Drizzle operates entirely within the TypeScript codebase. Both the database schema and queries are defined in code, and type safety is inferred directly from those definitions, without needing external schema files or generated clients.

This approach simplifies the development workflow and removes the need to manage synchronization between the database model and the client API.

When a programmer updates the schema, TypeScript types update automatically, and no extra steps are needed. That gives more control over what is happening:

// Drizzle query API

const users = await db.select({
  id: users.id,
  email: users.email,
  articleCount: sql`count(${articles.id})`
.as('article_count')
})
.from(users)
.leftJoin(articles, eq(users.id, articles.authorId))
.where(
  or(
    like(users.email, '%mail.com%'),
    like(users.name, 'C%')
  )
)
.groupBy(users.id, users.email)
.having(gt(sql`count(${articles.id})`, 0))
.limit(5);

This API offers:

  • predictable behavior (only what is written will be executed),
  • full TypeScript coverage,
  • no hidden magic - full transparency on what query is being sent.

In addition, since Drizzle queries closely resemble raw SQL written in template literals or structured expressions, various editor extensions can enhance the developer experience. For example, Visual Studio Code offers extensions such as:

These extensions can validate SQL syntax or highlight common mistakes directly in the editor. This makes it easier to catch errors early, even before running the application.

However, for the developer’s convenience, Drizzle also includes a higher-level relational API:

const userWithArticles = await db.query.users.findFirst({
  where: eq(users.id, 1),
  with: {
    articles: {
      where: eq(articles.published, true),
      limit: 5
    }
  }
});

3. Prepared statements - performance boost

Drizzle supports prepared statements out of the box, giving developers an easy way to optimize performance, especially in serverless environments.

To understand why prepared statements improve performance, let’s see what happens behind the scenes when executing a query from Drizzle or other ORMs. When running a query against a database, there are several things that happen as shown in the figure below:

executing query agains sql database

The process illustrated above occurs each time SQL is executed using the query builder. In typical scenarios, this is not a significant concern and does not usually result in performance issues. However, when developers work with highly complex queries, such as those involving numerous joins and a variety of clauses, the steps of parsing, analyzing, and compiling are redundantly repeated with every execution. This happens even when the query structure remains unchanged and only the parameters vary.

If steps 1, 2, and 3 of the process could be performed ahead of time and the results reused, overhead could be significantly reduced. The key is to retain support for dynamic parameters while avoiding redundant compilation. This is exactly what prepared statements enable, meaning that it replaces the repeated execution of those initial steps with a precompiled query structure that accepts dynamic inputs at runtime:

prepare statement in executing query

With prepared statements:

  • the database can reuse the query plan, reducing parsing time,
  • query latency is lower - great for cold starts,
  • avoid runtime string interpolation or manual sanitization, which are common sources of SQL injection vulnerabilities. Prepared statements separate the query structure from the data, ensuring that user input is safely parameterized and never executed as part of the SQL logic.

A prepared statement might look like this:

const db = drizzle(...);
const prepared = db.select()
.from(users)
.prepare("statement_name");

const res1 = await prepared.execute();
const res2 = await prepared.execute();
const res3 = await prepared.execute();

And with a placeholder (when there is a need to embed a dynamic runtime value):

const u1 = db
  .select()
  .from(users)
  .where(eq(users.id, sql.placeholder('id')))
  .prepare("u1");

await u1.execute({ id: 1 }) // SELECT * FROM users WHERE id = 1
await u1.execute({ id: 2 }) // SELECT * FROM users WHERE id = 2

In contrast to Prisma, which generates and executes SQL at runtime through a compiled client, Drizzle’s prepared statements are closer to the database and offer significantly faster cold start times - a critical factor in serverless deployments like AWS Lambda.

4. No runtime magic - clean and declarative code

Drizzle makes no assumptions about how queries should behave. There is no hidden state, no complex client generation, and no abstraction layers between the code and SQL.

In Prisma, the .prisma file defines schema, and then a CLI must be run to generate the client. A similar process exists in Kysely, where type safety is typically achieved by generating TypeScript interfaces from the database schema using external tools. In both cases, maintaining synchronization between the source schema and the generated types requires an extra build step and additional tooling.

With Drizzle, on the other hand:

  • programmers declare schema and queries in plain TypeScript,
  • there is no client layer that might misinterpret the developer’s intent,
  • queries are always transparent - SQL structure is available and visible in code.

This leads to more reliable debugging and fewer surprises in production.

See the “Drizzle vs Prisma - different translation strategies” section below for a more in-depth comparison.

5. Minimal and composable design

Drizzle follows a modular, composable design philosophy. Instead of bundling everything into a monolithic toolchain, it offers small, focused tools:

  • drizzle-orm: the query builder,
  • drizzle-kit: the optional CLI for handling migrations,
  • adapters for specific databases (like pg, mysql, sqlite) - without unnecessary bloat.

This makes Drizzle:

  • easy to integrate with any backend framework (Express, Bun, Hono, etc.),
  • lightweight for edge and serverless deployments,
  • ideal for microservice architecture or modular monorepos.

This modular approach allows for flexible project structures and clean separation of concerns. Since Drizzle does not require a centralized code generation step or a monolithic client, it integrates well with service-oriented or layered architectures. It becomes easier to isolate database interactions to specific modules or packages, without enforcing a global runtime or shared client instance.

Additionally, by avoiding tightly coupled abstractions and adhering to standard TypeScript and SQL constructs, Drizzle remains relatively lightweight and adaptable across different setups, including traditional server environments, edge runtimes, and serverless platforms.

Drizzle vs Prisma - different translation strategies

While both Drizzle and Prisma aim to streamline database access, they do so in fundamentally different ways.

Prisma: follows a schema-first model. It focuses on simplicity and abstraction. The developers define a data model using a custom schema language, and Prisma generates a client with an OOP-style API. It hides SQL, but it can also lead to performance issues, reduced flexibility, and leaky abstractions.

Drizzle: takes SQL-first, schema-as-code, and TypeScript-native approach. Its philosophy is “If you know SQL, you know Drizzle”. It emphasizes transparency, control, and predictability. There is no client generation, no hidden behavior, and no runtime surprises. It is the ideal tool for developers who want to stay close to the database while benefiting from strong typing and IDE support.

Data modeling

Now, let’s see how a database schema is defined and how developers can reference it to query the data:

// Prisma ORM

model User {
  id         Int     @id @default(autoincrement())
  name       String?
  email      String  @unique
  articles Article[]
}

model Article {
  id        Int     @id @default(autoincrement())
  title     String
  content   String?
  published Boolean @default(false)
  authorId  Int?
  author    User?   @relation(fields: [authorId], references: [id])
}

Once that’s done, Prisma generates a type-safe client which can be used in the code:

import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()

async function main() {
  const user = await prisma.user.create({
    data: {
      email: 'john@doe.com',
      name: 'John',
      articles: {
        create: { title: 'Article no. 1' }
      }
    },
    include: { articles: true }
  })
}

This setup clearly isolates the schema from the rest of the application code. It provides a single source of truth. However, the drawback is that the developers need to run a code generation step whenever the schema is updated.

The Drizzle approach uses TypeScript programmatic API to define the schema:

import {
  boolean,
  integer,
  pgTable,
  serial,
  text,
  uniqueIndex,
  varchar,
} from 'drizzle-orm/pg-core'

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 256 }),
  email: varchar('email', { length: 256 }).unique(),
})

export const articles = pgTable('articles', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 256 }).notNull(),
  content: text('content'),
  published: boolean('published'),
  authorId: serial('author_id').references(() => users.id),
});

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

With Drizzle, I can work directly with these schema objects in my queries like:

async function main() {
  const user = await db.insert(users)
    .values({ email: 'john@doe.com', name: 'John' })
    .returning();

  await db.insert(articles)
    .values(
      { title: 'Article no. 1', authorId: user.id }
    );

  const userWithArticles = await db.query.users
    .findFirst({
      where: (users, { eq }) => eq(users.id, user.id),
   with: { articles: true }
    });
}

Drizzle leverages the TypeScript compiler to detect schema-related issues, allowing the application to immediately reflect any changes to the schema without the need for rebuilding or running code generation.

Relations

Managing relationships between tables is one of the complex aspects of working with databases, especially in a TypeScript environment. The way Prisma and Drizzle handle relationships shows their different philosophies around ORM design.

Prisma treats relationships as a fundamental part of its system. Those are directly embedded into both the schema and the generated client. Using Prisma Schema Language, developers define relationships in a clear and structured way:

model User {
  id         Int        @id @default(autoincrement())
  articles   Article[]   // One-to-many relationship
  profile    Profile?    // One-to-one relationship
}

model Article {
  id       Int  @id @default(autoincrement())
  author   User @relation(fields: [authorId], references: [id])
  authorId Int
}

model Profile {
  id     Int  @id @default(autoincrement())
  user   User @relation(fields: [userId], references: [id])
  userId Int  @unique
}

This relationship-first design carries through into how the developers write the queries. Creating, retrieving, and updating related data is done using a neat and nested syntax:

const user = await prisma.user.create({
  data: {
    email: 'john@doe.com',
    articles: {
      create: [{ title: 'Article no. 1' }]
    },
    profile: {
      create: { bio: 'The programmer' }
    }
  }
});

// Querying with relationship filters
const users = await prisma.user.findMany({
  where: {
    articles: {
      some: { published: true }
    }
  }
});

The advantage of Prisma’s system is how much it simplifies dealing with complex data operations. Developers can perform multi-level operations (like creating connected records) in a single query, while Prisma ensures data integrity and consistency in the background. There is no need to manually handle foreign keys or write multiple queries to keep things in sync.

Drizzle, on the other hand, takes a different approach. Tables and relationships are defined separately. The developers use the schema builder to define tables and a separate relations API to connect them:

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: text('email').notNull()
});

export const articles = pgTable('articles', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  authorId: serial('author_id').references(() => users.id)
});

// Relationship definitions
export const usersRelations = relations(users, ({ many }) => ({
  articles: many(articles)
}));

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

This design is more explicit - it separates the data structure from its associations. For querying related data, Drizzle offers a relational API that lets developers load associated records in a way that feels intuitive:

const userWithArticles = await db.query.users.findFirst({
  with: {
    articles: {
      where: eq(articles.published, true),
      limit: 5
    }
  }
});

The main distinction lies in how integrated relationships are. Prisma embeds them deeply into both data reads and writes, automating much of the heavy lifting. Drizzle offers more separation and requires the developer to be more deliberate, trading convenience for fine-grained control.

SQL transactions with the Drizzle ORM

Ensuring data consistency and integrity is a crucial concern in any application that performs multiple related operations on a database. When several queries must succeed or fail as a unit, transactional control becomes essential.

Consider a scenario where a content publishing system stores new articles in a database. Publishing an article might involve:

  • inserting the article record,
  • associating it with an author,
  • assigning a set of tags.

If the article is inserted, but assigning the tags fails (for example, due to a missing foreign key or constraint violation) the system may end up with incomplete data - an orphaned article without any context. This kind of inconsistency is difficult to detect and resolve after the fact.

To avoid such issues, SQL databases support transactions, which group a sequence of operations into a single atomic unit. Transactions follow the ACID principles:

  • Atomicity: all operations in the transactions either complete successfully or none are applied.
  • Consistency: the database remains in a valid state before and after the transaction.
  • Isolation: concurrent transactions do not interfere with each other’s intermediate state.
  • Durability: once committed, changes persist even in the case of system failure.

Transactions with Drizzle

Drizzle ORM provides full support for transactional operations using the underlying database adapter. In PostgreSQL, this is accomplished through a simple and intuitive API, where the transaction context is passed as an isolated tx instance (https://orm.drizzle.team/docs/transactions).

Here is an example of a transaction that inserts an article, assigns an author, and links tags - all within a single operation block:

const db = drizzle(...);

await db.transaction(async (tx) => {
  const [article] = await tx
    .insert(articles)
    .values({
      title: 'Understanding Drizzle Transactions',
      published: true,
    })
    .returning();

  await tx.insert(articleAuthors).values({
    articleId: article.id,
    authorId: '7d4ae4dc-faac-4d48-9320-6865110aa0d5',
  });

  await tx.insert(articleTags).values([
    { articleId: article.id, tagId: 'typescript' },
    { articleId: article.id, tagId: 'database' },
  ]);
});

If any of the operations inside the transaction block fails, all previous changes will be automatically rolled back. There is no need to manually issue ROLLBACK or COMMIT commands - Drizzle handles this behind the scenes.

This transactional model provides:

  • Safety: no partial writes will persist.
  • Clarity: transactional intent is visible and scoped.
  • Composability: any part of the application can safely use transactions without changing the database interface.

Savepoints and nested transactions

In more complex scenarios, it may be necessary to partially commit or roll back only specific portions of a transactional workflow. This is where savepoints and nested transactions become useful.

Drizzle ORM supports nested transactional operations by reusing tx object passed into the transactional block. If a nested block fails, only that part is rolled back - the outer transaction remains unaffected unless the error is propagated.

This allows building modular and resilient transactional flows. Here is an example:

const db = drizzle(...);

await db.transaction(async (tx) => {
  await tx.insert(logs).values(
    { message: 'Starting import...' }
  );

  try {
    await tx.transaction(async (tx2) => {
      await tx2.insert(importQueue).values(
        { fileId: '9bb1fceb' }
      );
      await tx2.insert(importResults).values(
        { fileId: 'cb1f1766', success: true }
      );
    });
  } catch (error) {
    await tx.insert(logs).values(
      { message: 'Import failed. Rolling back nested transaction.' }
    );
    // The nested transaction is rolled back, the outer one continues.
  }

  await tx.insert(logs).values(
    { message: 'Outer transaction continues...' }
  );
});

This structure:

  • avoids propagating failure unnecessarily,
  • helps modularize side effects,
  • gives fine-grained control over rollback behavior.

Although Drizzle allows nesting transaction blocks, full nested transactions are not natively supported by PostgreSQL. PostgreSQL permits only a single active transaction per connection. To simulate nested behavior, Drizzle internally uses SQL savepoints.

When a nested tx.transaction(...) is called, Drizzle issues a SAVEPOINT. If an error occurs, it performs a ROLLBACK TO SAVEPOINT. The outer transaction continues unaffected unless the error is rethrown.

This implementation provides a consistent developer experience across database backends while adhering to the actual transactional capabilities of PostgreSQL.

Practical summary of Drizzle ORM concepts

To wrap up this article, let’s have a look at the application I prepared to demonstrate all the key concepts covered above related to Drizzle ORM.

The core aspects of the application highlighted in this article include:

  • a clear separation between reading and writing responsibilities using two separate services - writer and reader,
  • centralized schema definition using Drizzle ORM located in a shared module,
  • strict database permission control at the PostgreSQL level using dedicated database users with limited privileges,
  • reusable, type-safe data access and validation logic shared across both services,
  • simple but meaningful REST API endpoints for both read and write operations,
  • docker-based setup with initialization scripts for bootstrapping users, roles and permissions.

Project structure

The repository is organised into three top-level folders:

shared/

It contains the Drizzle ORM schema definitions, validators, and shared logic for read-only endpoints such as:

  • GET /users
  • GET /users/:id
  • GET /activities
  • GET /activities/:id

These endpoints are used in both reader and writer.

reader/

A standalone application with read-only access. It imports the shared schema and endpoints but connects to the database using a read-only user that only has SELECT privileges.

writer/

Another standalone service that imports the shared schema and read endpoints, but also includes write operations such as:

  • POST /users
  • PUT /users/:id
  • PATCH /users/:id
  • DELETE /users/:id

It connects using a writer user with full privileges (SELECT, INSERT, UPDATE, DELETE).

Drizzle transactions

This project demonstrates the use of Drizzle ORM transactions. Two key examples showcase transaction usage:

  • user creation with automatic activity (POST /users),
  • user update with activity tracking (PATCH /users/:id).

Database roles and security

The PostgreSQL container is configured using docker-compose and the roles are initialized with SQL scripts (shared/sql/init-users.sql and shared/sql/grant-access.sql) that:

  • create two distinct users: writer_user and reader_user,
  • grant full privileges to the writer
  • grant read-only access to the reader,
  • revoke all write-related permissions from the reader,
  • apply these grants after the schema is migrated to ensure they’re correctly enforced.

This setup guarantees strict separation of concerns and avoids accidental mutations in the reader service.

Repository and how to run

The complete source code, along with setup instructions, can be found on GitHub: https://github.com/BartoszButrymSoftwareMill/user-activity-tracker.

Summary

Drizzle ORM is an excellent choice for building modern TypeScript applications with full control over the data layer. It combines type safety, performance, and simplicity in a way that fits naturally into today’s JavaScript ecosystem.

Drizzle is especially worth trying when creating microservices, deploying to the edge or simply when a lightweight and composable ORM is needed. You might find yourself writing less code, making fewer mistakes and enjoying your database layer again.

Blog Comments powered by Disqus.