Prisma vs Knex vs Raw SQL: Choosing a Database Layer for Node.js
A practical comparison of Prisma ORM, Knex query builder, and raw SQL for Node.js applications, with recommendations for different project types.
Three Approaches to Database Access
Every Node.js project with PostgreSQL needs a database layer. The three main options — Prisma (ORM), Knex (query builder), and raw SQL via pg — each have distinct tradeoffs. After using all three in production, here's an honest comparison.
Prisma: The Full ORM
Prisma generates a type-safe client from your schema definition:
model Order {
id Int @id @default(autoincrement())
customer Customer @relation(fields: [customerId], references: [id])
customerId Int
items OrderItem[]
total Decimal
status String @default("pending")
createdAt DateTime @default(now())
}
Querying with Prisma
const orders = await prisma.order.findMany({
where: {
status: 'pending',
createdAt: { gte: new Date('2026-01-01') }
},
include: {
customer: true,
items: { include: { product: true } }
},
orderBy: { createdAt: 'desc' },
take: 20
});
Prisma Pros
- Type safety — auto-generated TypeScript types prevent runtime errors
- Migrations —
prisma migratehandles schema changes - Relations — eager loading with
includeis intuitive - Prisma Studio — built-in GUI for browsing data
Prisma Cons
- Complex queries are awkward — aggregations, CTEs, and window functions require raw SQL fallback
- Performance overhead — the generated queries aren't always optimal
- Bundle size — the Prisma engine adds ~15MB to your deployment
- Migration lock-in — switching away from Prisma migrations is painful
Knex: The Query Builder
Knex provides a fluent API for building SQL queries:
const orders = await knex('orders')
.join('customers', 'orders.customer_id', 'customers.id')
.where('orders.status', 'pending')
.where('orders.created_at', '>=', '2026-01-01')
.select(
'orders.*',
'customers.name as customer_name',
'customers.email as customer_email'
)
.orderBy('orders.created_at', 'desc')
.limit(20);
Knex Pros
- Flexible — supports complex queries without escaping to raw SQL
- Migrations — built-in migration system with rollback support
- Lightweight — minimal overhead over raw SQL
- Database-agnostic — same code works across PostgreSQL, MySQL, SQLite
Knex Cons
- No type safety — results are plain objects, no TypeScript types
- No relation management — you handle joins manually
- Manual schema tracking — no auto-generated schema from your database
Raw SQL: Maximum Control
Using the pg driver directly:
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const { rows: orders } = await pool.query(`
SELECT
o.*,
c.name as customer_name,
json_agg(json_build_object(
'product', p.name,
'quantity', oi.quantity,
'price', oi.price
)) as items
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON oi.product_id = p.id
WHERE o.status = $1 AND o.created_at >= $2
GROUP BY o.id, c.name
ORDER BY o.created_at DESC
LIMIT $3
`, ['pending', '2026-01-01', 20]);
Raw SQL Pros
- Full PostgreSQL power — CTEs, window functions, JSONB operations, anything
- Zero overhead — the query you write is the query that runs
- No abstraction leaks — you see exactly what's happening
- Tiny dependency — just the
pgpackage
Raw SQL Cons
- No type safety — easy to make typos in column names
- No migrations — you need a separate migration tool
- Verbose — simple CRUD requires more code
- SQL injection risk — must always use parameterized queries
When to Use Each
Use Prisma When:
- Building a Shopify app or any app with clear entity relationships
- Working with TypeScript and wanting compile-time safety
- The team includes less experienced SQL developers
- Your queries are mostly CRUD with simple joins
Use Knex When:
- Building backend APIs that need flexible querying
- You want migration support without the ORM overhead
- Your queries include aggregations and complex joins but not CTEs
- You might switch databases in the future
Use Raw SQL When:
- Building data-intensive applications (analytics, reporting)
- Performance is critical and you need optimized queries
- You're comfortable with SQL and want maximum control
- The application is small enough that an abstraction layer adds more complexity than it removes
Our Production Choices
| Project | Database Layer | Reason | |---------|---------------|--------| | LogoBadge (Shopify app) | Prisma | Shopify Remix template uses Prisma | | ShipAnywhere | Knex | Complex shipping queries with joins | | Stock Sync Dashboard | Raw SQL (pg) | Simple queries, Python app | | B2B Portal | Knex | Flexible reporting queries | | Booking System | Raw SQL (pg) | Small schema, full control needed |
The Hybrid Approach
Many projects benefit from combining approaches. Use Prisma for CRUD and fall back to raw SQL for complex queries:
// Simple CRUD with Prisma
const order = await prisma.order.create({ data: { ... } });
// Complex analytics with raw SQL
const stats = await prisma.$queryRaw`
SELECT DATE_TRUNC('month', created_at) as month,
COUNT(*) as orders,
SUM(total) as revenue
FROM orders
GROUP BY month
ORDER BY month DESC
`;
Conclusion
There's no universal best choice. Prisma excels for type-safe CRUD, Knex for flexible queries with migration support, and raw SQL for maximum control and performance. Choose based on your project's query complexity, team experience, and whether type safety justifies the added abstraction.