Node.js Interview Prep
Database Integration

ORMs & Query Builders -- Prisma, Drizzle, Sequelize, Knex

ORMs & Query Builders -- Prisma, Drizzle, Sequelize, Knex

LinkedIn Hook

"Your dashboard loads in 8 seconds. The database is healthy. The API is fast in isolation. So why is it slow?"

Nine times out of ten, the answer is the same three letters: N+1.

You wrote users.findMany() and then mapped over the result to fetch each user's posts. It looked clean. It looked idiomatic. It quietly fired 1 query for users and then 500 more queries for posts. Your ORM did not warn you. Your tests did not catch it. Production noticed.

This is the single most common database performance bug in Node.js -- and the modern ORMs (Prisma, Drizzle, Sequelize) all give you tools to fix it. But only if you know what to look for.

In Lesson 7.2, I break down Prisma vs Drizzle vs Sequelize vs Knex, when raw SQL is the right call, and how to kill N+1 queries with eager loading and DataLoader.

Read the full lesson -> [link]

#NodeJS #Prisma #Drizzle #Database #BackendDevelopment #InterviewPrep


ORMs & Query Builders -- Prisma, Drizzle, Sequelize, Knex thumbnail


What You'll Learn

  • How Prisma's schema-first approach generates a fully type-safe client
  • How Drizzle takes a SQL-first approach with TypeScript inference
  • How Sequelize models classes-as-tables in the traditional ORM style
  • How Knex.js provides a thin query builder without ORM overhead
  • The trade-offs between raw SQL, query builders, and full ORMs
  • The N+1 query problem -- what causes it and how to fix it
  • Eager loading with include/with and batching with DataLoader
  • How to pick the right tool for your project size and team

The Restaurant Order Analogy -- Why ORMs Exist

Imagine a restaurant where every waiter speaks a different language than the kitchen. The waiter takes an order in English, but the cook only reads French. Without a translator, every plate requires a fragile, hand-written conversion -- and one typo means the wrong dish.

Now imagine you hire a translator who stands between the dining room and the kitchen. The waiter speaks naturally; the translator turns it into perfect French. Better yet, the translator catches mistakes before the cook even sees them: "Sir, we don't have salmon today" -- before the order is even placed.

That translator is your ORM. Your application speaks objects, methods, and types. The database speaks SQL, rows, and constraints. The ORM stands in the middle, translating both ways, catching type errors at compile time, and freeing you from writing the same boilerplate SELECT * FROM users WHERE id = ? a thousand times.

But translators have a cost. Sometimes the waiter wants to say something the translator can't express. Sometimes the translator adds words the cook doesn't need, slowing service. And sometimes -- the famous N+1 problem -- the translator runs back and forth to the kitchen 500 times when one trip would have done.

A query builder like Knex is a lighter helper. It doesn't translate ideas; it just hands you a fluent way to spell SQL. You stay in control of every query, you just don't have to concatenate strings.

+---------------------------------------------------------------+
|           THE SPECTRUM: RAW SQL -> ORM                        |
+---------------------------------------------------------------+
|                                                                |
|  RAW SQL          KNEX           DRIZZLE      PRISMA          |
|  (pg, mysql2)     (builder)      (SQL-first)  (schema-first)  |
|                                                                |
|  +----+           +-------+      +--------+   +----------+    |
|  | "" |  ----->   | .from |----->| select |-->| findMany |    |
|  +----+           +-------+      +--------+   +----------+    |
|                                                                |
|  more control                            more abstraction     |
|  more boilerplate                        more conventions     |
|  fewer types                             fully typed          |
|                                                                |
|  Sequelize sits near Prisma but uses class models             |
|  instead of a schema file.                                     |
|                                                                |
+---------------------------------------------------------------+

Napkin AI Visual Prompt: "Dark gradient (#0a1a0a -> #0d2e16). Horizontal spectrum from left (raw SQL, amber #ffb020) to right (Prisma, Node green #68a063). Four boxes labeled 'Raw SQL', 'Knex', 'Drizzle', 'Prisma' with Sequelize floating above between Drizzle and Prisma. Below: two arrows -- top arrow 'Control' pointing left, bottom arrow 'Productivity' pointing right. White monospace labels."


