Back to Blog
PostgreSQL: indexes, query plans, and correct transactions
Computer ScienceDatabasesPostgreSQL

PostgreSQL: indexes, query plans, and correct transactions

A practical database guide to designing indexes, reading execution plans, and preventing concurrent update mistakes.

A database can return the correct result slowly, or return an incorrect business outcome very quickly. Performance and concurrency need separate attention. This article follows an illustrative order dashboard and inventory operation to show how query shape, indexing, and transaction design fit together.

The SQL examples assume PostgreSQL and simplified tables. Test changes with realistic data and the PostgreSQL version used by your application.

Begin with the query the product actually needs

An order dashboard often asks for the newest orders belonging to one customer. Start with that access pattern rather than adding indexes to every column.

SELECT id, created_at, total_cents
FROM orders
WHERE customer_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 25;

The secondary order on id gives a deterministic order when timestamps tie. A candidate index is:

CREATE INDEX orders_customer_created_id_idx
ON orders (customer_id, created_at DESC, id DESC);

The customer equality narrows the search, and the remaining keys match the requested ordering. Multicolumn B-tree indexes are generally most effective when the leading columns fit the predicates; the exact plan depends on data distribution and planner choices. See PostgreSQL’s multicolumn index documentation.

An index occupies space and adds maintenance work to writes. The objective is to support important access patterns, not maximize the number of indexes. An index useful for one customer’s recent orders may be a poor fit for a report grouped across all customers.

Read a plan as a description of work

Use a representative query with EXPLAIN (ANALYZE, BUFFERS) in a suitable test environment. ANALYZE executes the statement, so do not casually run it on a production write. Compare estimated and actual row counts, inspect repeated loops, and examine where rows are filtered out.

Planner cost values are not milliseconds. Actual node times and row counts need to be interpreted with their loop counts, and parent timings include work beneath them. Buffer statistics help distinguish kinds of data access. The official EXPLAIN guide explains those fields in detail.

Suppose a query returns 25 rows but scans a huge portion of the table. Ask whether the predicate and ordering fit the index. If the planner expected only a few matches but encountered thousands, investigate statistics and data skew before forcing a plan. A sequential scan can be the right choice when much of the table is needed.

Pagination is also an access-pattern decision

Large offsets require skipping earlier rows before producing a page. For a forward-only feed, a cursor based on the last ordering values can express the next page directly:

SELECT id, created_at, total_cents
FROM orders
WHERE customer_id = $1
  AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT 25;

Here $2 and $3 are the last timestamp and ID from the preceding page. Assume both ordering columns are non-null and preserve their exact values in the cursor. Reapply the customer authorization and filters on every request. A cursor is navigation state, not permission to access an order.

Keyset pagination does not create an immutable snapshot across requests. New rows, deletions, or changes to ordering fields can still affect what the user sees. Decide whether the product needs a live feed or a stable exported snapshot.

Make the invariant explicit

Now consider an inventory rule: available quantity must never become negative. A naive implementation reads the quantity, checks it in application code, then writes a decremented value. Two concurrent requests can both observe the same initial stock and both conclude that a purchase is allowed.

Express the condition and mutation together:

UPDATE inventory
SET available = available - $1
WHERE product_id = $2
  AND available >= $1
RETURNING available;

Validate that $1 is a positive integer, make product_id unique, and interpret zero returned rows as “not available or not found.” A check constraint on nonnegative quantity provides another layer of protection. If an order row must be created with this decrement, execute both operations in one transaction and roll back if either fails.

This example solves a single-row inventory condition. It does not automatically solve invariants spanning multiple products, warehouses, or external payment systems.

Know what isolation promises

PostgreSQL’s default Read Committed mode gives each statement a snapshot of committed data at statement start. Two statements within one transaction can therefore observe different committed states. Stronger isolation changes which anomalies are possible; Serializable can reject a transaction when necessary to preserve a serializable outcome. Applications must be prepared to retry the entire transaction after serialization failure. These guarantees are documented in PostgreSQL’s isolation reference.

Keep transaction retries separate from external side effects. Sending a notification inside a transaction that later retries may send it twice. A transactional outbox can record the intended notification alongside the order, with a separate delivery worker and duplicate-aware processing.

Validate performance and correctness separately

Use a realistic dataset to compare query plans and latency. Then run concurrent inventory requests against a small stock count and check the final invariant. One test answers whether the operation is efficient; the other answers whether it remains correct under contention.

A good database change comes with a reason: this index supports this access pattern, this transaction protects this invariant, and these measurements demonstrate the expected behavior. That reasoning is more durable than copying a list of optimization tips.

Related Posts

Data structures and Big O through real application problems

Data structures and Big O through real application problems

Understand arrays, hash maps, queues, heaps, and graphs by tracing the work an application performs.

Computer ScienceAlgorithmsData Structures
Read More
JavaScript concurrency: the event loop, races, and backpressure

JavaScript concurrency: the event loop, races, and backpressure

Understand async execution, avoid stale responses, and limit parallel work without blocking the interface.

Computer ScienceWeb DevelopmentJavaScript
Read More
Building a foundation for a generative media SaaS

Building a foundation for a generative media SaaS

Notes on the backend services, data model, reporting, and integration work behind Pixovid.

Full StackSaaSPostgreSQL
Read More