ORMs & Database Tools
The layer between your code and SQL. Drizzle, Prisma, Kysely — and when raw SQL is the right answer.
ORMs & Database Tools
In one line: An ORM lets you write queries in your programming language instead of raw SQL. Drizzle is the rising 2026 leader; Prisma is the established choice; Kysely is for SQL purists who want type safety.
An ORM ("Object-Relational Mapper") is a layer that translates between your code's objects and your database's tables. Instead of writing SELECT * FROM users WHERE id = 42, you write db.user.findById(42). The ORM handles the SQL generation, the connection pooling, the type mapping. The trade-off: ORMs hide complexity, which is great until you need to understand what's happening under the hood.
Drizzle ORM — the 2026 leader
TypeScript-first, lightweight, SQL-like syntax. Schema is defined in TypeScript:
import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').unique().notNull(),
createdAt: timestamp('created_at').defaultNow(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
userId: integer('user_id').references(() => users.id).notNull(),
title: text('title').notNull(),
});
Queries look like SQL:
const result = await db
.select({ id: users.id, postCount: count(posts.id) })
.from(users)
.leftJoin(posts, eq(posts.userId, users.id))
.groupBy(users.id);
In English: Ask the database for every user paired with their posts, then group results by user id and count how many posts each user has. The
eq(...)helper builds an SQL equality condition; TypeScript inference makesresulta strongly-typed array of{ id: number; postCount: number }— no separate type definition needed.
Why it's popular:
- Excellent TypeScript inference (your query result type is exactly the columns you selected).
- Lightweight (no separate query engine).
- SQL-like — easy to translate mental models.
- Edge-runtime compatible.
Prisma
The dominant ORM from 2020–2024. Schema-first (you write a .prisma file).
model User {
id Int @id @default(autoincrement())
email String @unique
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String
user User @relation(fields: [userId], references: [id])
userId Int
}
Queries are object-style:
const user = await prisma.user.findUnique({
where: { id: 42 },
include: { posts: true },
});
Strengths: Beautiful schema syntax; excellent migrations; Prisma Studio (GUI for browsing data).
Weaknesses: Larger runtime than Drizzle; some performance issues in serverless; less SQL-like.
In 2026, Drizzle is rising fast and Prisma remains widely used. New projects are increasingly choosing Drizzle.
Kysely
Type-safe SQL query builder. Lower-level than Drizzle/Prisma — you write SQL-like code with full type safety, no migration tooling included.
const users = await db
.selectFrom('users')
.where('email', '=', 'tony@example.com')
.selectAll()
.execute();
When to use: Teams that want type safety but reject ORM abstractions.
Other languages
| Language | ORM / DB tool |
|---|---|
| Python | SQLAlchemy (powerful, complex), Django ORM (built into Django) |
| Go | GORM (simpler than SQLAlchemy) |
| Java | Hibernate / JPA (mature, complex) |
| Ruby | ActiveRecord (built into Rails) |
| PHP | Eloquent (built into Laravel) |
Raw SQL — sometimes the right answer
For complex queries, a hand-tuned SQL string is often clearer than an ORM equivalent. Modern ORMs (Drizzle, Prisma) let you write raw SQL when needed.
// Drizzle's escape hatch:
const result = await db.execute(sql`
SELECT u.name, COUNT(p.id) AS post_count
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
GROUP BY u.id
ORDER BY post_count DESC
LIMIT 10
`);
In English: Identical query to the typed Drizzle version above, written as raw SQL through the
sqltemplate tag. The tag handles parameter escaping so you don't open yourself to SQL injection. Use this when the ORM's fluent API makes a complex query harder to read, not easier. Don't be afraid to drop down to raw SQL when it's clearer.
Migrations
Schema changes versioned in code:
// drizzle/0001_add_email_index.ts
export const up = sql`CREATE INDEX users_email_idx ON users(email);`;
export const down = sql`DROP INDEX users_email_idx;`;
Run with drizzle-kit migrate, prisma migrate deploy, etc.
Best practices:
- Migrations are immutable once committed.
- Migrations should be reversible when possible.
- Test migrations against production data copies.
- Backward-compatible migrations let you deploy code and schema independently.
ORMs are great, but they're not a substitute for understanding SQL. The moment you hit a performance problem, a complex aggregation, or a tricky join, you'll be reading raw SQL. Spend an afternoon on SQL basics. You'll thank yourself for the rest of your career.
Common mistakes
- N+1 queries hidden by the ORM's fluent API.
users.map(u => u.posts)looks like JS but fires one query per user. Useinclude(Prisma) or an explicit join/with(Drizzle) to fetch in a single round trip. Watch the SQL log; if you see 50 queries to render one page, you have an N+1. - Editing an already-applied migration. Once a migration has run in any shared environment (staging, prod, a teammate's DB), it's immutable. Editing it puts everyone's schema out of sync with the migration history. The fix: write a new migration that corrects course.
- Branching schema and code separately, then merging. Two PRs touch the schema; both add migration
0007. Merge, deploy, chaos. Migrations are numbered/timestamped sequentially for a reason — rebase yours on top ofmainand renumber before merging. - Reaching for raw SQL the moment the ORM gets awkward. A 5-line raw query is fine; a 200-line one in
db.execute(sql\...`)` loses type safety and parameter help. Use raw SQL for genuinely complex aggregates, not to avoid learning the ORM's join syntax. - Forgetting to disable lazy loading in serverless. Some ORMs lazily resolve relations on access — fine on a long-lived server, disastrous in a Lambda that's already returned the response. Prefer explicit, eager loads (
include/with) and you avoid the surprise. - Skipping a backward-compatible deploy. Renaming a column in one migration + deploying the code that uses the new name in the same release means a window where running code references a column that doesn't exist. Expand → migrate → contract: add the new column, dual-write, switch reads, drop the old.
Page checkpoint
Did ORMs stick?
RequiredWhat's next
→ Continue to Authentication — the auth-as-a-service landscape (Clerk, Better Auth, Auth0, etc.).