Prisma -- Schema-First and Type-Safe

Prisma's defining idea is the schema file. You declare your data model in a single schema.prisma file, run prisma generate, and Prisma writes a fully type-safe client tailored to your exact tables. Every query you write is autocompleted, every field is typed, every relation is validated at compile time.

The Schema File

// prisma/schema.prisma
// Define which database engine and where the connection lives
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// Tell Prisma to generate the JS/TS client
generator client {
  provider = "prisma-client-js"
}

// A User has many Posts (one-to-many relation)
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  createdAt DateTime @default(now())
  posts     Post[]   // Virtual field -- the related Post records
}

// A Post belongs to one User
model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  authorId  Int      // Foreign key column
  author    User     @relation(fields: [authorId], references: [id])
}

Querying with the Generated Client

// src/users.ts
import { PrismaClient } from '@prisma/client';

// One client per process -- it manages its own connection pool
const prisma = new PrismaClient();

// Create a user along with two posts in a single transaction
async function createUserWithPosts() {
  const user = await prisma.user.create({
    data: {
      email: 'ada@example.com',
      name: 'Ada Lovelace',
      // Nested writes -- Prisma handles the foreign keys for you
      posts: {
        create: [
          { title: 'Notes on the Analytical Engine', published: true },
          { title: 'On Bernoulli numbers', published: false },
        ],
      },
    },
    // Tell Prisma to return the related posts in the same response
    include: { posts: true },
  });

  // user.posts is fully typed -- TS knows every field
  console.log(user.posts[0].title);
}

What makes Prisma special:

  1. Migrations are first class. prisma migrate dev diffs your schema against the database and writes a SQL migration file you can review and check in.
  2. The client is generated, not introspected at runtime. Types are static, so editor autocomplete is instant and bundle size is small.
  3. Nested reads and writes are expressed as plain objects -- no need to write joins by hand.
  4. Prisma Studio gives you a browser-based GUI for your data with zero setup.

The trade-off: Prisma owns the schema, and complex SQL features (CTEs, window functions, custom types) are limited or require dropping to $queryRaw.


Drizzle -- The SQL-First Alternative

Drizzle is the newest entry and takes the opposite philosophy from Prisma. Instead of a schema DSL, you declare tables in TypeScript and write queries that look almost exactly like SQL. There is no code generation step, no separate query engine, and the query builder is a thin layer over the wire protocol.

// src/db/schema.ts
import { pgTable, serial, varchar, integer, boolean } from 'drizzle-orm/pg-core';

// Tables are plain TS objects -- the types are inferred automatically
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  name: varchar('name', { length: 100 }),
});

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 200 }).notNull(),
  authorId: integer('author_id').references(() => users.id),
});
// src/db/queries.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { eq } from 'drizzle-orm';
import { Pool } from 'pg';
import { users, posts } from './schema';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);

// The query reads almost like SQL, but every column is typed
async function getPublishedPostsByEmail(email: string) {
  return db
    .select({ title: posts.title, author: users.name })
    .from(posts)
    .innerJoin(users, eq(posts.authorId, users.id))
    .where(eq(users.email, email));
}

Why people pick Drizzle: zero runtime overhead, no separate query engine binary (Prisma ships a Rust engine), works in edge runtimes like Cloudflare Workers, and the API maps 1:1 to SQL so there is no abstraction to fight when queries get complex.

Why people don't: smaller ecosystem, fewer migration tooling features, and the SQL-shaped API can feel verbose for simple CRUD compared to prisma.user.findMany().


Sequelize -- The Traditional ORM

Sequelize predates Prisma and Drizzle by a decade and follows the classic Active Record pattern: tables are classes, rows are instances, and you call methods like .save() and .destroy() on objects. It is dynamic JavaScript at heart, with TypeScript types layered on top.

// src/models/user.js
const { DataTypes, Model } = require('sequelize');
const sequelize = require('./connection'); // A configured Sequelize instance

// Define a model by extending Model and calling .init()
class User extends Model {}

