Development

Postgres for Application Developers: The Parts That Matter

Antonio Evans8 min read

A developer in a vast, orderly warehouse pulls exactly the right box from the shelves using a glowing index card, while a calm elephant holds up the roof.
Key points

Key points

  1. 1An index is a separate sorted structure that points back to table rows, so it speeds up selective reads while adding cost to every write.
  2. 2EXPLAIN ANALYZE shows what the planner actually did, and the gap between estimated and actual rows is usually where a slow query starts.
  3. 3Read Committed is the Postgres default, and code that reads a value and then writes based on it needs either a row lock or a stricter isolation level with retries.
  4. 4Every Postgres connection is a separate server process, so a pooler such as PgBouncer is the standard fix when an application opens more connections than the database can afford.

The mental model that makes everything else easier

Lukas Fittl of pganalyze makes the point that databases are just like any other code: they run algorithms with known strengths and limits, and you get good performance by understanding those limits rather than hoping the database figures it out.

The simplest model has two parts.

  1. The table (the heap) holds the rows in no particular order. Finding a row without help means reading the table from start to end. That is a sequential scan.
  2. An index is a separate structure, usually a B-tree, that stores a sorted copy of one or more column values with a pointer back to the row. Finding a row through an index means walking a short tree and then jumping to the row.

Two consequences follow. First, an index only helps when it narrows the search a lot. Fittl describes this as cardinality: a primary key has one row per value and makes an excellent index, while a column with ten possible tags returns huge numbers of rows per value and makes a weak one. Second, every index must be updated on every insert, update and delete that touches its columns. The official documentation states it plainly: indexes also add overhead to the database system as a whole, so they should be used sensibly.

There is a subtle third consequence. Because a B-tree is sorted, inserting values in random order forces page splits all over the index, while inserting values in increasing order only extends the end. Fittl notes this is part of why random UUIDs are bad in Postgres as indexed keys, and recommends sequence values or time-ordered identifiers instead.

Indexes you need and indexes you do not

The Heroku Dev Center guide gives a useful rule: an index is a way to retrieve a relatively small number of rows, and it is only worth it when the WHERE clause is selective. With that in mind, these are the index shapes application developers reach for most.

Index shapeWhen to use itExample
Single-column B-treeEquality or range filters on one selective columnCREATE INDEX ON orders (customer_id);
Multi-column B-treeQueries that filter on the same leading columns togetherCREATE INDEX ON orders (customer_id, created_at);
PartialQueries that always target a small subset of rowsCREATE INDEX ON jobs (run_at) WHERE status = 'pending';
ExpressionQueries that filter on a function of a columnCREATE INDEX ON users (lower(email));
UniqueValues that must never repeatCREATE UNIQUE INDEX ON users (lower(email));
GINArrays, JSONB containment, full-text searchCREATE INDEX ON docs USING gin (tags);

Three rules from the Heroku guide prevent most mistakes.

  • Column order matters in a multi-column index. An index on (a, b) serves WHERE a = x AND b = y and WHERE a = x, but queries with WHERE b = y can't use an index on columns (a, b). Put the column you always filter on first.
  • Match the sort order of paginated lists. An index such as published_at DESC NULLS LAST lets Postgres read rows already in order and stop after the limit, instead of reading everything and sorting.
  • Build indexes on live tables concurrently. An ordinary CREATE INDEX blocks writes for the whole build, which can mean hours on a large table. The guide recommends CREATE INDEX CONCURRENTLY to avoid blocking writes during index creation. It takes longer but keeps the application running.

The same guide warns that testing index usage on a laptop with a small dataset is misleading. On a tiny table Postgres correctly decides that reading the whole table is cheaper than the random reads an index requires. Tune indexes against production data or a copy that matches it.

To find indexes you do not need, look for ones that are never scanned. Vikas Jindal's practical guide points to the pg_stat_user_indexes view and its idx_scan counter, and flags indexes with high writes and low reads as candidates to drop. An index with zero scans after weeks of normal traffic is pure write overhead.

Reading a query plan with EXPLAIN ANALYZE

