Why you mignht not need an ORM (and when you do)
#ORMs
#databases
#backend
ORMs (Object-Relational Mappers) are popular because they speed up development by mapping database tables to language-native objects. But they also introduce abstraction, runtime overhead, and sometimes surprising behavior.
This article helps you decide whether an ORM is the right tool for your project, shows practical alternatives, and includes small code examples and a developer checklist so you can pick an approach with confidence.
TL;DR
- Use an ORM when you need fast developer productivity, type-safe models, and convenient relations — especially for CRUD-heavy apps or when your team prefers a higher-level API.
- Avoid or limit ORMs when you need maximum performance, fine-grained SQL control, complex queries, or minimal runtime overhead.
- Consider hybrid approaches: small, well-typed models in an ORM for simple cases, and raw SQL / query builders for complex queries and performance-critical paths.
What ORMs get you (benefits)
- Developer productivity: scaffolding, model-driven code, generated types, and concise CRUD operations.
- Consistency & conventions: a standard way to structure DB access and migrations.
- Abstractions for relations: eager/lazy loading, cascading, and object graphs.
- Type-safety (modern ORMs): Prisma, TypeORM, and others can generate types that match your schema.
- Migrations & tooling: many ORMs include a migration system, schema introspection, and a dev experience that’s pleasant for teams.
Common pain points with ORMs
- Leaky abstractions: complex SQL constructs (window functions, lateral joins, CTEs) can be awkward or impossible to express idiomatically.
- Performance surprises: ORMs can generate inefficient queries (N+1 problems, large joins) if not used carefully.
- Runtime overhead: additional layers between your code and SQL can add latency and memory use.
- Hidden behavior: implicit conversions, default behaviors, or cascading deletes may be surprising.
- Lock-in: switching away from an ORM can be costly once your codebase relies on its APIs heavily.
When to avoid an ORM
- You need maximum throughput and minimal latency (hot code paths in APIs).
- Your application relies on complex reporting or analytics queries that are easier to write in raw SQL.
- You prefer to keep SQL explicit and reviewable (auditability and predictability).
- Your team is comfortable writing SQL and maintaining migrations manually.
When an ORM makes sense
- CRUD-heavy admin apps where productivity and safe defaults matter more than micro-optimizations.
- Teams that want generated types and schema-driven development (Prisma’s generated types are a big win).
- Projects where rapid iteration of the domain model and migrations is the frequent workflow.
Alternatives and hybrid approaches
- Raw SQL: full control, minimal abstraction, easiest to predict performance.
- Query Builders (Knex, Objection, Squel, Drizzle): safer programmatic SQL composition with fewer surprises than ORMs.
- Lightweight mappers / micro-ORMs: expose typed helpers without full object graph management (e.g., Slonik + postgres for Node, or Deno’s postgres clients).
- Use an ORM for simple CRUD models and raw SQL for complex queries — extract a data access layer (repository pattern) that centralizes where raw SQL is used.
Practical examples
- Raw SQL with node-postgres (pg) — explicit and fast
// example: db.js (Node + pg)
import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export async function getRecentPosts(limit = 10) {
const res = await pool.query(
"SELECT id, title, published_at FROM posts ORDER BY published_at DESC LIMIT $1",
[limit],
);
return res.rows;
}
Pros: you always see and control the SQL; easy to optimize with indexes and explain plans.
- Query builder with Drizzle ORM (or Knex) — structured SQL without heavyweight ORM
// Drizzle example (TypeScript)
import { eq } from "drizzle-orm";
import { db } from "./db";
import { posts } from "./schema";
export const recent = await db
.select()
.from(posts)
.where(eq(posts.published, true))
.orderBy(posts.publishedAt.desc)
.limit(10);
Pros: composable, less magic than a full ORM, often lighter runtime.
- Prisma (modern, type-safe ORM) — schema-first, generated client
// schema.prisma
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
createdAt DateTime @default(now())
}
import { prisma } from "./prisma";
const posts = await prisma.post.findMany({
where: { published: true },
take: 10,
orderBy: { createdAt: "desc" },
});
Pros: generated types, great DX, and less boilerplate for common operations.
When to mix approaches
- Keep your high-level models and simple CRUD in an ORM for speed of development.
- For reporting or performance-critical endpoints, write raw SQL or use a query builder and put those queries behind a repository interface.
- Add integration tests to ensure the ORM models and raw SQL produce the same results where applicable.
Migrations and schema evolution
- Migrations are essential regardless of approach. Use a tool you trust: Prisma Migrate, Knex migrations, Flyway, or raw SQL migration runners.
- Keep migrations versioned in source control and review schema changes like code changes.
- For high-traffic production systems, plan zero-downtime migrations: avoid destructive column drops, backfill data before swapping to non-null constraints, and use rolling schema changes.
Transactions & concurrency
- Transactions are necessary for correctness where multiple related writes must succeed or fail as a unit. All approaches support transactions; keep them short and retry on transient failures.
Raw SQL transaction example (node-postgres):
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
[amount, fromId],
);
await client.query(
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
[amount, toId],
);
await client.query("COMMIT");
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
Prisma transaction sketch:
await prisma.$transaction(async (tx) => {
await tx.account.updateMany({
where: { id: fromId },
data: { balance: { decrement: amount } },
});
await tx.account.updateMany({
where: { id: toId },
data: { balance: { increment: amount } },
});
});
Testing and reproducibility
- Use test fixtures and a known database state for integration tests. Consider running a disposable ephemeral database during CI (Dockerized Postgres or managed test DBs).
- For unit tests, mock the data layer. But ensure some integration tests exercise real SQL for correctness and performance assumptions.
Performance considerations
- Measure before optimizing. Use the database’s EXPLAIN and real query logging to find hot queries.
- Watch for common ORM anti-patterns: N+1 queries, selecting unnecessary columns, and large eager-loaded object graphs.
- Consider connection pooling, prepared statements, and query caching in hot paths.
Operational concerns & observability
- Add query logging and slow-query tracking to detect regressions.
- Collect metrics: query latency, error rates, and connection usage.
- Control database migrations via CI pipelines and have a rollback plan.
Security and safety
- Parameterize queries to avoid SQL injection. Most drivers and ORMs do this correctly; avoid string interpolation for SQL.
- Protect sensitive data at rest and in transit; use least privilege DB users for your application.
- Sanitize inputs and limit fetch sizes to prevent DoS with huge queries.
Real-world trade-offs and examples
- Small teams shipping quickly: an ORM like Prisma speeds up development and reduces boilerplate.
- Data platform or analytics teams: prefer raw SQL and data warehouses where SQL expressiveness matters.
- Mixed environments: use an ORM for operational CRUD and separate a reporting service that queries the data warehouse with raw SQL.
Checklist: choosing the right approach
- Do you need complex, highly-optimized SQL? If yes -> prefer raw SQL or query builder.
- Is developer productivity and type generation a high priority? If yes -> consider Prisma or a modern ORM.
- Do you need predictable performance for hot endpoints? If yes -> benchmark and consider raw SQL for those routes.
- Will your team maintain the database schema and migrations carefully? If no -> choose an ORM with good migration tooling or invest in migration processes.
- Is switching away from the ORM costly for your codebase? If yes -> encapsulate ORM usage behind repository interfaces.
Practical repo structuring advice
- Centralize DB access in a single module or repository layer. That makes it easier to swap strategies later.
- Keep raw SQL files or query builder functions near the repository module that uses them (cohesion).
- Generate types where possible so the rest of the app has compile-time guarantees.
Final thoughts
ORMs are powerful productivity tools but not a one-size-fits-all solution. Treat them like any dependency: understand their failure modes and keep the option to bypass them for complex or performance-critical queries. A hybrid approach (ORM + targeted raw SQL) often gives the best balance between developer happiness and production performance.
Further reading & resources
- “The Art of PostgreSQL” (book) — deep dive on SQL patterns and performance
- Prisma docs — https://www.prisma.io/docs
- Drizzle ORM — https://orm.drizzle.team/
- node-postgres (pg) — https://node-postgres.com/
- Knex.js — http://knexjs.org/