User.init(
  {
    id: {
      type: DataTypes.INTEGER,
      autoIncrement: true,
      primaryKey: true,
    },
    email: {
      type: DataTypes.STRING,
      allowNull: false,
      unique: true,
    },
    name: DataTypes.STRING,
  },
  {
    sequelize,        // The connection instance
    modelName: 'User',
    tableName: 'users',
    timestamps: true, // Adds createdAt and updatedAt automatically
  }
);

class Post extends Model {}
Post.init(
  {
    title: { type: DataTypes.STRING, allowNull: false },
    content: DataTypes.TEXT,
  },
  { sequelize, modelName: 'Post', tableName: 'posts' }
);

// Declare the relationship -- Sequelize adds the foreign key column
User.hasMany(Post, { foreignKey: 'authorId', as: 'posts' });
Post.belongsTo(User, { foreignKey: 'authorId', as: 'author' });

module.exports = { User, Post };
// src/services/userService.js
const { User, Post } = require('../models/user');

// Create a user, then attach a post via the instance method
async function createAdmin() {
  const admin = await User.create({ email: 'admin@example.com', name: 'Admin' });

  // createPost is auto-generated by the hasMany association
  await admin.createPost({ title: 'Welcome to the site' });

  // Eager load the posts back -- include avoids the N+1 trap
  const fresh = await User.findByPk(admin.id, {
    include: [{ model: Post, as: 'posts' }],
  });

  return fresh;
}

Sequelize works well for older codebases and developers coming from Rails or Django. The downsides are real though: TypeScript support is bolted on, the API surface is enormous, and runtime errors that should be compile-time errors slip through.


Knex.js -- The Query Builder

Knex is not an ORM. It is a query builder -- a fluent JavaScript API that produces SQL strings. There are no models, no schemas you must keep in sync, no relationships to declare. You write queries that look like SQL but compose like JavaScript, and Knex handles parameter binding, dialect differences, and a battle-tested migration runner.

// src/db/knex.js
const knex = require('knex')({
  client: 'pg',
  connection: process.env.DATABASE_URL,
  pool: { min: 2, max: 10 },
});

module.exports = knex;
// src/db/queries.js
const knex = require('./knex');

// SELECT with WHERE, ORDER BY, and LIMIT
async function recentPostsByAuthor(authorId) {
  return knex('posts')
    .select('id', 'title', 'created_at')
    .where({ author_id: authorId, published: true })
    .orderBy('created_at', 'desc')
    .limit(20);
}

// INSERT with RETURNING (Postgres)
async function addPost(authorId, title) {
  const [row] = await knex('posts')
    .insert({ author_id: authorId, title, published: false })
    .returning(['id', 'title']);
  return row;
}

// JOIN -- a single query, no ORM magic
async function postsWithAuthors() {
  return knex('posts')
    .join('users', 'users.id', '=', 'posts.author_id')
    .select('posts.id', 'posts.title', 'users.name as author');
}

// Transactions -- Knex passes a transactional builder to the callback
async function transferOwnership(postId, newAuthorId) {
  await knex.transaction(async (trx) => {
    await trx('posts').where({ id: postId }).update({ author_id: newAuthorId });
    await trx('audit_log').insert({ action: 'transfer', post_id: postId });
  });
}

Knex hits a sweet spot for teams that want SQL fluency without string concatenation. It is also the foundation many other libraries (Objection.js, Bookshelf) build on top of. The catch: there are no types for your tables out of the box -- a select('titel') typo only fails at runtime.


The N+1 Query Problem -- The Bug Every ORM Hides

The N+1 problem is the single most expensive performance bug in ORM-based applications. It happens when you fetch a list of N parent records and then issue one additional query per parent to fetch its children. One query becomes N+1 queries -- 1 for the list, N for the children.

The Bug

// BAD -- Classic N+1
// Step 1: One query for all users
const users = await prisma.user.findMany();

// Step 2: For each user, ANOTHER query for their posts
for (const user of users) {
  // This fires a SELECT * FROM posts WHERE author_id = ? every iteration
  const posts = await prisma.post.findMany({ where: { authorId: user.id } });
  console.log(`${user.name} has ${posts.length} posts`);
}

// If users.length is 500, you just sent 501 queries to the database.
// Each query has its own network round trip. The page takes 8 seconds.

The Fix -- Eager Loading

// GOOD -- Single query with `include`
const users = await prisma.user.findMany({
  // Tell Prisma to fetch related posts in the SAME round trip
  // Under the hood Prisma issues 2 queries: one for users, one
  // for posts WHERE author_id IN (...) -- not N+1.
  include: { posts: true },
});

for (const user of users) {
  // No new query -- the posts are already loaded
  console.log(`${user.name} has ${user.posts.length} posts`);
}

Every modern ORM has its own name for this:

  • Prisma: include: { posts: true }
  • Drizzle: db.query.users.findMany({ with: { posts: true } })
  • Sequelize: User.findAll({ include: [Post] })
  • TypeORM: relations: ['posts']

The Other Fix -- DataLoader

When the query doesn't fit a simple include (for example, in a GraphQL resolver where each field is resolved independently), use DataLoader. It batches calls made within the same tick of the event loop into one query and caches results per request.

// src/loaders/postLoader.ts
import DataLoader from 'dataloader';
import { prisma } from '../db';

// The batch function receives all keys collected during one tick
// and must return results in the SAME order as the keys.
export function makePostLoader() {
  return new DataLoader<number, Post[]>(async (authorIds) => {
    // ONE query for all authors, no matter how many resolvers asked
    const posts = await prisma.post.findMany({
      where: { authorId: { in: [...authorIds] } },
    });

    // Group posts by authorId so we can return them in key order
    const byAuthor = new Map<number, Post[]>();
    for (const p of posts) {
      const list = byAuthor.get(p.authorId) ?? [];
      list.push(p);
      byAuthor.set(p.authorId, list);
    }

    return authorIds.map((id) => byAuthor.get(id) ?? []);
  });
}

// In a GraphQL resolver -- 100 calls to .load(id) become 1 query
// User.posts = (parent, _args, ctx) => ctx.postLoader.load(parent.id);
+---------------------------------------------------------------+
|                  N+1 BEFORE vs AFTER                          |
+---------------------------------------------------------------+
|                                                                |
|  BEFORE (N+1):                                                 |
|  SELECT * FROM users;                       <-- 1 query        |
|  SELECT * FROM posts WHERE author_id = 1;   <-- query 2        |
|  SELECT * FROM posts WHERE author_id = 2;   <-- query 3        |
|  SELECT * FROM posts WHERE author_id = 3;   <-- query 4        |
|  ... (500 more) ...                                            |
|  Total: 501 queries, 501 round trips                           |
|                                                                |
|  AFTER (eager loading):                                        |
|  SELECT * FROM users;                                          |
|  SELECT * FROM posts WHERE author_id IN (1,2,3,...);           |
|  Total: 2 queries, 2 round trips                               |
|                                                                |
|  AFTER (DataLoader in GraphQL):                                |
|  Same 2 queries, automatically batched per request             |
|                                                                |
+---------------------------------------------------------------+

Raw SQL vs ORM -- The Trade-Offs

+---------------------------------------------------------------+
|         RAW SQL vs QUERY BUILDER vs ORM                       |
+---------------------------------------------------------------+
|                                                                |
|  Aspect          Raw SQL    Knex       Drizzle    Prisma      |
|  -------------- ---------- ---------- ---------- ----------    |
|  Type safety     none       weak       full       full         |
|  Boilerplate     high       medium     low        very low     |
|  SQL control     total      total      high       medium       |
|  Migrations      manual     built-in   built-in   built-in     |
|  Learning curve  low        low        medium     medium       |
|  Edge runtime    yes        yes        yes        limited      |
|  Bundle size     tiny       small      small      large        |
|  CTEs / windows  yes        yes        yes        $queryRaw    |
|                                                                |
+---------------------------------------------------------------+

Pick raw SQL when performance is critical, the schema is small, or the queries use database-specific features (Postgres tsvector, materialized views, recursive CTEs).

Pick Knex when you want SQL fluency, control over every query, and a proven migration tool -- but you don't need an ORM.

Pick Drizzle when you want type safety, edge runtime support, and an API that doesn't hide SQL.

