Backend in Depth
Database Fundamentals for API Engineers: Indexes, Transactions, Isolation
B-tree indexes, reading EXPLAIN ANALYZE, transactions and isolation levels, and the race conditions they stop, like selling too many items.
On this page
In one line: an index makes reads fast and writes slower. A transaction makes a group of changes all-or-nothing. An isolation level decides what transactions running at the same time can see of each other. Most "mysterious" API bugs and slowdowns come from one of these three.
A database is a program that stores data and lets you search and change it safely. A query is a request to the database, written in SQL (the standard language for talking to relational databases). A query plan is the list of steps the database picks to run a query.
You do not need to be a database expert. But if you own an API, you must be able to read a query plan (the steps the database takes to run a query) and think about requests that arrive at the same time. The examples use PostgreSQL, which is often used with Prisma. The ideas work in other databases too.
Words used in this lesson
- An index is an extra data structure that the database keeps beside a table, so it can find rows without reading the whole table. It speeds up reads and slows down writes.
- A transaction is a group of database changes that either all happen or none happen.
- An isolation level is a setting that says how much a transaction can see of other transactions that run at the same time.
- A row is one record in a table. A column is one field of every row. A primary key is the column (or columns) that identifies each row. A foreign key is a column that points to the primary key of another table (for example
order_items.order_idpoints toorders.id). - A constraint is a rule the database enforces, such as
UNIQUEorCHECK (stock >= 0). - Selectivity describes how few rows a condition matches. A condition that matches 1 row in a million is very selective.
- A lock is a mark that stops other transactions from changing the same row until the owner is done. (In PostgreSQL, plain reads do not wait for row locks.)
- A race condition is a bug where the result depends on which of two actions happens first.
- Atomic means "all in one step that cannot be cut in half". An atomic update checks and changes the value in a single statement.
- MVCC (multi-version concurrency control) is how PostgreSQL lets readers and writers work at once: each change creates a new version of the row, and old versions stay until a cleanup job removes them.
- Vacuum is the PostgreSQL cleanup job that removes old row versions. Autovacuum runs it automatically.
- An ORM (object-relational mapper) such as Prisma is a library that lets you use database tables as objects in your code.
- Throughput is how much work a system finishes per second.
- A replica is a read-only copy of the database that follows the main one.
Difference in one line: an index makes one read faster, a transaction makes a group of writes safe.
How an index works
Think of a book. To find one topic, you can read every page, or you can look in the index at the back. A database index works the same way.
Without an index, SELECT * FROM orders WHERE user_id = 42 makes the database read every row. This is called a sequential scan. The bigger the table, the slower the query.
A B-tree index is the default index type in PostgreSQL. It keeps the column values sorted in a tree shape. The database can then jump straight to the matching rows. The time grows very slowly as the table grows (this is called logarithmic time: a table 1,000 times bigger needs only a few more steps).
CREATE INDEX idx_orders_user_id ON orders (user_id);A B-tree index helps with:
- equality (
=) - ranges (
<,>,BETWEEN) ORDER BYon the indexed columnsLIKE 'abc%'(the text starts with "abc"). This works only if the index uses theCcollation or thetext_pattern_opsoperator class. Otherwise, create the index withtext_pattern_ops.MINandMAX- joins on the indexed column
Costs
- Every
INSERT,UPDATE(of indexed columns) andDELETEmust also update each index. So more indexes means slower writes and more storage. - Indexes also use memory when they are cached.
- An index that nobody uses is pure cost. Add indexes for real query patterns, not for every column.
PostgreSQL does not automatically index foreign-key columns. (It does index primary keys and unique constraints.) A foreign key like order_items.order_id without an index makes joins and cascading deletes slow. Add the index yourself.
Composite indexes and column order
A composite index (also called a multicolumn index) covers more than one column. It sorts by the first column. Inside that, it sorts by the second column, and so on. It can help queries that use a leftmost prefix of its columns. This means the first column, or the first two columns, and so on, but not a later column alone.
CREATE INDEX idx_orders_user_status_created ON orders (user_id, status, created_at DESC);| Query | Uses the index? |
|---|---|
WHERE user_id = 7 | Yes (leftmost) |
WHERE user_id = 7 AND status = 'paid' | Yes |
WHERE user_id = 7 AND status = 'paid' ORDER BY created_at DESC | Yes, and avoids a sort |
WHERE status = 'paid' | Poorly: the first column is missing |
WHERE created_at > '2026-01-01' | Poorly |
Order the columns by how you query. Put the columns you compare with = first. Then put the column you sort by, then the column you filter by range. This rule of thumb is called "equality, sort, range" (ESR). It is a guide, not a law: always check the plan.
Other useful index types and features
- Unique indexes (made by
UNIQUEconstraints) make sure a value appears only once. The database does this check in one safe step (atomically). - Partial indexes (
CREATE INDEX ... WHERE status = 'pending') index only the rows you query. They are smaller and faster. - Expression indexes (
CREATE INDEX ON users (lower(email))) help queries that search on a computed value. - Covering indexes (
CREATE INDEX ... INCLUDE (total)) store extra columns. The database can then answer from the index alone, without reading the table. This is called an index-only scan. - GIN indexes (Generalized Inverted Index) are for values that contain many parts:
jsonb(JSON data), arrays and full-text search. GiST indexes are for ranges and geometry (shapes and map data). BRIN indexes are tiny indexes for huge tables where the values grow in step with the row order, such as a time column in a log table.
When an index does NOT help
- Tiny tables. Scanning a few hundred rows is faster than using an index. The planner (the part of the database that picks the plan) knows this.
- Low selectivity. Say an index is on
is_activeand 95% of rows aretrue. A query fortruereturns most of the table anyway. - A function or cast on the column.
WHERE lower(email) = 'a@b.com'cannot use a plain index onemail. Create an expression index instead.WHERE created_at::date = ...has the same problem. Use a range instead. - Leading wildcards.
LIKE '%abc'(the wildcard%comes first) cannot use a B-tree. Use a trigram index (from thepg_trgmextension, which splits text into 3-letter pieces) or full-text search. - Implicit type conversions. The column type and the parameter type do not match, so the database converts the values.
- The wrong column order in a composite index.
- Stale statistics. The planner keeps facts about your data (how many rows, how values are spread). If these are old, it chooses badly. Run
ANALYZEto refresh them. (Autovacuum usually does this for you.) - Fetching a large part of the table. A sequential scan is the right choice here.
ORacross different columns. It can stop the database from using an index. ConsiderUNION(joining the results of two queries) or a bitmap scan.OFFSETwith huge values. The index finds the start, but the database still skips all earlier rows (Practical Design Problems, Lesson 03).
Reading EXPLAIN ANALYZE
EXPLAIN is a PostgreSQL command that shows the plan without running the query. EXPLAIN ANALYZE actually runs the query and adds real timings and row counts. (Be careful with INSERT, UPDATE and DELETE: they really change data. Wrap them in a transaction and ROLLBACK.) Add BUFFERS to see how much data was read.
EXPLAIN (ANALYZE, BUFFERS)SELECT * FROM orders WHERE user_id = 42 AND status = 'paid' ORDER BY created_at DESC LIMIT 20;The numbers below are an example. Yours will differ.
Limit (cost=0.43..45.2 rows=20 width=96) (actual time=0.05..0.31 rows=20 loops=1) -> Index Scan using idx_orders_user_status_created on orders (cost=0.43..210.8 rows=94 width=96) (actual time=0.05..0.29 rows=20 loops=1) Index Cond: ((user_id = 42) AND (status = 'paid'::text)) Buffers: shared hit=24Planning Time: 0.2 msExecution Time: 0.4 msWhat to look for:
| Plan node | Meaning |
|---|---|
| Seq Scan | Reads the whole table. Fine for small tables. A red flag on large tables when the filter is selective |
| Index Scan | Walks the index, then fetches rows from the table |
| Index Only Scan | Answers from the index alone. It needs an index that holds every column the query uses, and a recently vacuumed table (vacuum updates the map that tells the database a row is visible, so it can skip the table) |
| Bitmap Index/Heap Scan | Combines several index conditions. Good for medium selectivity |
| Sort (especially "external merge, Disk") | The index did not give the order, so the database sorts. A big sort may spill to disk |
| Nested Loop / Hash Join / Merge Join | Ways to join tables. A nested loop with a huge loops number looks like the N+1 problem (one query per row) inside the database |
Key numbers:
costis the planner's estimate in arbitrary units (not milliseconds).actual timeis real milliseconds (first row..all rows).rows(estimated) vs actual rows. A big difference means bad statistics and probably a bad plan.loopssays how many times a step ran.actual timeis per loop, so multiply it byloops.Rows Removed by Filterbeing large means you read many rows to keep a few. This is a good place for an index.Buffers: shared readmeans data came from disk.hitmeans it came from cache.
Workflow:
- Find the slow query (from logs or
pg_stat_statements, a PostgreSQL extension that records how long each query takes in total). - Run
EXPLAIN (ANALYZE, BUFFERS)on realistic data. - Spot the slow scan or sort.
- Add or change an index.
- Run it again and compare.
Test on data that is as big as production. A plan on 100 rows tells you very little.
Transactions and ACID
A transaction is a group of statements that succeed or fail together. Think of moving money between two bank accounts. Taking money out and putting money in must both happen, or neither.
BEGIN;UPDATE accounts SET balance = balance - 500 WHERE id = 1;UPDATE accounts SET balance = balance + 500 WHERE id = 2;COMMIT; -- or ROLLBACK; to undo everythingACID is the set of four guarantees a transaction gives you (the name is the first letters of the four words):
| Letter | Guarantee |
|---|---|
| Atomicity | All statements apply, or none do |
| Consistency | The database moves from one valid state to another (the rules, called constraints, still hold) |
| Isolation | Transactions running at the same time do not see each other's half-finished work. How much they hide depends on the isolation level |
| Durability | Once committed (made final with COMMIT), the data survives a crash. It is first written to the write-ahead log, a file where the database records each change before it changes the data files |
In Prisma:
await prisma.$transaction(async (tx) => { const from = await tx.account.update({ where: { id: 1 }, data: { balance: { decrement: 500 } } }); if (from.balance < 0) throw new Error("Insufficient funds"); // throwing rolls back everything await tx.account.update({ where: { id: 2 }, data: { balance: { increment: 500 } } });});In Prisma, an interactive transaction like this stops with an error after 5 seconds by default (you can change timeout). Keep transactions short. They hold locks and one connection from the pool. Never call slow outside services (payment providers, email) inside a transaction.
Isolation levels and what can go wrong
When transactions run at the same time, some strange things can happen. These are called anomalies.
| Anomaly | What happens |
|---|---|
| Dirty read | You read data that another transaction has not committed yet |
| Non-repeatable read | You read a row twice and it changed in between, because someone committed an update |
| Phantom read | You run the same query twice and new rows that match appear the second time |
| Lost update | Two transactions read a value and both change it. The second one overwrites the first |
| Write skew | Two transactions read the same data and make a decision. Then they write different rows. Together they break a rule. Example: two on-call doctors both go off-call, so nobody is on call |
PostgreSQL's levels:
| Level | Behaviour in PostgreSQL |
|---|---|
| Read Committed (default) | Each statement sees data that was committed before that statement started. No dirty reads. Non-repeatable reads, phantoms and lost updates can happen |
| Repeatable Read | The whole transaction sees one snapshot (a frozen view of the data). This prevents non-repeatable and phantom reads. You may get a serialization failure, and then you retry. Write skew can still happen |
| Serializable | Works as if transactions ran one after another. This also prevents write skew. Your application must retry when a serialization failure happens |
PostgreSQL treats Read Uncommitted the same as Read Committed, so dirty reads never happen. A higher isolation level lowers throughput (how much work you finish per second). It also needs retry logic.
await prisma.$transaction(async (tx) => { /* ... */ }, { isolationLevel: "Serializable" });Scenario: two users buy the last item at the same moment
Tx A: SELECT stock → 1 Tx B: SELECT stock → 1Tx A: stock = 1 - 1 → writes 0 Tx B: stock = 1 - 1 → writes 0Both orders succeed. Stock is 0 but two items were sold. ← oversellingThe "read, check, then write" pattern is a race condition. This means the result depends on which request is faster. Here are ways to prevent it, starting with the simplest:
- Atomic conditional update. Let the database do the check and the change in one step:
UPDATE products SET stock = stock - 1 WHERE id = $1 AND stock >= 1;-- check the affected row count: 1 = reserved, 0 = out of stockconst { count } = await prisma.product.updateMany({ where: { id, stock: { gte: 1 } }, data: { stock: { decrement: 1 } } });if (count === 0) throw new ConflictError("Out of stock", "OUT_OF_STOCK");- Pessimistic locking. This means locking first, because you expect a clash.
SELECT ... FOR UPDATElocks the row until the transaction ends. The second buyer waits, then sees the new stock. - Optimistic locking. This means not locking, because you expect clashes to be rare, and detecting them at write time. Add a
versioncolumn. RunUPDATE ... WHERE id = $1 AND version = $2. If no row matched, someone else changed it first, so retry or fail. - A database constraint.
CHECK (stock >= 0)is a last safety net. - Serializable isolation with retries. Use this when the logic is too complex for one statement.
- Reservation tables. Hold the stock for a short checkout time, then release it when the time runs out. Real shops often use this.
Also use unique constraints for "only one" rules. Examples are one active subscription per user, or one vote per user per poll. Do not check first and insert later.
Deadlocks
A deadlock happens when two transactions each hold a lock that the other one needs. Both wait forever. The database notices this and stops one of them. To reduce deadlocks:
- Lock rows in a consistent order.
- Keep transactions short.
- Retry the transaction that was stopped.
Connections matter too
A database connection is an open channel between your app and the database. Each one is expensive. In PostgreSQL, each one is a separate process on the server (a process is a running program). The server only supports a limited number of them (100 by default).
APIs use a connection pool. A pool is a small group of connections that requests share. If requests keep connections too long (long transactions, slow queries), the pool runs out. Then everything waits. This is why serverless deployments need a pooler (Lesson 08).
Common mistakes
- Indexing every column, or none besides the primary key.
- Forgetting indexes on foreign keys in PostgreSQL.
- Functions on indexed columns in
WHERE(lower(email),date(created_at)). - Read-check-write races. Use atomic updates, locks or constraints instead.
- Long transactions that include network calls.
- Trusting
EXPLAINon tiny dev data. SELECT *and fetching more than you need. It also blocks index-only scans.- Thinking the default isolation level protects you from everything.
Interview questions
When does an index not help?
An index does not help in these cases:
- The table is tiny.
- The column has low selectivity (most rows have the same value).
- The query uses a function or a cast on the indexed column.
- The query uses
LIKEwith a wildcard at the start. - The types do not match.
- A composite index is missing its leftmost columns.
- The query returns a large part of the table.
- The statistics are old.
Also, indexes make writes slower. So I remove indexes that nobody uses.
What does EXPLAIN ANALYZE show?
It shows the plan the database chose, and it also runs the query to show what really happened. I can see the node types (Seq Scan, Index Scan, joins, sorts). I can see the estimated and actual row counts, the time for each node, the loops, the rows removed by filters and the buffer hits. I compare the estimates with the actual numbers to find bad statistics. I also look for sequential scans, expensive sorts and nested loops with many repeats.
Scenario: two users buy the last item at the same moment. Prevent overselling.
I make the check and the decrement one atomic step. For example: UPDATE products SET stock = stock - 1 WHERE id = $1 AND stock >= 1. If zero rows are changed, the item is out of stock. There are other options: SELECT … FOR UPDATE, optimistic locking with a version column, a CHECK (stock >= 0) constraint as a safety net, or serializable transactions with retries. I avoid reading the stock in application code and then writing it back.
Read Committed vs Repeatable Read?
In Read Committed (the PostgreSQL default), each statement sees data that was committed before that statement began. So the same query run twice in one transaction can give different results. In Repeatable Read, the whole transaction uses one snapshot, so reads stay stable. But it can fail with serialization errors, and I must retry. Write skew is still possible. Serializable closes that gap.
Why index foreign keys in PostgreSQL?
PostgreSQL does not create an index on the referencing column for you. Without one, joins from parent to child need a sequential scan of the child table. Cascading deletes and updates need it too. This gets slower as the table grows.
How does column order in a composite index matter?
The index is sorted by the first column, then by the second column inside it. So it helps queries that use a leftmost prefix. I put the equality columns first. Then I put the column used for sorting or for a range. An index on (user_id, created_at) helps WHERE user_id = ? ORDER BY created_at. It does not help a query that only filters on created_at.
What is a lost update and how do you prevent it?
Two transactions read the same value. Both work out a new value. The second write overwrites the first, so one change is lost. I prevent it with atomic updates (SET x = x + 1), row locks (FOR UPDATE), optimistic version checks, or a stricter isolation level with retries.
Why keep transactions short?
A transaction holds locks and a pooled connection until it finishes. Long transactions block other writers and raise the risk of deadlocks. They also fill the database with old row versions. They can use up the whole connection pool. I do slow work, like calling an external API, outside the transaction.
Scenario: a report query takes 30 seconds, but only at month end. What do you check?
I check the data size and whether the plan changed. At month end, the date range covers many more rows. The planner may switch to a sequential scan, or the sort may spill to disk. I run EXPLAIN (ANALYZE, BUFFERS) with the month-end values. I check the indexes for the filter and sort columns. I compare estimated rows with actual rows. I also look for lock or disk problems from batch jobs. Fixes include the right composite index, pre-calculated summary tables, or running the report on a replica (a read-only copy of the database).
Key takeaways
- Indexes trade slower writes for faster reads. Design them from real queries, mind the column order, and index foreign keys yourself in PostgreSQL.
- Read
EXPLAIN (ANALYZE, BUFFERS): scan type, estimated vs actual rows, loops, sorts. - ACID transactions make multi-step changes all-or-nothing. Keep them short.
- Know the isolation levels and the anomalies. Prefer atomic updates, locks and constraints over read-check-write.
- Fix races in the database, not with
ifstatements in application code.
What's next
Lesson 08: Prisma in Production. Now we connect this to the ORM you actually use: N+1 queries, joins, batching and connection pools.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Indexes and EXPLAIN · SQL & PostgreSQL
- Transactions, Isolation Levels and Locks · SQL & PostgreSQL
- Design Pagination: Offset vs Cursor (Keyset) · Practical Design Problems
Part of the study guide
- SQL and PostgreSQL Guide: From First Query to Production
- Full-Stack Developer Interview Guide: Questions and Answers
Related series