Warming up the neural circuits...
By the end of this chapter you will:
ORMs "never write SQL again." That promise is a lie — and also the wrong goal. A good ORM doesn't replace SQL; it augments it with type safety, migrations, and ergonomic query building. A bad ORM generates terrible queries and hides them from you. This chapter teaches you which is which.
Raw SQL is a manual transmission — total control, but you can grind gears (SQL injection, missing WHERE clauses). An ORM is an automatic — it handles the routine stuff, but sometimes it picks the wrong gear for a hill. A great driver knows both: automatic for commuting (), manual for performance driving (complex reporting queries).
Prisma and Drizzle are the "dual-clutch" automatics — almost as fast as manual, with type safety. Mongoose is a different kind of vehicle entirely (document-based, not relational). Know when to use each.
Prisma is the most popular Node.js ORM. You define your schema in prisma/schema.prisma and Prisma generates a fully type-safe client.
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
// Usage — fully typed
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Create
const user = await prisma.user.create({
data: {
Prisma's strengths:
prisma migrate dev)include and select make eager loading explicit and type-safePrisma's weaknesses:
JOIN in the query — uses include which can trigger separate queriesDrizzle is the new contender. It feels like writing SQL, but with 100% type safety.
import { pgTable, serial, text, integer, boolean, timestamp } from 'drizzle-orm/pg-core';
import { eq, desc } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/node-postgres
Drizzle's strengths:
Drizzle's weaknesses:
includeMongoose is a schema-based ODM (Object-Document Mapper) for MongoDB.
import mongoose, { Schema, Document } from 'mongoose';
interface IUser extends Document {
email: string;
name: string;
createdAt: Date;
}
const
Mongoose strengths: hooks, schema , populate() for references.
Mongoose weaknesses: Slower than the native MongoDB driver, schema overhead defeats MongoDB's "schemaless" advantage, populate() is not a real JOIN — it makes separate queries.
| Scenario | Best choice | Why |
|---|---|---|
| Rapid prototyping, standard CRUD | Prisma | Best DX, auto-generated types, migrations |
| Performance-critical, complex queries | Drizzle | SQL control with type safety |
| MongoDB stack | Mongoose | Best MongoDB ODM, though native driver + validation layer also works |
| Complex reporting, analytics | Raw SQL | ORMs generate suboptimal queries for multi-table aggregations |
| Payment/billing code | Raw SQL or Drizzle | You need exact control over transactions and locking |
| Migration-heavy project | Prisma | Best migration system |
| Serverless / edge functions | Drizzle | Smaller bundle, faster cold start |
Use Prisma or Drizzle for 80% of queries (CRUD, simple JOINs). Use raw SQL (via $queryRaw or db.execute) for the 20% that need hand-tuned queries. This is what Stripe, GitHub, and most production teams do. Don't be a purist — use the right tool for each query.
Prisma's include looks like a JOIN but sometimes generates multiple queries:
// ❌ N+1: Prisma may execute 1 query for users + N queries for posts
const users = await prisma.user.findMany({
include: { posts: true }
});
// ✅ Use select with a specific shape (Prisma may optimize to a JOIN)
// ✅ Or batch: fetch users, collect IDs, fetch all posts in one query
const users = await
The pattern: as companies scale, they tend to move away from ORMs for critical paths and keep ORMs for internal/admin tooling.
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
Using include everywhere in Prisma without checking the generated SQL | Prisma sometimes fires N+1 queries silently | Use Prisma's logging (log: ['query']) to see what SQL is generated. Test with realistic data volumes. |
| ORM for everything, never writing SQL | You never learn what the database is actually doing. When something is slow, you're helpless. | Know SQL. Use the ORM for convenience, not as a crutch. |
| Picking an ORM because "it's what the tutorial used" | The ORM you learn first becomes a hammer; every problem looks like a nail | Evaluate ORMs based on your project's actual needs: query complexity, team SQL fluency, performance requirements |
| Ignoring connection pooling in the ORM config | ORMs default to few connections. Under load, queries up waiting for a connection. | Set connection_limit in Prisma or max in Drizzle's pool config. Test with production-like concurrency. |
| Using ORM migrations without reviewing the generated SQL | ORMs sometimes generate surprising DDL — wrong column types, missing indexes, table locks | Always review the migration SQL before committing. prisma migrate dev --create-only then inspect. |
log: ['query', 'info', 'warn', 'error']. Drizzle: pass a logger. This is how you catch N+1s and slow queries before production.connection_limit = num_cpus * 2 + 1. For serverless, use Prisma Data Proxy or pgbouncer in front of your database.drizzle-kit generate then run the SQL files manually or via a custom migration script.lean() for read-only queries to skip .$queryRaw and Drizzle's sql template allow raw SQL — these are injection vectors if you concatenate user . Always use parameterized forms.Task model with id, title, done, createdAt. Write CRUD operations. Use Prisma Studio to inspect data.Task schema. Write equivalent CRUD operations. Compare the developer experience.include. Observe the generated queries. Rewrite to use a single query or batch pattern.EXPLAIN ANALYZE to compare query plans.UserRepository) with CRUD methods. Implement it with Prisma and Drizzle. Swap implementations with zero code changes in the of the app.What is an ORM? Object-Relational Mapper — a library that maps database tables to classes/objects in your programming language, letting you query the database using your language instead of SQL.
Name three popular Node.js ORMs and what database each targets. Prisma (PostgreSQL, MySQL, SQLite, SQL Server), Drizzle (PostgreSQL, MySQL, SQLite), Mongoose (MongoDB). TypeORM and Sequelize are older alternatives.
What is an N+1 query? When an ORM executes 1 query to fetch N records, then N additional queries to fetch related data — totaling N+1 queries. Avoid by using eager loading (include, with) or batch queries.
When would you use raw SQL over an ORM? For complex reporting queries (multi-table aggregations, window functions), payment/billing code that requires exact control over locking and isolation levels, performance-critical hot paths where ORM overhead matters, and data migrations that need precise control.
Compare Prisma and Drizzle — when would you choose each? Prisma: rapid prototyping, teams that value DX over raw performance, projects with heavy migration needs. Drizzle: performance-critical applications, teams comfortable with SQL who want type safety, serverless/edge deployments where cold start matters, complex query requirements where you need JOIN/CTE/window function control.
How do you prevent ORM-generated queries from being slow in production? Enable query logging. Review generated SQL for every new query. Use EXPLAIN ANALYZE on the generated SQL. Set up slow query monitoring. For Prisma, use $queryRaw when the generated query is suboptimal. For Drizzle, the generated SQL is nearly identical to hand-written SQL, so the concern is smaller.
ORMs trade a small performance cost for massive developer experience gains. Prisma is the most polished — great for teams that want schema-first development and excellent tooling. Drizzle is the most SQL-like — great for developers who want type-safe SQL control. Mongoose is the MongoDB standard. The real-world pattern is hybrid: ORM for 80% of queries, raw SQL for the performance-critical 20%. Never let the ORM be a black box — always know what SQL it generates.
include for relations. Watch for N+1.populate() not a real JOIN.include do? Eagerly loads related records. May generate a JOIN or may generate a separate query — check the logs.lean() do? Returns plain objects instead of Mongoose documents — skips hydration, validation, and getters/setters. Faster for read-only queries.