Pick Prisma when developer velocity matters most, the team is mid-sized, and the schema is the source of truth.

Pick Sequelize when you are maintaining an existing project that already uses it, or when class-based Active Record fits your team's mental model.


Common Mistakes

1. Walking a list and querying inside the loop (the N+1 trap). Every for (const x of items) { await find(x.id) } is a red flag. Use include, with, or WHERE id IN (...) to fetch children in a single round trip. In GraphQL, use DataLoader to batch automatically.

2. Creating a new ORM client per request. Instantiate Prisma, Drizzle, or Sequelize once at module scope. Each client owns its own connection pool. Creating one per request leaks pools, exhausts the database, and crashes under load.

3. Forgetting to handle migrations in production. Running prisma migrate dev on production data is dangerous -- it can drop tables. Use prisma migrate deploy in CI/CD, and always review generated migration files before merging.

4. Selecting all columns when you need three. findMany() returns every column by default. On wide tables this wastes bandwidth and breaks query plans. Use select: { id: true, name: true } (Prisma) or projection in your query builder.

5. Using transactions only for writes. If you read a row, decide based on its value, then write -- you need a transaction with the right isolation level. Otherwise two requests can both pass the check and both write, corrupting state.

6. Treating the ORM as a magic database. Every method call becomes SQL. Turn on query logging in development (log: ['query'] in Prisma) and read what your code actually emits. You will catch N+1 queries, missing indexes, and wasted joins immediately.


Interview Questions

1. "What is the N+1 query problem and how do you fix it in Prisma?"

The N+1 problem happens when you fetch a list of N parent records with one query, then issue an additional query for each parent to fetch its related children -- ending up with 1 + N queries instead of 2. The classic example is fetching a list of users and then looping over them to fetch each user's posts. The fix in Prisma is to use include or select with a relation: prisma.user.findMany({ include: { posts: true } }). Under the hood Prisma issues two queries -- one for the users and one for posts WHERE authorId IN (...) -- regardless of how many parents you have. For more dynamic cases like GraphQL resolvers, use DataLoader to batch and cache requests within a single tick of the event loop.

2. "When would you choose Prisma over Drizzle, and vice versa?"

Choose Prisma when you want maximum developer velocity, a polished migration workflow, and a high-level API that hides SQL behind nested object reads and writes. The schema-first approach is great for teams with mixed skill levels because the schema file becomes documentation. Choose Drizzle when you need edge runtime support (Cloudflare Workers, Vercel Edge), zero runtime overhead, or when your queries are complex enough that Prisma's abstraction starts fighting you. Drizzle's API mirrors SQL, so there is no impedance mismatch when you need CTEs, window functions, or custom joins. Drizzle is also lighter -- there is no separate Rust query engine binary to ship.

3. "What is the difference between a query builder and an ORM?"

A query builder like Knex provides a fluent JavaScript API that produces SQL strings. It does not know about your domain model, does not manage relationships, and does not return typed objects -- you stay in control of every query. An ORM like Prisma or Sequelize maps database tables to application objects, manages relationships, handles eager loading, and often performs identity tracking and lifecycle hooks. The trade-off is control versus convenience. Query builders give you SQL fluency without string concatenation but no domain modeling. ORMs give you a high-level mental model but introduce abstraction that can hide performance problems and limit access to advanced SQL features.

4. "How does DataLoader prevent the N+1 problem and when would you use it instead of eager loading?"

DataLoader is a small library that wraps a batch-loading function and a per-request cache. When you call .load(id), it doesn't query immediately -- it collects all calls made during the current tick of the event loop, then invokes your batch function once with the full list of keys. Your batch function issues a single WHERE id IN (...) query and returns results in the same order as the keys. Use DataLoader instead of eager loading when you don't know up front which relations are needed -- the canonical case is GraphQL, where each field resolver runs independently and you can't pre-declare an include. Eager loading is the right choice for REST endpoints where you know exactly which related data the response needs.

5. "When is raw SQL the right choice over an ORM?"

