Identify the business query first

A slow page is not automatically a slow SQL statement. The request may wait on application code, an external service, a lock, connection acquisition or several individually modest queries. Begin with a customer-visible symptom: which action is slow, at what time, for which account or data shape, and how often? Trace that action through application timing to the database statement and record the query fingerprint, parameters or parameter ranges, and relevant table sizes. Avoid copying personal data or secrets into issue tickets. A single hand-picked query with tiny test data rarely represents production. The objective is to identify a repeatable workload that can be measured before and after a change, not to produce an impressive-looking execution plan.

PostgreSQL's pg_stat_statements module can track planning and execution statistics for SQL statements. Its documentation says it needs shared_preload_libraries and a server restart to add or remove the module, with query identifiers enabled; each database then needs the extension to access its views. That means enabling it is an operational change to plan, not a harmless dashboard toggle. If it is already available, compare calls, mean and maximum execution time, total time and block activity over an agreed observation window. A query that is moderately slow but called very often can consume more capacity than an occasional outlier. Conversely, a rare but deadline-critical report may still deserve priority. Keep the time window and workload context beside every figure.

For example, suppose an order-history page becomes slow only for accounts with years of records. The proposed investigation, not an observed client result, is to capture the exact filtering and ordering used for both a small account and a large account. Include whether the interface requests the first page or deep pages. A plan for the small account may favour scanning because few rows exist; that does not invalidate an index for the large account. Likewise, if the query returns thousands of records to render a screen that shows only a few, reducing the result set may solve more than adding an index. Define the customer goal, such as showing the newest relevant orders, before selecting a database remedy.

Read the plan as evidence, not a verdict

Use EXPLAIN to inspect the planner's proposed operations and estimated row counts. EXPLAIN ANALYZE is different: PostgreSQL actually executes the statement and reports actual rows and timing. The official guide warns that estimates vary with sampled statistics and that plan interpretation takes care. For a write statement, actual execution can modify data, so never run it casually on live business records; arrange a safe environment or controlled transaction with a database specialist. For a read query, choose representative parameter values and a time when diagnostic load is acceptable. Save the plan, PostgreSQL version, schema and relevant settings so a later comparison has context. A bare screenshot of one timing number is not enough.

Compare estimated and actual rows at the node where they first diverge materially. If the planner expects a handful but gets a large share of a table, outdated or insufficient statistics may be a better first suspect than a missing index. PostgreSQL's ANALYZE command collects table statistics used by the planner; an appropriate statistics refresh can change plan selection, though it should be scheduled with awareness of workload. Look at the filter, join and sort operations as well as scan type. A sequential scan is not inherently a bug when much of a small table is required. A planned index scan is not inherently good if it retrieves many scattered rows and then sorts them. Investigate the actual query and data distribution before judging by an operator name.

The BUFFERS option adds information about buffer hits, reads, dirty blocks and writes for plan nodes. It helps distinguish a query mostly using cached pages from one bringing pages into memory, but a buffer hit is not a promise that the whole query is cheap. Repeated runs can benefit from warm caches and may not resemble the first customer request after a quiet period. Compare plans under similar data and cache conditions, and include application-level latency because network transfer, JSON construction and rendering happen outside the database plan. If the symptom is intermittent, check locking and concurrency as part of the broader trace; an index experiment alone may hide rather than resolve that cause.

Choose and test a proportionate change

An index is one candidate remedy when a stable predicate or ordering can let PostgreSQL find a useful small subset of rows. Inspect existing indexes first, including column order and predicates. PostgreSQL documents that indexes can speed retrieval but add overhead to the database as a whole; every new index also has storage and maintenance costs. A composite index that reflects a real filter and sort may be useful, while a broad collection of speculative single-column indexes can burden writes without improving this query. Alternatives include returning fewer columns or rows, rewriting an unnecessary join, fixing pagination, refreshing statistics or changing a costly application access pattern. State the hypothesis and expected plan change before creating an index.

On a live table, index creation is itself a deployment decision. PostgreSQL's CREATE INDEX documentation explains that concurrent creation avoids locking out writes in the way a regular index build can, but it takes more work, has operational caveats and cannot run inside a transaction block. It can also leave an invalid index after failure, which must be investigated rather than treated as a completed fix. Confirm the database version and exact command with the operator before scheduling. Rehearse on realistic data, check available disk capacity and monitor the build. An index that helps one read path but materially slows order writes or exhausts storage is not an acceptable solution merely because one EXPLAIN plan looks faster.

After the change, compare the same customer action and representative query parameters over a comparable observation window. Record actual execution plans, request latency, call volume, writes, storage and error behaviour. Check both the high-volume and difficult-tail cases; an average can conceal the customers still waiting. If the planner does not use the index, investigate why rather than forcing it blindly: the table may be small, selectivity may be low, statistics may be stale, or the query expression may not match. Keep a rollback plan for any operational change, including how to remove an unwanted index safely. Close the issue only when the business action is improved and the new cost is understood, not when a migration command finishes.

A worked comparison keeps the decision honest. Suppose the order-history page filters by account, sorts by newest order and requests a bounded page. First record the real query shape and plan for a recent account and a long-lived account; do not invent a benchmark result. If the long-lived case reads many rows and sorts them before returning a small page, test whether the filter and ordering can be supported by an existing or carefully chosen composite index. If it instead spends time waiting for a lock, an index aimed at the scan will miss the cause. If the application fetches all history and slices it after transfer, fix that boundary before adding database structures. For every candidate, write down the expected effect on rows read, sort work, write maintenance and storage. Then measure the same cases after the change. This prevents a persuasive plan screenshot from replacing a real customer outcome and exposes trade-offs that a single query timing hides.

Give the measurement a stopping rule before tuning begins. State the customer action and an acceptable response target chosen by the business, then identify the query's contribution to the total. If a database change removes only a small part of a request dominated by rendering or an external service, stop adding indexes and investigate the larger delay. Conversely, if a query improvement helps the common case but worsens a large-account case, retain the difficult case as a release blocker until it is understood. Compare equivalent traffic periods and explain any changed data volume, hardware, cache state or application release. Avoid claiming that a lower mean execution time proves every customer improved. The decision is complete when measured customer outcomes, operational cost and rollback readiness support the change; the plan remains evidence for that judgment rather than the objective itself.

Before you change production

  • Can you link the slow customer action to a representative SQL statement?
  • Did the plan reveal the first material estimate or work mismatch?
  • Will the proposed fix improve the business action without unacceptable write cost?

Sources & further reading