
I Stopped Using ORMs. Then I Started Again. Here Is Why
I went through a phase a couple years back where I was convinced ORMs were net-negative and that every serious developer should write raw SQL. I read Brandur Leach's articles about Postgres, I watched the "ORM is an anti-pattern" talks, I felt righteous.
Then I spent six months writing raw SQL for everything and I changed my mind. Not completely but in the specific direction that matters.
The Case Against ORMs (Which I Still Believe)
The parts that drove me away were real.
ORMs encourage you to think about your data in application terms rather than relational terms. You end up writing:
typescript// Prisma const usersWithPosts = await prisma.user.findMany({ include: { posts: { include: { tags: true, }, }, }, })
This generates a query with JOINs. Fine. But what does the query plan look like? What's the N+1 situation if posts has a lot of records? You don't know without logging the queries and analyzing them which you should do, but most developers don't until something is slow.
The ORM is a leaky abstraction. When you need to understand performance, you need to understand the SQL. If you never wrote SQL in the first place, you can't.
The second thing: complex queries are where ORMs fall apart. Anything with window functions, CTEs, LATERAL joins, conditional aggregations you end up fighting the ORM's query builder to express what you want, or you resort to raw SQL inside the ORM anyway.
typescript// This is getting silly in ORM-land const result = await prisma.$queryRaw` WITH ranked_posts AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) as rn FROM posts WHERE published = true ) SELECT * FROM ranked_posts WHERE rn = 1 `
If you're writing raw SQL for your complex queries anyway, what's the ORM buying you?
What Six Months of Raw SQL Taught Me
Mostly that I was writing the same code over and over.
Every query needs: parameter sanitization, mapping results to typed objects, handling nullable columns, pagination (always LIMIT and OFFSET, sometimes cursor-based), error handling that translates DB errors to app errors.
Here is a function I wrote about four hundred times:
typescriptasync function getUser(id: string): Promise<User | null> { const result = await pool.query( 'SELECT id, email, name, created_at FROM users WHERE id = $1', [id] ) if (result.rows.length === 0) return null const row = result.rows[0] return { id: row.id, email: row.email, name: row.name, createdAt: row.created_at, // snake_case → camelCase, manually } }
Then I'd write the same pattern for getUserByEmail, getUsersByOrg, getActiveUsers. The boilerplate is not the hard part. The boilerplate is the part where I make a typo and map created_at to updatedAt and spend twenty minutes debugging it.
The Position I've Actually Landed On
Use a query builder for simple CRUD and relation traversal. Write raw SQL for anything complex. Don't use the ORM's magic for things that benefit from being explicit.
My current stack for Postgres in Node.js:
Drizzle for schema definition, migrations, and simple queries. Drizzle generates SQL you can read, TypeScript types from your schema, and doesn't hide what it's doing.
Raw SQL with postgres.js for anything that needs CTEs, window functions, or fine-grained query planning.
Here's what Drizzle looks like:
typescript// schema.ts import { pgTable, uuid, text, timestamp, boolean } from 'drizzle-orm/pg-core' export const users = pgTable('users', { id: uuid('id').primaryKey().defaultRandom(), email: text('email').notNull().unique(), name: text('name').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), }) export const posts = pgTable('posts', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').notNull().references(() => users.id), title: text('title').notNull(), body: text('body').notNull(), published: boolean('published').default(false).notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), })
typescript// queries.ts import { db } from './db' import { users, posts } from './schema' import { eq, and, desc } from 'drizzle-orm' // Simple query Drizzle handles this cleanly export async function getUserById(id: string) { return db.select().from(users).where(eq(users.id, id)).limit(1) } // Relation query also fine export async function getUserWithRecentPosts(userId: string) { return db .select({ user: users, post: posts, }) .from(users) .leftJoin(posts, and( eq(posts.userId, users.id), eq(posts.published, true) )) .where(eq(users.id, userId)) .orderBy(desc(posts.createdAt)) .limit(5) }
Now here's the same data layer for a complex query I just drop to SQL:
typescriptimport { sql } from 'drizzle-orm' // Get each user's most recent published post (window function) export async function getUsersWithLatestPost() { return db.execute(sql` WITH ranked AS ( SELECT u.id as user_id, u.name, u.email, p.id as post_id, p.title, p.created_at, ROW_NUMBER() OVER ( PARTITION BY u.id ORDER BY p.created_at DESC ) as rn FROM users u LEFT JOIN posts p ON p.user_id = u.id AND p.published = true ) SELECT user_id, name, email, post_id, title, created_at FROM ranked WHERE rn = 1 ORDER BY created_at DESC NULLS LAST `) }
Both live in the same file. The schema types flow through. The migrations are SQL files that Drizzle generates from your schema readable, reviewable, runnable manually if needed.
The Thing That Actually Made Me Come Back
Migrations.
Writing migrations by hand is fine until you have six developers making schema changes and you need to coordinate the order, handle rollbacks, and keep environments in sync. Even a minimal migration runner tracking which migrations have run, running new ones in order is infrastructure you don't want to maintain yourself.
Drizzle generates migrations from schema diffs:
bashdrizzle-kit generate:pg # generates SQL migration from schema changes drizzle-kit push:pg # applies pending migrations
The generated SQL is human-readable. You can review it before committing. You can modify it if the auto-generated migration isn't quite right. It's not magic.
What I'd Tell My Raw-SQL Self
You were right about knowing SQL. You were wrong about never using abstractions.
The goal is to write application logic, not database plumbing. A tool that handles the mapping layer while keeping the SQL visible is not a betrayal of your principles. It's engineering.
Pick an ORM or query builder where you can see the SQL it's generating. Use it for the things it handles well. Write raw SQL for the things it doesn't. Know enough SQL to understand both.
That's it.