Raw SQL wins in three situations. First, when the query uses database-specific features the ORM does not expose well -- recursive CTEs, window functions, full-text search with tsvector, JSON path operators, materialized views. Second, when performance is critical and you need to control the exact query plan, including index hints, the order of joins, or batch sizes -- ORMs sometimes generate queries the planner handles poorly. Third, when the schema is small and the team is fluent in SQL -- the boilerplate cost of an ORM outweighs its productivity benefit. Even on ORM-based projects, it is normal to drop to raw SQL (prisma.$queryRaw, knex.raw) for the few queries that need it. The right answer is rarely "ORM only" or "SQL only" -- it is to use the ORM for the 95% of CRUD code and raw SQL for the 5% that demands it.


Quick Reference -- ORM & Query Builder Cheat Sheet

+---------------------------------------------------------------+
|           PRISMA CHEAT SHEET                                  |
+---------------------------------------------------------------+
|                                                                |
|  SETUP:                                                        |
|  npx prisma init                                               |
|  npx prisma migrate dev --name init                            |
|  npx prisma generate                                           |
|                                                                |
|  QUERIES:                                                      |
|  prisma.user.findMany({ where: { active: true } })             |
|  prisma.user.findUnique({ where: { id: 1 } })                  |
|  prisma.user.create({ data: { email, name } })                 |
|  prisma.user.update({ where: { id }, data: { name } })         |
|  prisma.user.delete({ where: { id } })                         |
|                                                                |
|  EAGER LOAD:                                                   |
|  prisma.user.findMany({ include: { posts: true } })            |
|                                                                |
|  RAW SQL ESCAPE HATCH:                                         |
|  prisma.$queryRaw`SELECT * FROM users WHERE id = ${id}`        |
|                                                                |
+---------------------------------------------------------------+

+---------------------------------------------------------------+
|           DRIZZLE CHEAT SHEET                                 |
+---------------------------------------------------------------+
|                                                                |
|  db.select().from(users).where(eq(users.id, 1))                |
|  db.insert(users).values({ email, name }).returning()          |
|  db.update(users).set({ name }).where(eq(users.id, 1))         |
|  db.delete(users).where(eq(users.id, 1))                       |
|                                                                |
|  RELATIONAL:                                                   |
|  db.query.users.findMany({ with: { posts: true } })            |
|                                                                |
+---------------------------------------------------------------+

+---------------------------------------------------------------+
|           KNEX CHEAT SHEET                                    |
+---------------------------------------------------------------+
|                                                                |
|  knex('users').select('*').where({ active: true })             |
|  knex('users').insert({ email }).returning('id')               |
|  knex('users').where({ id }).update({ name })                  |
|  knex('users').where({ id }).del()                             |
|                                                                |
|  JOIN:                                                         |
|  knex('posts').join('users', 'users.id', 'posts.author_id')    |
|                                                                |
|  TRANSACTION:                                                  |
|  knex.transaction(async (trx) => { ... })                      |
|                                                                |
+---------------------------------------------------------------+

+---------------------------------------------------------------+
|           N+1 RULES                                            |
+---------------------------------------------------------------+
|                                                                |
|  1. Never await inside a for-loop over query results           |
|  2. Use include / with / relations to eager load               |
|  3. In GraphQL, use DataLoader for per-request batching        |
|  4. Turn on query logging in dev to spot the bug early         |
|  5. Add an index on every foreign key column                   |
|  6. Project only the columns you need (select, not *)          |
|  7. Measure -- count queries per endpoint, not just latency    |
|                                                                |
+---------------------------------------------------------------+
ToolTypeType SafetyMigrationsBest For
PrismaORM (schema-first)Full (generated)Built-in, polishedRapid app development
DrizzleORM (SQL-first)Full (inferred)Built-in (drizzle-kit)Edge runtimes, complex SQL
SequelizeORM (Active Record)Partial (bolted on)Built-in (umzug)Legacy projects
KnexQuery builderNone by defaultBuilt-in, matureSQL fluency without ORM
Raw SQL (pg)DriverNoneManualMax performance, advanced SQL

Prev: Lesson 7.1 -- Connecting to Databases Next: Lesson 7.3 -- Database Patterns


This is Lesson 7.2 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.

On this page