EXPLAIN shows the plan the planner chose. EXPLAIN ANALYZE runs the query and adds what actually happened. The Postgres documentation on using EXPLAIN is the reference, but you can get most of the value from a few habits.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total
FROM orders
WHERE customer_id = 42
ORDER BY created_at DESC
LIMIT 20;

Kat Batuigas of Crunchy Data walks through the output in four parts: the tree structure of plan nodes, planning and execution times, estimated statistics, and actual run statistics. Read it this way.

  1. Find the node with the most time. Each node reports actual time in milliseconds. Child nodes feed their parents, so the expensive work is often deep in the tree.
  2. Compare estimated rows to actual rows. When the planner expected 10 rows and got 500,000, it probably chose the wrong join or scan type. Stale statistics are a common cause, and running ANALYZE on the table refreshes them.
  3. Look for "Rows Removed by Filter". In Batuigas's example, a parallel sequential scan read more than two million rows to return one. That line tells you an index on the filtered column is missing.
  4. Watch the loops count. A node with loops=5000 ran five thousand times. Multiply its time by its loops to see its true cost.

The thoughtbot guide adds a useful visual trick: redraw the plan as a tree of Sort, Hash Join, Seq Scan and Hash nodes so the structure is obvious before you look at numbers.

One warning applies to every write statement. Crunchy Data notes that EXPLAIN ANALYZE will actually run the query, so be careful with updates or deletes. Wrap those in a transaction and roll it back.

Transactions and isolation levels in practice

Isolation decides what one transaction can see of another that is running at the same time. The Postgres documentation lists the standard levels and the anomalies each allows, and notes that Read Committed is the default isolation level in PostgreSQL.

LevelWhat each statement seesWhat can still go wrong
Read Committed (default)Data committed before that statement beganA value read early can change before you write
Repeatable ReadData committed before the transaction beganUpdates that conflict fail and must be retried
SerializableAs if transactions ran one at a timeConflicting transactions fail and must be retried

The default works for most code. It breaks on the read-then-write pattern: read a balance, check it in application code, then write a new balance. Two requests can read the same starting value and both write, and one update is lost.

There are two standard fixes.

Lock the row you are about to change. SELECT ... FOR UPDATE holds a lock on the selected rows until the transaction ends, so a second request waits. Nile's guide to isolation shows the classic job queue built this way, using SELECT job FROM job_queue FOR UPDATE SKIP LOCKED LIMIT 1 so that several workers can pull jobs without picking the same one.

Raise the isolation level and retry. Under Repeatable Read or Serializable, Postgres detects conflicts and aborts one transaction with a serialization error. The documentation is explicit that applications using this level must be prepared to retry transactions due to serialization failures, and that the retry should restart the whole transaction from the beginning. If your data access layer cannot retry a full transaction, use row locks instead.

A simpler option often beats both: make the update itself do the check. UPDATE accounts SET balance = balance - 100 WHERE id = 7 AND balance >= 100 is atomic, and the affected row count tells you whether it succeeded.

Connections are expensive so pool them

Heroku's PgBouncer guide describes what happens when a client connects without a pooler: the server forks a new system process to handle the connection. Each process holds memory, and every managed Postgres plan has a connection limit. Serverless functions and horizontally scaled web servers each open their own connections, so the limit arrives faster than most teams expect.

A connection pooler sits between the application and the database and shares a small number of real connections among many clients. ScaleGrid describes PgBouncer as a lightweight proxy between the PostgreSQL database and client applications that speaks the Postgres protocol, so the application connects to it like any database.

PgBouncer offers three pooling modes, and the choice changes what your code may do.

  • Session pooling gives a client one server connection for as long as it stays connected. It supports every feature but saves the least.
  • Transaction pooling gives a client a server connection only for the length of a transaction. It saves the most, and it is what most serverless setups use.
  • Statement pooling disallows multi-statement transactions entirely.

The PgBouncer feature table lists what transaction pooling breaks: SET and RESET, LISTEN, SQL-level PREPARE, WITH HOLD cursors, and session-level advisory locks are marked "Never". If your code sets a session variable for row-level security or takes an advisory lock outside a transaction, it will behave unpredictably behind a transaction pooler. Use SET LOCAL inside a transaction and transaction-level advisory locks instead.

