Common database and transaction mistakes (PostgreSQL, ORMs) Intermediate
Estimated reading time: 15 min read
This page collects the database mistakes code review often misses: code that runs correctly on a dev machine with 100 rows, then gets slow, corrupts data or locks tables in production.
Each pair only shows the default fix. The deep explanations are in Writing transactions in code and the other articles in the Database group.
At a glance: which mistake, when it shows up
Section titled “At a glance: which mistake, when it shows up”| Mistake | On a dev machine | In production |
|---|---|---|
N+1, SELECT *, deep OFFSET |
Fast | Slows down as rows grow |
| Check-then-insert, read-modify-write | Correct | Corrupt data under concurrent requests |
| Transaction on the wrong connection | Correct (one request) | Lost locks, half-done writes |
NOT IN meeting NULL |
Correct (no NULL yet) |
Returns empty, no error |
Missing foreign key index, lower(email) |
Fast | Seq scan every time |
| Table-locking migration | A few ms | Blocks writes for minutes |
Measurements on this page ran on PostgreSQL 17.11 (the repo’s Docker setup), with a 2-million-row orders table and a 100-thousand-row customer table.
Queries
Section titled “Queries”N+1: queries inside a loop
Common
// Knex: 1 query for users + N queries for postsconst users = await knex('users').select('id', 'name');for (const u of users) { u.posts = await knex('posts').where({ user_id: u.id });}
// TypeORM: same mistake, written with a repositoryfor (const u of users) { u.posts = await postRepo.find({ where: { user: { id: u.id } } });}Prefer
// Knex: 2 queries, grouped by user_id in the appconst users = await knex('users').select('id', 'name');const posts = await knex('posts').whereIn('user_id', users.map((u) => u.id));const byUser = Map.groupBy(posts, (p) => p.user_id);for (const u of users) u.posts = byUser.get(u.id) ?? [];
// TypeORM: let the ORM load the relationconst usersWithPosts = await userRepo.find({ relations: { posts: true } });// Prisma 7: prisma.user.findMany({ include: { posts: true } })Why
- Each loop iteration is a network round-trip. 3 users produce 4 queries (counted with Knex’s
queryevent), 500 users produce 501. - On dev with a local DB, round-trips cost almost nothing, so nobody notices. In production with the DB on another machine, each round-trip adds hundreds of microseconds to a few ms.
whereIngenerateswhere "user_id" in ($1, $2, ...): a fixed number of queries, independent of N.
SELECT * and loading the whole table to filter in the app
Common
const all = await orderRepo.find(); // SELECT every column, every rowconst recent = all .filter((o) => o.customerId === customerId) .slice(0, 20);Prefer
const recent = await orderRepo.find({ select: { id: true, total: true, createdAt: true }, where: { customerId }, order: { createdAt: 'DESC' }, take: 20,});Why
- With a 2-million-row table, the bad version pulls 2 million objects into the Node process’s RAM on every request. A few concurrent requests and it’s OOM.
- Filtering in the database can use indexes and sends only 20 rows over the network.
- Selecting specific columns also avoids leaking sensitive columns (
password_hash) when the object is returned straight from the API.
NOT IN (subquery) when the subquery contains NULL
Common
-- Customers who have never orderedSELECT id FROM customerWHERE id NOT IN (SELECT customer_id FROM orders);-- orders.customer_id is nullable, there's one walk-in order (NULL)-- → 0 rowsPrefer
SELECT c.id FROM customer cWHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.id);-- → 2 rows, correctWhy
PostgreSQL docs: if no equal value is found and the subquery has at least one NULL, NOT IN returns null, not true. WHERE treats null as false, so every row is excluded.
Tested: 3 customers, 1 order for customer 1, 1 NULL order → NOT IN returns 0 rows, NOT EXISTS returns 2. The bug only appears from the day the first NULL row arrives.
OFFSET for deep pages on a large table
Common
// GET /orders?page=50001const rows = await db.selectFrom('orders') .select(['id', 'created_at']) .orderBy('created_at', 'desc').orderBy('id', 'desc') .limit(20).offset((page - 1) * 20) .execute();Prefer
// GET /orders?after=<created_at>,<id> (cursor from the previous page)const rows = await db.selectFrom('orders') .select(['id', 'created_at']) .where(({ eb, refTuple, tuple }) => eb(refTuple('created_at', 'id'), '<', tuple(cursor.createdAt, cursor.id))) .orderBy('created_at', 'desc').orderBy('id', 'desc') .limit(20) .execute();Why
- PostgreSQL docs: rows skipped by
OFFSETstill have to be computed by the server. Measured with an index on(created_at DESC, id DESC):OFFSET 1000000takes 99 ms (reads 1,000,020 entries), keyset takes 0.06 ms (reads 20). - Page 1 is equally fast, so tests and demos don’t catch it. A crawler or someone clicking “last page” does.
- The cursor must be a unique column pair (
created_at, id). Details and traps: OFFSET and keyset.
Concurrency: two requests at once
Section titled “Concurrency: two requests at once”The two mistakes below share the same shape: read, decide in the app, then write. Between the read and the write, another request slips in.
Loading diagram…
Check-then-insert to prevent duplicates
Common
const { rowCount } = await pool.query('SELECT 1 FROM subscriber WHERE email = $1', [email]);if (rowCount > 0) return 'exists';await pool.query('INSERT INTO subscriber (email) VALUES ($1)', [email]);return 'created';Prefer
// Schema: email text NOT NULL UNIQUEconst res = await pool.query( `INSERT INTO subscriber (email) VALUES ($1) ON CONFLICT (email) DO NOTHING RETURNING id`, [email],);return res.rowCount === 1 ? 'created' : 'exists';Why
- 20 concurrent requests with the same email: the bad version creates 20 rows (another run: 15); the good version creates exactly 1 row, and 19 requests get
exists. - Only a database constraint can enforce this across many requests and many instances. The app-level check is only for returning a nice error.
- With
DO NOTHING, skipped rows don’t appear inRETURNING. Details: Pattern 4.
Read-modify-write: lost updates
Common
const { rows } = await pool.query('SELECT n FROM counter WHERE id = 1');await pool.query('UPDATE counter SET n = $1 WHERE id = 1', [rows[0].n + 1]);Prefer
// Compute in the database, in one statementawait pool.query('UPDATE counter SET n = n + 1 WHERE id = 1');
// With a business condition: put it in WHERE, check rowCountconst r = await tx.query( 'UPDATE product SET stock = stock - $2 WHERE id = $1 AND stock >= $2', [id, qty]);if (r.rowCount === 0) throw new SoldOut();Why
- 50 concurrent “+1” requests: the bad version ends with n = 1, the good one with n = 50. Every request reads the same old value and overwrites it with a constant.
- Wrapping the bad version in
BEGIN/COMMITdoesn’t fix it: under Read Committed the update is still lost (the flash sale demo oversells by 40 orders). - If you need app logic between the read and the write, use
SELECT ... FOR UPDATE. See Pattern 1 and Pattern 2.
Transactions
Section titled “Transactions”BEGIN/COMMIT via pool.query (node-postgres)
Common
await pool.query('BEGIN');await pool.query('UPDATE account SET balance = balance - $2 WHERE id = $1', [from, amt]);await pool.query('UPDATE account SET balance = balance + $2 WHERE id = $1', [to, amt]);await pool.query('COMMIT');Prefer
const client = await pool.connect();try { await client.query('BEGIN'); await client.query('UPDATE account SET balance = balance - $2 WHERE id = $1', [from, amt]); await client.query('UPDATE account SET balance = balance + $2 WHERE id = $1', [to, amt]); await client.query('COMMIT');} catch (err) { await client.query('ROLLBACK').catch(() => {}); throw err;} finally { client.release();}Why
Each pool.query borrows any connection, so the four statements may run on four connections. The node-postgres docs say it plainly: don’t use transactions with pool.query. Measured consequences: locks released early, overselling, and orphaned BEGINs holding locks forever. See The classic node-postgres mistake.
Using knex/db instead of trx inside a transaction callback
Common
// Knexawait knex.transaction(async (trx) => { await trx('orders').insert(order); await knex('audit_log').insert(log); // knex, not trx});
// Kyselyawait db.transaction().execute(async (trx) => { await trx.insertInto('orders').values(order).execute(); await db.insertInto('audit_log').values(log).execute(); // db, not trx});Prefer
await knex.transaction(async (trx) => { await trx('orders').insert(order); await trx('audit_log').insert(log);});
await db.transaction().execute(async (trx) => { await trx.insertInto('orders').values(order).execute(); await trx.insertInto('audit_log').values(log).execute();});// TypeORM: use the manager passed in; Prisma 7: use tx, not prismaWhy
- Tested with Knex and Kysely: the callback throws after both inserts. The statement through
trxis rolled back; the one throughknex/dbis still in the table. - Statements through
knex/dbrun on another connection, outside the transaction: they can’t see uncommitted data and aren’t protected by the transaction’s locks. - Common when a repository holds
knex/dbas a field. Make repository functions take an executor parameter. API table per ORM: ORMs: same principle.
Making HTTP calls inside a transaction
Common
await prisma.$transaction(async (tx) => { await tx.product.update({ where: { id }, data: { stock: { decrement: 1 } } }); await paymentGateway.charge(card, amount); // HTTP 1-3 seconds await tx.order.create({ data: { productId: id, status: 'paid' } });});Prefer
const order = await prisma.$transaction(async (tx) => { await tx.product.update({ where: { id }, data: { stock: { decrement: 1 } } }); const order = await tx.order.create({ data: { productId: id, status: 'pending_payment' } }); await tx.outbox.create({ data: { type: 'payment.requested', payload: { orderId: order.id } } }); return order;});// a worker reads the outbox and calls the payment gateway after commitWhy
- The lock on the product row is held the whole time you wait for the third party. Every request buying the same product queues behind it.
- A rollback can’t take back money already charged. Prisma 7 also has a default 5-second
timeoutfor interactive transactions: a slow gateway gets the transaction cancelled after the charge. - The Prisma 7 docs advise avoiding network requests inside transactions. Details: Keep transactions short, Transactional outbox.
Indexes and migrations
Section titled “Indexes and migrations”A foreign key without an index
Common
CREATE TABLE orders ( id bigserial PRIMARY KEY, customer_id int NOT NULL REFERENCES customer(id) ON DELETE CASCADE);-- Assuming REFERENCES creates an index automatically. It doesn't.Prefer
CREATE TABLE orders ( id bigserial PRIMARY KEY, customer_id int NOT NULL REFERENCES customer(id) ON DELETE CASCADE);CREATE INDEX orders_customer_id_idx ON orders (customer_id);Why
- PostgreSQL automatically indexes the referenced side (the primary key), not the foreign key column. The docs explain why: it isn’t always needed.
- Without the index:
WHERE customer_id = 42is a parallel seq scan taking 27 ms; deleting one customer costs 40.7 ms in the foreign key trigger. With the index: 1.2 ms and 0.09 ms. - Deleting customers in bulk means one scan of
ordersper row. This is the source of many “data cleanup” jobs that run for an hour.
WHERE lower(email) = ... with only an index on email
Common
CREATE UNIQUE INDEX customer_email_idx ON customer (email);
SELECT id FROM customer WHERE lower(email) = lower($1);-- Seq Scan on customer, 11 ms on 100 thousand rowsPrefer
CREATE UNIQUE INDEX customer_email_lower_idx ON customer (lower(email));
SELECT id FROM customer WHERE lower(email) = lower($1);-- Index Scan using customer_email_lower_idx, 0.03 msWhy
- The index on
emailstores the original values; the planner can’t use it for the expressionlower(email). To use an index, the expression in the query must match the index’s expression. UNIQUEonlower(email)also blocks duplicates likeAn@a.vnandan@a.vn, which a plain index can’t do.- More on diagnosing unused indexes: Expression indexes, Index not used.
Migration: adding a column with a volatile default
Common
ALTER TABLE orders ADD COLUMN public_id uuid DEFAULT gen_random_uuid();-- 2 million rows: 2.9 seconds, rewrites the whole table and its indexes,-- holding ACCESS EXCLUSIVE the entire time (blocks even reads)Prefer
-- 1. Add the column without a default: catalog-only change (2.5 ms)ALTER TABLE orders ADD COLUMN public_id uuid;-- 2. The default applies only to new rows, existing rows untouched (0.9 ms)ALTER TABLE orders ALTER COLUMN public_id SET DEFAULT gen_random_uuid();-- 3. Backfill in small batches in a separate job, then add NOT NULL if neededUPDATE orders SET public_id = gen_random_uuid()WHERE id IN (SELECT id FROM orders WHERE public_id IS NULL LIMIT 5000);Why
- Since PostgreSQL 11, a non-volatile default (a constant,
now()) is computed once and stored in the catalog: no rewrite. Measured on PG 17:DEFAULT 'new'takes 3 ms,DEFAULT now()takes 0.5 ms,relfilenodeunchanged. - A volatile default (
gen_random_uuid(),random(),clock_timestamp()) must be computed per row, so PostgreSQL rewrites the whole table:relfilenodechanges, 2.9 seconds for 2 million rows. ADD COLUMNalways takesACCESS EXCLUSIVE. The good version only holds it for a few ms, but migrations should still setlock_timeoutso they don’t queue behind a long query.
Migration: CREATE INDEX on a table receiving writes
Common
// Knex migration, runs inside a transaction by defaultexport async function up(knex) { await knex.raw('CREATE INDEX orders_total_idx ON orders (total)');}Prefer
// CONCURRENTLY can't run inside a transaction: disable it for this file onlyexport const config = { transaction: false };
export async function up(knex) { await knex.raw('CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_total_idx ON orders (total)');}Why
- A plain
CREATE INDEXtakes aShareLock(checked inpg_locks): reads still run, but every INSERT/UPDATE/DELETE waits until the build finishes. On a large table that’s minutes without writes. - Running
CONCURRENTLYinside a transaction fails withcannot run inside a transaction block. Knex lets you disable the transaction per file viaconfig.transaction. - A
CONCURRENTLYbuild that fails midway leaves anINVALIDindex that still costs write overhead. Check\d,DROP INDEX, then rerun.
Review checklist
Section titled “Review checklist”- No
awaited queries insidefor/map; relations loaded viawhereIn,relations,includeor a JOIN. - Queries select specific columns, with
WHEREandLIMIT; no filtering in the app. - No
NOT IN (subquery)on nullable columns; useNOT EXISTS. - Large list APIs use keyset pagination, not deep OFFSET.
- Duplicates prevented with
UNIQUE+ON CONFLICT; noSELECTfollowed byINSERT. - No
SET x = <value computed in the app>; useSET x = x + ...orFOR UPDATE; checkrowCount. - Transactions use one client (
pool.connect()) ortrx/tx/managerfor every statement inside. - No HTTP calls, emails or publishing inside transactions.
- Every foreign key column has an index (or a documented reason why not).
- Queries applying functions to columns (
lower,date) have a matching expression index. - Migrations: no volatile defaults on large tables;
CREATE INDEX CONCURRENTLYwith the transaction disabled;lock_timeoutset.
Next: API & security.