| name | database-query-safety |
| description | Database query safety patterns — N+1 detection, query plan analysis, parameterized queries, SQL injection prevention, migration safety, and slow-query monitoring. Sources: prisma/prisma, drizzle-team/drizzle-orm, brianc/node-postgres, sequelize/sequelize, knex/knex. |
/database-query-safety
When to Use
- Writing ORM queries that touch joins or relations (N+1 risk)
- Any raw SQL query construction involving user input
- Before shipping a database migration
- "The dashboard is slow after we added this feature" (query plan audit)
Do NOT use for
- In-memory data structures (no SQL involved)
- Read-only analytics DBs with no user input
N+1 Detection and Fix (Prisma / Drizzle)
const posts = await db.post.findMany()
const withAuthors = await Promise.all(
posts.map(p => db.user.findUnique({ where: { id: p.authorId } }))
)
const posts = await db.post.findMany({
include: { author: true },
})
const posts = await db
.select({ post: postsTable, author: usersTable })
.from(postsTable)
.leftJoin(usersTable, eq(postsTable.authorId, usersTable.id))
Query Plan Analysis
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders
WHERE customer_id = $1 AND status = 'pending'
ORDER BY created_at DESC
LIMIT 20;
const db = new PrismaClient({
log: [{ emit: 'event', level: 'query' }],
})
db.$on('query', e => {
if (e.duration > 100) console.warn(`Slow query (${e.duration}ms):`, e.query)
})
SQL Injection Prevention (node-postgres / knex)
const rows = await client.query(
`SELECT * FROM users WHERE email = '${email}'`
)
const rows = await client.query(
'SELECT * FROM users WHERE email = $1',
[email]
)
const user = await knex('users').where({ email }).first()
const user = await db.select().from(users).where(eq(users.email, email))
Migration Safety (expand-contract pattern)
Phase 1 — EXPAND: add new column nullable, no constraints
ALTER TABLE users ADD COLUMN display_name TEXT;
Phase 2 — BACKFILL: populate in batches (never UPDATE all at once)
UPDATE users SET display_name = name WHERE id BETWEEN $1 AND $2;
Phase 3 — CONSTRAIN: add NOT NULL after backfill complete
ALTER TABLE users ALTER COLUMN display_name SET NOT NULL;
Phase 4 — CONTRACT: drop old column (separate deploy)
ALTER TABLE users DROP COLUMN name;
Rule: never DROP or NOT NULL in the same migration as ADD
Rule: always test rollback (DOWN migration) before pushing
Slow Query Monitoring
SELECT
round(total_exec_time::numeric, 2) AS total_ms,
calls,
round(mean_exec_time::numeric, 2) AS avg_ms,
left(query, 80) AS query_snippet
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
SELECT schemaname, tablename, attname, n_distinct, correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'customer_id';
Anti-Fake-Pass Checklist
❌ ORM relation loaded without include/join (N+1 — check query log count)
❌ Raw SQL with user input via string template literal
❌ Migration that adds NOT NULL without backfill first
❌ No EXPLAIN ANALYZE run before declaring a slow query "fixed"
❌ Batch UPDATE without WHERE clause row limit (table lock risk)
❌ knex/Prisma query in a loop — must be extracted to single bulk query