Let the database enforce your rules

Application validation runs before the write and cannot see what a concurrent request is doing. Heroku's index guide uses Rails as the example: an application-level uniqueness check doesn't guarantee uniqueness because concurrent users can create invalid duplicate records. The fix is a unique constraint or unique index in the database.

The same reasoning applies to other rules.

  • NOT NULL on every column that must have a value.
  • Foreign keys so an order cannot point at a customer that does not exist. Index the referencing column too, since joins and deletes on the parent table look rows up by it.
  • CHECK constraints for simple invariants such as quantity > 0.
  • INSERT ... ON CONFLICT for upserts, which the transaction isolation documentation describes as guaranteed to either insert or update each proposed row under Read Committed.

Constraints are also documentation. A new developer reading the schema learns the rules of the domain without reading application code.

A working checklist

Use this list when a feature touches the database in a new way.

  1. Every foreign key column has an index.
  2. Every query on a hot path has been run through EXPLAIN (ANALYZE, BUFFERS) against realistic data.
  3. Multi-column indexes put the always-filtered column first and match the sort order of paginated lists.
  4. New indexes on existing tables are created with CONCURRENTLY.
  5. Read-then-write logic either locks the row, does the check inside the UPDATE, or runs at a stricter isolation level with a full retry loop.
  6. Uniqueness and required fields are enforced by constraints, not only by application validation.
  7. The application connects through a pooler, and code that runs behind transaction pooling avoids session state.
  8. Unused indexes are reviewed and dropped.

If your team keeps reusable engineering checklists like this one, Quanta Intelligence skills and playbooks are one way to package them so every developer and coding agent applies the same rules.

FAQ

FAQ

Should every column in a WHERE clause have an index?

No. An index helps only when the filter is selective enough to return a small share of the table, and each index slows every write. Index the columns your frequent, slow queries filter or sort on, confirm the gain with EXPLAIN ANALYZE, and drop indexes that are never scanned.

Why does Postgres ignore my index?

Usually because the planner estimates that reading the table is cheaper, which is correct for small tables or filters that match many rows. It can also happen when statistics are stale, when the query wraps the column in a function the index does not cover, or when a multi-column index does not start with the filtered column. Run ANALYZE on the table and compare estimated rows with actual rows in the plan.

Which isolation level should a typical web application use?

Most applications run well on the default, Read Committed, combined with row locks or atomic conditional updates for the few operations that read and then write. Use Repeatable Read or Serializable when correctness depends on a consistent view across several queries, and only if your code can retry the whole transaction after a serialization failure.

Do I need a connection pooler if my framework already pools connections?

An in-process pool limits connections per application instance, not across all instances. When you run many web servers, workers or serverless functions, the total can still exceed the database limit, so an external pooler such as PgBouncer is the usual answer. Check whether your hosting provider already runs one for you.

Are UUID primary keys a bad idea in Postgres?

They work, but fully random UUIDs insert into random places in a B-tree index, which causes more page splits and more write I/O than increasing values. If you need globally unique identifiers, prefer a time-ordered format so new keys land at the end of the index. Sequence-based integer keys remain the cheapest option when you do not need global uniqueness.

Sources

Sources

  1. https://pganalyze.com/blog/5mins-postgres-for-app-developers-tables-indexes
  2. https://www.postgresql.org/docs/current/indexes.html
  3. https://devcenter.heroku.com/articles/postgresql-indexes
  4. https://www.postgresql.org/docs/current/using-explain.html
  5. https://www.crunchydata.com/blog/get-started-with-explain-analyze
  6. https://thoughtbot.com/blog/reading-an-explain-analyze-query-plan
  7. https://www.postgresql.org/docs/current/transaction-iso.html
  8. https://www.thenile.dev/blog/transaction-isolation-postgres
  9. https://devcenter.heroku.com/articles/best-practices-pgbouncer-configuration
  10. https://scalegrid.io/blog/postgresql-connection-pooling-part-2-pgbouncer/
  11. https://www.pgbouncer.org/features.html
  12. https://medium.com/@vikkasjindal/postgresql-indexes-a-practical-guide-for-developers-and-dbas-7503016a7ffa
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles