Making it fast¶
A rider finishes a long day in the Ardennes, exports the GPX from their bike computer, and drops it into the map's ride-check box. The question is easy to say out loud: which of the things on this map did I actually pass? Our fountain is one of the answers, 80 metres off the track, somewhere around kilometre 23.
Chapter 4 (spatial-questions.md) already gave you everything you need to
express that question, and chapter 3 (metres-vs-degrees.md) gave you the
::geography cast that makes "within 250 metres" mean real metres instead of degrees. Put the two
together and you get a query that is short, readable, and correct.
It is also, written the obvious way, unusably slow. Not wrong. Slow.
That gap is what this chapter is about, and it is the one thing in this series you cannot discover
by reading code carefully. A query that compares your shape against every row in the table works
perfectly on a freshly seeded development database with a few hundred catalog items. It works so
well that nothing suggests there is a problem. Then the same code meets a table that has grown:
this project's coverage_poi table holds around two million rows across nineteen countries at
the time of writing (Germany alone contributes close to 400,000), and the planet-wide target for that
table is around 4.7 million points (pipeline/coverage/load.py, the sizing comment above
_SOURCE_DDL). The
query does not change. The answer does not change. What changes is how many rows it has to touch,
and for a query with no index to help it, that is the whole of the cost, because every row gets the
same treatment. A freshly seeded development catalog is a few hundred rows. The planet-wide target
is 4.7 million. That is four orders of magnitude more rows put through an identical statement.
The thing that closes that gap is an index. Not the ordinary kind (the ordinary kind cannot help here at all) but a spatial one, and it works differently enough from a B-tree that it is worth understanding rather than just switching on.
This chapter ends with a real rewrite that happened in this repository, is recorded in the code's
own comment and in the design note that shipped it
(docs/specs/Dated/2026-07-14-town-search-and-ride-check-design.md), and took one query from 62
seconds to under a second without changing what it returns.
Bounding boxes¶
Start with the simplest idea in spatial indexing, because everything else is built on it.
A bounding box is the smallest upright rectangle that completely contains a shape: four numbers, the smallest and largest x and y, never rotated to fit the shape more snugly. It is cheap twice over. Cheap to compute, because a twelve-thousand-point outline reduces to four numbers in one pass and the database stores the result. And cheap to compare, because two boxes overlap only if they overlap on both axes, which is four comparisons against walking an outline.
Now the part that matters, and that people get backwards:
A bounding box test can prove that two shapes cannot touch. It can never prove that they do.
If the boxes do not overlap, you are finished: the shapes are definitely apart, and you did not have to look at either shape to know it. That is a proof, not a guess.
If the boxes do overlap, you have learned nothing certain. Wallonia's bounding box contains a good deal of ground that is in France, and a point sitting in that ground passes the box test while being firmly outside the region. The box is a conservative approximation: it never wrongly excludes, and it frequently wrongly includes.
That asymmetry is not a flaw to be worked around. It is the whole design. It means a box test can be used to throw work away safely, and it means a box test alone can never be the final answer.
What a GiST index actually does¶
GiST stands for Generalised Search Tree. The generalisation is the point. An ordinary B-tree
index, the one you get by default on an integer or a string column, works because those values
can be put in order. 4 is less than 7; "amsterdam" sorts before "berlin". A tree of sorted
values lets the database jump straight to a range and ignore the rest.
Geometry has no such order. Is a Belgian province "less than" a drinking fountain? The question is
meaningless. PostGIS does ship a B-tree operator class for geometry, btree_geometry_ops, which is
why ORDER BY geom and GROUP BY geom are legal statements at all, but the ordering it imposes is
an arbitrary tie-break, there so that sorting and grouping have something to work with. It does
not put nearby shapes near each other, and it cannot answer "overlaps" or "contains". So a B-tree
over a geometry column is not a slow spatial index; for the questions in this chapter it is not a
spatial index at all. GiST is PostgreSQL's framework for building index trees over data where "less
than" makes no sense but "contains" and "overlaps" do, and PostGIS uses it to index geometry.
Here is what it stores, which is the whole trick: not the shapes, the boxes. Every row's geometry is reduced to its bounding box, and those boxes are grouped into a tree. Every node in the tree stores one box that encloses all the boxes beneath it. A leaf holds a row's own box and a pointer to the row.
Searching is then a descent. You give the tree the box of the thing you are asking about, and at each node you compare boxes. If a node's box does not overlap your query box, nothing underneath it can possibly match, so you skip the node and every row beneath it, without ever touching them. That is why the tree wins: it discards rows in whole branches instead of one at a time.
What comes out of the descent is a candidate set: every row whose box overlaps yours. From the previous section you know exactly what that is worth. It is guaranteed to contain every true match, and it is also guaranteed to contain some rows that are not matches at all.
So the database runs a second phase. For each candidate it fetches the real geometry and runs the real, exact test. The survivors of that recheck are the answer.
Two phases, and they have different jobs:
- The index scan. Box against box, in a tree. Fast, approximate, and it only ever errs by letting too much through.
- The exact recheck. Real geometry against real geometry, on the small set phase 1 handed over. Slow per row, but there are now very few rows.
PostGIS wires this into the functions themselves. Something like ST_Intersects(a, b) is defined
as a bounding-box overlap test, written a && b, and that is the part the GiST index can answer,
combined with the exact internal test. You write one function call; you get both phases.
Every geometry column this project searches spatially carries one of these indexes, created in the same statement block that created its table:
CREATE INDEX idx_item_geom ON item USING GIST (geom),web/migrations/Version20260703153611.php:27idx_route_geomonrecommended_routeandidx_heat_geomonheat_point, same file, lines 31 and 33idx_region_geomonregion,web/migrations/Version20260703152605.php:24idx_submission_geomonsubmission,web/migrations/Version20260704222148.php:30coverage_poi_geom_idxoncoverage_poi, not a Symfony migration at all. That table is owned by the Python pipeline, so its schema and its indexes live inpipeline/coverage/load.py, in the_INDEX_DDLtuple next to the table's own DDL. The same tuple adds a second spatial index,coverage_poi_geog_idx, built on(geom::geography)rather than on the bare column; the next section is about why that one exists.
One geometry column in this codebase has no GiST index: users.base_point, the rider's coarse home
location added by web/migrations/Version20260721160000.php. That is deliberate rather than
forgotten. Nothing ever searches for users by that point. It is read back out for a row the code
already has, BaseLocationService pulls it apart with ST_X and ST_Y
(web/src/Service/BaseLocationService.php), and the searching it feeds is done against region,
which does have an index. An index you never search is storage and write cost for nothing.
Which predicates can use it¶
Here is the rule that the rest of this chapter is a demonstration of. It is short, and it is easy to half-remember in a way that is wrong.
An index holds boxes for the exact expression it was built on. Every one of ours on the application tables was built on the bare column:
USING GIST (geom). So the only comparisons those indexes can serve are comparisons whose indexed side isgeom, exactly as it is stored.
The predicates from chapter 4 are all built to cooperate with that. ST_Intersects, ST_Contains
and the geometry form of ST_DWithin are each defined in terms of a bounding-box operator over
their arguments, which is precisely the thing a GiST index answers, and so is the rest of the
PostGIS relationship family alongside them. Hand one of them a bare indexed column and a value that
does not depend on the row, and the two-phase machinery from the previous section is available.
Two things take that away.
A function wrapping the indexed column. ST_Buffer(i.geom, 0.01), ST_Centroid(i.geom),
ST_PointOnSurface(i.geom): each of these computes a new shape, per row. idx_item_geom holds
boxes for i.geom. It holds nothing whatsoever about ST_Centroid(i.geom). It is not that the
database refuses to use the index; it is that the index genuinely contains no information about the
value being asked for.
You can watch both halves of that in one line of this repository. The region backfill in
web/src/Catalog/Command/ImportCatalogCommand.php joins items to regions with
ST_Contains(r.geom, ST_PointOnSurface(i.geom)). The right-hand side is a function of i.geom, so
nothing about idx_item_geom applies to it. The left-hand side is r.geom, bare and indexed, so
the box test on the region side is available. One predicate, one indexed side and one not, which
is the right shape for a backfill that has to visit every item anyway.
A cast. This is the one that bites, because a cast does not look like a function call and does not read like work.
i.geom::geography is not i.geom. It is a different value, of a different type, with different
semantics, chapter 3 is entirely about what those semantics buy you. It has to be computed, and it
is computed once per row. The GiST index was built on i.geom, so it knows nothing about
i.geom::geography, and it cannot serve a comparison phrased in terms of it.
The problem is not that geography maths is expensive. It is expensive (an ellipsoid distance is real trigonometry, much more work than comparing four numbers), but that is not what breaks. The problem is that phrasing the comparison in terms of the cast puts it outside what the index knows, so every row in the table reaches the expensive part. Cheap maths on every row would also be too slow, once there are enough rows.
There is one way round that, and coverage_poi uses it: build a second index on the cast itself.
coverage_poi_geog_idx is USING gist ((geom::geography)), a functional index. It holds boxes for
the expression geom::geography, so a predicate phrased in exactly those terms,
ST_DWithin(cp.geom::geography, …), which is what CoverageRepository::nearby() and the
OSM-linking candidate search run, is served by it. The comment beside it in _INDEX_DDL records the
measurement that justified it: 716 ms down to 2 ms for a radius query that had been reading every
water POI in the table for every moderation card. The price is a second index to build and keep on a
table the pipeline rewrites region by region, and it is paid there because those radius queries run
constantly. The application tables do not carry one: item is small enough that the rewrite below,
which needs no extra index at all, is the right tool, and it is the more general lesson.
Keep that distinction in hand for the next section, because the fix that follows does not make the maths cheaper. It makes fewer rows reach it.
A real rewrite¶
This happened here. The code, the comment explaining it, and the measurement are all in
web/src/Catalog/RideCheckService.php.
The question¶
A rider uploads a GPX file. RideCheckService::check() parses it, simplifies the track, and turns
it into a GeoJSON LineString (chapter 2, shapes.md). Then it asks the database:
which served catalog items lie within the rider's chosen radius of that track? The radius is one of
ALLOWED_RADII, 100, 250, 500 or 1000 metres, defaulting to 250, and the work happens in the
private method RideCheckService::corridorGroups().
Each match also needs two numbers to be useful: how far off the track it was, and how far along the
ride it appeared, so the results can be listed in the order the rider met them. Those come from
ST_Distance and from ST_LineLocatePoint over ST_ClosestPoint, both introduced in chapter 4.
The naive version¶
The obvious way to ask "within 250 real metres of this line" is the one chapter 3 teaches:
Read it and there is nothing to object to. It is short. It says exactly what it means. The radius is in metres, honestly measured on the ellipsoid, which is the entire reason for the casts. Every review would pass it.
And it is the shape the previous section just described. The cast sits on item.geom, the indexed
column, so idx_item_geom cannot serve the comparison. Every row in item is read, a sequential
scan, Seq Scan in a query plan, meaning the database walks the table from the first row to the
last because it has no better way in, and for every one of those rows it computes an exact
ellipsoid distance against the whole simplified track, a line with a lot of vertices in it.
The design note that shipped the rewrite
(docs/specs/Dated/2026-07-14-town-search-and-ride-check-design.md, the execution note on the §4.2
corridor predicate) records what that cost, and it is not a rounding error: ST_DWithin(::geography)
on the column, with an inlined track CTE re-parsing the GeoJSON per row per ST_* call, came to
62 s live. Sixty-two seconds, for a request a rider is sitting and waiting for.
The fix¶
The rewrite does not touch the maths and does not change the answer. It changes where the cast sits.
Build the corridor first. Take the track, cast it to geography, and buffer it by the radius,
ST_Buffer on a geography argument grows the shape by that many real metres, for the same reason
ST_DWithin on geography measured in real metres. Then cast the result back to geometry, so what
comes out is an ordinary EPSG:4326 shape. That is a single value, computed once, that does not
depend on any row.
The two lines that do it:
WITH track AS MATERIALIZED (SELECT ST_SetSRID(ST_GeomFromGeoJSON(:geom), 4326) AS g),
corridor AS MATERIALIZED (SELECT ST_Buffer((SELECT g FROM track)::geography, :radius)::geometry AS b)
The test in the WHERE clause is then simply ST_Intersects(i.geom, (SELECT b FROM corridor)):
the bare indexed column on one side, one constant geometry on the other. That is exactly the
comparison idx_item_geom was built to answer, so the two-phase machinery from the top of this
chapter switches on, the tree prunes the table down to the handful of items whose boxes overlap
the corridor, and only those get an exact geometry test.
Notice what did not happen:
- Nothing got cheaper. Buffering a long line is considerably more work than one distance calculation. It is simply done once instead of once per row.
- Nothing got less accurate about the radius. The metres are still real metres, still measured
in geography, still on the ellipsoid. The
::geographycast was not removed. It was moved to the other side of the comparison, where the row count is one. - The exact test still runs.
ST_Intersectsis not a box test; it is a box test followed by an exact test. Phase 2 is still there. It just has almost nothing left to check.
The distance and along-the-ride numbers are still computed with ST_Distance and
ST_LineLocatePoint, in the SELECT list, which means they now run only for rows that already
survived the corridor test, rather than for the whole table.
The same idiom appears immediately below in RideCheckService::followedRoutes(), which reuses the
identical corridor and probes recommended_route with ST_Intersects(r.geom, …). Its own comment
names the index it rides: idx_route_geom.
::geography cast. Above, it sits on i.geom, the indexed column, so the index holds nothing about the value being compared and every row in the table has to be read and measured. Below, it sits on the track, inside a corridor that is built once; the comparison that reaches the table is i.geom exactly as stored, which is what idx_item_geom holds boxes for, so whole branches of the tree are skipped and only a handful of rows are ever examined exactly. The plan node may appear as Index Scan or as Bitmap Index Scan feeding a Bitmap Heap Scan; both mean the index was used.One honest footnote. A buffer is a polygon, and a polygon's edge approximates a true circle with a finite number of straight segments. So "inside the corridor" and "within exactly N metres" are not character-for-character the same set: a point sitting almost precisely on the boundary could fall on either side of it. At the radii this feature offers, 100 metres to a kilometre, that difference is far below the accuracy of the GPS trace being measured against, and well below the point where it could change an answer a rider would notice.
Why MATERIALIZED is load-bearing¶
You will have noticed the word MATERIALIZED in both of those CTEs. It is not decoration, and the
reasons it is there are worth separating, because there are two of them and they are independent.
A CTE, a Common Table Expression, is the WITH name AS (…) block at the top of a query. It
names a subquery so the rest of the statement can refer to it. PostgreSQL is allowed to inline a
CTE: instead of computing it once and keeping the result, it substitutes the definition at each
place the name is used, which lets the planner optimise across the boundary. Usually that is a good
thing. Writing MATERIALIZED removes the choice: compute it once, keep the result, use it
everywhere.
The comment above the query in corridorGroups() states both reasons in the developers' own words:
MATERIALIZED is load-bearing: an inlined track CTE re-parses GeoJSON per ST_*; ST_Intersects can use the GIST index.
Reason one: the parsing. The track CTE's body is ST_GeomFromGeoJSON(:geom): it takes the
uploaded track as a JSON string and parses it into a geometry. The query then refers to track in
four separate places: once to build the corridor, once inside ST_Distance, and twice inside the
ST_LineLocatePoint / ST_ClosestPoint pair. If that CTE were inlined, each of those references
would become its own parse of the entire GeoJSON text, and the three of them that sit in the
SELECT list, the one inside ST_Distance and the two inside the ST_LineLocatePoint /
ST_ClosestPoint pair, would do it again for every row that comes back. MATERIALIZED means the
string is parsed exactly once.
Reason two: the shape of the test. As the comment puts it, ST_Intersects can use the GiST
index, and it can only do so because the corridor it is compared against is one value, not a
per-row expression. MATERIALIZED is what guarantees that: one buffer, one value, computed before
the table is touched.
Do not merge those two into one idea. The first is about how many times a string is parsed. The second is about which comparison arrives at the table. Fixing either one alone would leave the query slow for the other's reason.
And here is the property that makes this section worth a heading of its own: none of this is
visible in the query's text. The version with MATERIALIZED and the version without it look
almost identical, return identical rows, and differ by one word. There is no error, no warning, and
no test that fails. The only way to see the difference is to read the query plan, which is the next
section.
How to tell¶
Put EXPLAIN ANALYZE in front of the statement. EXPLAIN alone reports what the planner intends
to do, with estimated costs. Adding ANALYZE actually executes the statement and reports real
timings and real row counts alongside the estimates, which is what you want, because a plan that
looks sensible and estimates badly is the usual failure. Both of the ride-check queries are
read-only, so running them is safe; for anything that writes, wrap it in a transaction and roll
back.
You cannot copy the SQL out of the PHP file and paste it in. That statement is assembled at
runtime. :geom and :radius are Doctrine DBAL placeholders, not values, and
ItemState::servedSqlTuple() (web/src/Catalog/ItemState.php) is PHP string interpolation that
expands to the tuple ('unverified', 'verified'). You need the expanded statement, either from the
Doctrine panel of the Symfony profiler in the dev environment, which logs what was actually sent, or
by substituting a real GeoJSON LineString and a radius by hand.
Then run it against the dev stack's database:
(make sh c=db opens a shell in the same container if you would rather get there that way.)
Now read the output. Do not try to memorise what a good plan looks like, plans vary with the data, the version, and the statistics. Look for these four things instead.
The scan node on the spatial table. If you see Seq Scan on item and you know idx_item_geom
exists, something in the predicate stopped the index being usable. In practice it is almost always
one of the two things from earlier in this chapter: a cast on the indexed column, or a function
wrapping it. Check the WHERE clause for ::geography and for ST_-something applied to the
column before you look anywhere else.
The index actually being named. A healthy plan mentions it: Index Scan using idx_item_geom, or
a Bitmap Index Scan using idx_item_geom feeding a Bitmap Heap Scan. The second form is the
two-step one, the index scan collects the locations of all matching rows into an in-memory bitmap,
and the heap scan then fetches them in physical table order rather than one at a time in index
order. Both mean the index was used. Which one the planner chooses depends on how many rows it
expects to get back, and neither is a problem.
Rows Removed by Filter. That number is phase 2 doing its job: candidates the box test let
through and the exact geometry rejected, point 2 in figure F8. Where it appears depends on which of
those two shapes the plan took. On the bitmap path the recheck is the Filter on the Bitmap Heap
Scan, the node above the Bitmap Index Scan. On a plain Index Scan there is no node above: the
Index Cond and the Filter sit on the same node, and Rows Removed by Filter is reported there.
Either way, a small number is healthy and expected. A large one means the boxes are poor stand-ins
for the shapes they represent. The classic case is a long diagonal line: a diagonal LineString's
bounding box is mostly empty space, so it collects candidates it will then throw away.
Whether the CTE is computed once. With MATERIALIZED you should see the CTE as its own node,
executed once. Inlined, it will not appear as a node at all; the expression turns up inside the scan
that uses it, and it runs as often as that scan does.
One more warning, and it is the reason this chapter exists at all. EXPLAIN ANALYZE against a
seeded development database tells you very little. For a table of a few hundred rows the planner
will choose a sequential scan on purpose, because reading the whole table is genuinely cheaper than
descending a tree and then fetching rows one at a time. That choice is correct, and it means a
missing-index problem is completely invisible at that size. If you want to know whether a spatial
query will hold up, test it against a table with a realistic number of rows in it.
Finally, a caution against reading this chapter as a checklist. The ::geography shape appears in
several other places in this repository, SurfaceProfiler::profile()
(web/src/Catalog/SurfaceProfiler.php), BaseAreaResolver::resolve()
(web/src/Service/BaseAreaResolver.php), and both arms of CoverageRepository::nearby()
(web/src/Coverage/CoverageRepository.php). That is not a list of bugs. Whether the shape matters
depends entirely on how many rows the other conditions leave for it, and on which indexes exist:
BaseAreaResolver filters region outlines, of which there are not many, and the coverage queries
run against coverage_poi_geog_idx, the functional index built for exactly that shape. The rule is
not "never cast a column". The rule is know which comparison the index can serve, and measure the
path you are actually changing.
What to carry into chapter 6¶
- A bounding box is four numbers: the smallest upright rectangle containing a shape. It can prove two shapes cannot touch. It can never prove that they do.
- A GiST index stores those boxes in a tree, so the database can skip whole branches instead of rows. What it produces is a candidate set, not an answer.
- Every spatial query therefore has two phases: a cheap approximate filter from the index, then an exact recheck on the survivors. Both are real work, and only the second one is correct on its own.
- An index holds boxes for the expression it was built on. The application tables' indexes are
built on the bare
geomcolumn, so a cast or a function applied to that column puts the comparison beyond their reach.coverage_poiadds a functional index ongeom::geographyso that its radius queries stay served; that is the exception that proves the rule. - The ride-check fix was not cheaper maths. The maths is the same and the metres are still real. What changed is which side of the comparison the cast sits on, so the question that reaches the table is one the index can answer.
MATERIALIZEDis worth understanding for two separate reasons: it stops a CTE's body being re-evaluated, and it pins the corridor to a single value computed once.- "It works on the seed data" is not evidence. Test the plan against realistic row counts, with
EXPLAIN ANALYZE.
The fountain now has a position, a shape, a way to be asked about, and a way to be asked about
quickly. What it does not yet have is an origin. Everything so far has assumed the row already
exists in our database, but somebody mapped that fountain in OpenStreetMap, in a data model that
looks nothing like ours, and it had to get from there to here. Chapter 6
(osm-to-database.md) follows it the whole way.
Further reading¶
- PostgreSQL: using EXPLAIN: how to read a plan, rather than how to memorise a good one.
- PostGIS spatial indexing: GiST from the introduction that explains it best.
Try it¶
Hands-on: watch Seq Scan become an Index Scan
Run the naive ::geography form of "within 250 m of this point" against item, then run the
ST_Intersects-on-a-precomputed-corridor rewrite this chapter just walked through, and read the
two query plans side by side. The point is the centre of Stavelot, where three of the pins
make course-data seeds sit within a couple of hundred metres of each other, at the 250 m
radius one of RideCheckService::ALLOWED_RADII actually offers. Open a psql session the way
this chapter already showed, then run the naive form first:
EXPLAIN ANALYZE
SELECT count(*)
FROM item
WHERE ST_DWithin(geom::geography,
ST_SetSRID(ST_Point(5.9300, 50.3950), 4326)::geography, 250);
Aggregate (actual time=51.541..51.544 rows=1.00 loops=1)
-> Seq Scan on item (actual time=26.876..51.524 rows=3.00 loops=1)
Filter: st_dwithin((geom)::geography, …, '250'::double precision, true)
Rows Removed by Filter: 4972
Execution Time: 52.304 ms
Seq Scan on item: the whole table, every row read and tested, and Rows Removed by Filter
is the table minus the three answers. Now the rewrite: buffer the point once, cast back to
geometry, and test with ST_Intersects against the bare indexed column:
EXPLAIN ANALYZE
WITH corridor AS MATERIALIZED (
SELECT ST_Buffer(ST_SetSRID(ST_Point(5.9300, 50.3950), 4326)::geography, 250)::geometry AS b
)
SELECT count(*)
FROM item
WHERE ST_Intersects(geom, (SELECT b FROM corridor));
Aggregate (actual time=3.555..3.582 rows=1.00 loops=1)
CTE corridor
-> Result (actual time=0.001..0.002 rows=1.00 loops=1)
-> Index Scan using idx_item_geom on item (actual time=1.368..3.569 rows=3.00 loops=1)
Index Cond: (geom && (InitPlan 2).col1)
Filter: st_intersects(geom, (InitPlan 2).col1)
Execution Time: 3.706 ms
Index Scan using idx_item_geom instead of a scan of the whole table. Both queries agree the
answer is 3 (the two Stavelot fountains and the abbey). The rewrite changes the plan, never the
result. The scan-node names are the reproducible part; the row counts depend on what your
install holds, and the millisecond figures are this machine, right now, and will move every run.
On a freshly seeded catalog of a few hundred rows the planner may pick a sequential scan for the
corridor form too, because reading a tiny table really is cheaper than descending a tree; the
warning at the end of "How to tell" is about exactly that.
The same two queries on coverage_poi, and why the naive form is not a Seq Scan there
Run both statements again with coverage_poi in place of item. The corridor form behaves
the same way, riding coverage_poi_geom_idx. The naive form does not fall back to a
sequential scan, and the reason is the functional index this chapter introduced:
Aggregate (actual time=0.584..0.585 rows=1.00 loops=1)
-> Index Scan using coverage_poi_geog_idx on coverage_poi (actual time=0.225..0.581 rows=8.00 loops=1)
Index Cond: ((geom)::geography && _st_expand(…, '250'::double precision))
Filter: st_dwithin((geom)::geography, …, '250'::double precision, true)
Execution Time: 0.613 ms
Read the Index Cond line: the indexed side is (geom)::geography, the exact expression
coverage_poi_geog_idx was built on, so the box test runs against the cast and the table is
never walked. Take that index away and this query reads two million rows for every
moderation card, which is the measurement recorded next to the index in
pipeline/coverage/load.py. So coverage_poi can afford the naive spelling and item
cannot, and the difference is not the size of the table or the cost of the maths. It is
which expression each table has an index for.
make course-data builds coverage_poi from an 847-byte OSM fixture committed to this
repo, ten rows, so the whole course runs offline. Filling the table for real means
downloading a country extract from Geofabrik, which needs network and a few gigabytes of
disk. It is one command, and chapter 6 explains everything it does:
With a real extract loaded, the Rows Removed by Filter line on a sequential scan is the
number this chapter is really about: not "an index is faster", but "the table scan grows
with the table and the index descent does not".