Problem Statement
An endpoint takes four seconds and there are two proposals on the table: add an index, or materialize the result. Choosing wrongly is not neutral — an unnecessary cache adds a refresh job, a staleness contract and a second copy of the data, while a missing index makes the cache expensive to maintain and leaves every other consumer slow. This page shows how to read the evidence that decides it.
Why the Naive Approach Fails
The usual decision procedure is a guess dressed as intuition: “it is a big table, so it needs an index” or “it is an aggregate, so it needs a cache”. Both are right often enough to be dangerous.
The evidence is already in the plan, and it takes one command to get:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT d.name,
count(p.id) AS parcels,
ST_Union(p.geom) AS coverage
FROM districts d
JOIN parcels p ON p.geom && d.geom AND ST_Intersects(p.geom, d.geom)
GROUP BY d.name;What matters is not the total but where it accumulates.
Production-Ready Implementation
A short procedure that turns the plan into a decision, written as a checklist you can run in ten minutes:
1. Capture the plan with real parameters. Bind values change everything — a plan for a tiny bounding box and one for a national extent are different queries in practice.
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT …; -- with the actual values your API sends, not placeholders2. Attribute the time. Read bottom-up and record three numbers: time in scan nodes, time in function and aggregate nodes, and the largest loops count anywhere in the tree. Remember that a child node’s actual time is per loop and must be multiplied.
3. Apply the rule.
| What dominates | Remedy | Why |
|---|---|---|
Scan nodes, high rows removed by filter |
index | the query cannot find its rows efficiently |
| Aggregate or geometry-function nodes | materialized view | the work is real and repeated identically |
A large loops count with small per-loop time |
rewrite | the plan shape is wrong, not the storage |
shared read far exceeding shared hit |
memory, or a smaller working set | nothing is cached, including the index |
4. Confirm the staleness budget before choosing the cache. A cache is only available as an option if somebody can say how out of date the answer may be. If the answer is “it must be current”, the choice collapses to index or rewrite regardless of the profile.
Configuration and Tuning Knobs
track_io_timing = on adds real I/O timings to EXPLAIN (ANALYZE, BUFFERS), which is what separates “slow because it read from disk” from “slow because it computed”. It costs a small amount of overhead and is worth enabling permanently on any database where these decisions get made.
shared_buffers sets the ceiling on what can stay cached. When the buffers line shows mostly read rather than hit, no index or materialized view changes the fundamental problem — the working set exceeds memory, and either the memory or the working set has to change.
work_mem affects sorts and hash aggregates. A GroupAggregate spilling to disk shows as external merge in the plan, and raising work_mem for that query can be a bigger win than either an index or a cache.
Verification Steps
Whichever remedy you pick, verify it against the same measurement that motivated it:
-- before and after, on the same query with the same parameters
EXPLAIN (ANALYZE, BUFFERS) SELECT …;For an index, the scan node should change type and its time should collapse. For a cache, the endpoint plan should no longer contain the aggregate at all — if it does, the application is still running the old query somewhere. For a rewrite, the loop count should fall by orders of magnitude.
And check the second-order effect. An index adds write cost: measure insert throughput before and after on a write-heavy table. A cache adds a refresh job: measure its duration and confirm it fits inside its schedule with room to grow.
A worked example of the decision
A reporting endpoint takes 3.4 seconds. The plan attributes 120 milliseconds to an index scan over parcels, 2.9 seconds to a GroupAggregate running ST_Union, and 380 milliseconds to a sort. Loops are all 1, and buffers are almost entirely shared hit.
Scans are 4% of the time, so an index changes nothing. Memory is fine, so provisioning changes nothing. The aggregate is the query, and the aggregate is the same for every caller because the grouping key is a district that changes only on an annual boundary import. The product owner confirms the report may be up to an hour old.
That is a materialized view, and the analysis took four minutes. Had the same plan shown 2.9 seconds in a sequential scan with rows removed by filter in the millions, the same four minutes would have produced the opposite answer with equal confidence — which is the point of reading the plan rather than reasoning from the shape of the SQL.
Gotchas Checklist
- A plan captured with
EXPLAINalone tells you nothing about time. OnlyANALYZEexecutes the query, and only executed nodes report reality. - Child node times are per loop. The single most common misreading of a spatial plan is treating a nested loop’s inner scan as if it ran once.
- Adding both an index and a cache is usually one too many. Do the index first, re-measure, and let the numbers decide whether the cache is still needed.
- A cache over a query with a bad plan preserves the bad plan. Refreshes will be slow forever, and nothing will surface it because no user waits on them.
SETTINGSin the EXPLAIN options shows non-default GUCs affecting the plan. It answers “why does this behave differently on staging?” faster than any other single flag.
Related Topics
- Materialized Views for Spatial Caching — parent topic: building the cache once you have decided
- Query Plan Analysis with EXPLAIN — reading the plan this decision rests on
- Reading EXPLAIN ANALYZE Output for Spatial Joins — the loop-count trap in detail
- Choosing a Spatial Index Type — if the answer turns out to be an index