Pitfalls, glossary, where to look¶
This chapter does not follow the fountain. Every chapter before it built one idea on the last; this one assumes you have read all nine and is the page you come back to afterwards, when you are writing code of your own and something about it feels off. It has three parts: a table of mistakes that actually happen in GIS code, most of which have happened in this one, a table for "I need to change X, where do I start", and a glossary of every term the series used.
Pitfalls¶
Each row is a trap that is either real GIS knowledge worth knowing generally, or a mistake that has genuinely happened somewhere in this codebase's history. Nothing here is invented to look plausible.
| Trap | What it looks like | Why it happens | What to do |
|---|---|---|---|
| Longitude before latitude | A spatial query runs without error and returns zero rows, or a point lands somewhere absurd, chapter 1's example is a fountain near Spa landing in the Indian Ocean off Somalia | GeoJSON, PostGIS (ST_Point(x, y)) and most geometry libraries take (longitude, latitude) because they treat a position as (x, y); humans, and some hand-entered data in this repo (SeedManualCatalogCommand), say latitude first |
Check argument order before anything else when a spatial query mysteriously returns nothing. SpatialResolver::resolve() and spotlight.js's setCircleSpotlight() are the two places in this codebase where the flip from human order to (lng, lat) happens on purpose, read them as the reference shape. See coordinates.md |
| Missing or mismatched SRID | PostGIS refuses to compare two geometries ("Operation on mixed SRID geometries"), or worse: no error at all, but the geometry is silently labelled as being somewhere it was never converted to | Numbers alone are not a place, an SRID is what says which coordinate system they belong to. ST_SetSRID only relabels a geometry; it does not recompute anything. ST_Transform actually converts. Using the first where you meant the second gives a geometry that claims a system it was never moved into |
Every geometry column in this project is declared geometry(Geometry, 4326) by GeometryType::getSQLDeclaration() (web/src/Catalog/Doctrine/GeometryType.php), so there should never be a reason to call ST_Transform here at all, if you find yourself wanting one, stop and ask why the data isn't already in 4326. See coordinates.md |
| Treating degrees as metres | A "within 50 km" filter returns too few rows, or the wrong-shaped set of rows, and the miss gets worse depending on latitude, nothing throws an error | A degree of latitude is about 111 km everywhere, but a degree of longitude shrinks by cos(latitude) away from the equator. Doing arithmetic on raw degrees as if they were a fixed-size unit produces an ellipse, not a circle, and the wrong size in every direction except due north-south |
Cast to geography for any real distance comparison, BaseAreaResolver::resolve()'s ST_DWithin(r.geom::geography, …, :m) is the worked example, with :m arriving already converted from kilometres. See metres-vs-degrees.md |
| Buffering in degrees instead of on geography | A "within 250 m of this route" corridor is subtly the wrong shape and size, an ellipse flattened east-west, not the real 250 m stadium-shape around the line | ST_Buffer(geom, d) on a plain geometry value treats d as degrees, exactly the same bug as the row above, just applied to growing a shape instead of comparing two of them |
Cast to geography for the buffer itself, then cast the result back to geometry, RideCheckService::corridorGroups()'s ST_Buffer((SELECT g FROM track)::geography, :radius)::geometry is this project's own example. See metres-vs-degrees.md and making-it-fast.md |
A ::geography cast that silently disables the index |
A query that reads correctly and returns the right answer takes tens of seconds instead of under one; the design note that shipped RideCheckService's rewrite records a real 62-second run (docs/specs/Dated/2026-07-14-town-search-and-ride-check-design.md) |
The application tables' GiST indexes are built on the bare geom column. Casting that column to ::geography compares a value the index knows nothing about, so PostgreSQL falls back to reading and measuring every row, the cost is not the ellipsoid maths, it is that every row now pays for it |
Move the cast off the indexed side. Build the corridor once (cast the other side to geography, buffer, cast back), then compare the bare column against that one precomputed shape with ST_Intersects, the actual rewrite this project shipped. See making-it-fast.md |
| A missing GiST index on a new geometry column | EXPLAIN shows Seq Scan on a spatial table; fine and invisible on a seeded development database, ruinous once the table holds real volume |
Geometry has no natural order, so an ordinary B-tree index cannot help at all, PostGIS needs USING GIST (geom) specifically, and a new geometry column does not get one automatically |
Add CREATE INDEX … USING GIST (geom) in the same migration that adds the column, the way every searched geometry column in this project already has one. users.base_point is the one deliberate exception, nothing ever searches by it, so it has no index on purpose, not by omission. See making-it-fast.md |
| An inlined CTE re-parsing geometry per row | Correct results, still slow, and the query's text gives no hint why, the difference between a fast and a slow version of the same query is a single word | PostgreSQL may inline a WITH name AS (…) block instead of computing it once, so an expression like ST_GeomFromGeoJSON(:geom) referenced several times gets recomputed, reparsed from a JSON string, at every one of those places, once per row wherever the reference sits inside a SELECT list |
Write MATERIALIZED to force the CTE to compute once and be reused. RideCheckService::corridorGroups()'s own comment: "MATERIALIZED is load-bearing: an inlined track CTE re-parses GeoJSON per ST_*; ST_Intersects can use the GIST index." See making-it-fast.md |
| Polygon ring winding order | PostGIS reads a polygon fine either way; hand the same coordinates to some other renderer or GIS library and a hole gets drawn as a fill, or the reverse | The direction a ring is walked, clockwise or counter-clockwise, is called its winding, and some tools use it as the only signal for "this ring is a hole, not a fill." PostGIS itself is forgiving about it | Be deliberate about winding whenever geometry from this project is handed to a tool that is not PostGIS. Nothing in this codebase currently depends on getting it right, which is exactly why it is easy to forget the rule exists until a non-PostGIS consumer shows up. See shapes.md Course 2's edge cases chapter explains where the convention comes from, and that tippecanoe and MapLibre fills both do care about it, so "nothing here depends on it" is about this codebase rather than about GIS. |
| Assuming an OSM tag is present | Code that reads a tag key throws on a missing key, or silently treats "not present" as a confident "no" | OSM tags are a convention followed by volunteers, not a schema enforced by any software, a drinking fountain with no amenity tag at all is not a contradiction, and a tag can be spelled inconsistently across mappers and countries |
Treat every tag read as optional, and never read absence as a negative answer, only as "nobody recorded this." pipeline/coverage/parse.py's trim to storedTagKeys and the whole "tags are not a schema" argument in this chapter's source is the reference case. See osm-to-database.md |
| Antimeridian / dateline wrapping | A region's bounding box spans the entire globe instead of a thin sliver near ±180° | Longitude wraps from +180 back to −180 at the antimeridian. A naive min/max union of longitudes breaks the instant a shape's coordinates straddle that seam, the two extreme values end up on opposite sides of the world instead of close together |
Handled on both sides, in the shape GeoJSON specifies (RFC 7946 §5.2): a crossing box is written with its west value greater than its east, and read the long way round. RegionRegistryProvider::all() (web/src/Catalog/RegionRegistryProvider.php) normalises through ST_ShiftLongitude when a region's raw span exceeds 180°, and CCScope (web/assets/map/scope.js) unions boxes by keeping whichever direction is narrower and answers every longitude question through helpers that understand the wrapped shape. The case that made it real was not a single region but a union: no region we ship crosses the seam, yet New Zealand's country scope is seventeen of them running 166.4°E to 175.8°W, and a naive min/max turned that into a 355° box containing the planet. It is now 17.7°. Never compare a box by hand: b[0] <= lng && lng <= b[2] is false almost everywhere a crossing region actually is. Contract: docs/specs/map-and-search.md §4.5a; the risk was named in advance in docs/specs/Dated/2026-07-19-region-scoping-design.md §8 risk 11 The full account, including the 1e-14 mantissa subtlety and the seventeen-region New Zealand union, is in course 2's edge cases chapter. |
Two of those rows, the ::geography cast that disables an index, and the inlined CTE that
re-parses geometry per row, are really one story: they are the same real rewrite,
RideCheckService::corridorGroups(), seen from two different angles. It is worth seeing both sides
of that one fix side by side, because the "before" is exactly the shape a reasonable-looking query
takes if you don't already know either trap:
Casting geom itself, the indexed column, means every row now pays for spheroid distance maths,
and re-evaluating ST_GeomFromGeoJSON(:geom) inline means every row re-parses the same JSON string
too. The shipped rewrite moves both costs off the per-row path:
'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)
SELECT i.id, i.letter, i.name, ST_AsGeoJSON(i.geom) AS geom,
...
WHERE i.letter NOT IN '.$excluded.'
AND i.state IN '.ItemState::servedSqlTuple().'
AND ST_Intersects(i.geom, (SELECT b FROM corridor))
MATERIALIZED forces the GeoJSON parse and the buffer to happen once, not once per row; casting the
buffer to ::geography and back to ::geometry keeps the cast on the one-off corridor shape instead
of on i.geom, so the comparison that actually runs per row, ST_Intersects(i.geom, …), is a plain
geometry test the idx_item_geom GiST index can serve directly. Same underlying question, "what's
near this route", two very differently priced ways to ask it.
I need to change X: where do I look¶
A quick map from a task to the files that do it. These are starting points, not the only files involved, read what they import before assuming the whole story is in one place.
| I need to… | Start here |
|---|---|
| Add a spatial query (is this point inside/near/along that shape?) | web/src/Contribution/SpatialResolver.php (containment), web/src/Service/BaseAreaResolver.php (nearby, with real distance), see spatial-questions.md |
| Add a geometry column to a new or existing table | A migration declaring the column via the shared geometry DBAL type, plus its own CREATE INDEX … USING GIST (geom) in the same migration, and web/src/Catalog/Doctrine/GeometryType.php if the declaration itself needs to change, see coordinates.md and making-it-fast.md |
| Change how a spatial query performs at scale | web/src/Catalog/RideCheckService.php (corridorGroups(), followedRoutes()) is the reference rewrite, read its own comments before touching anything else, see making-it-fast.md |
| Change what the map draws, or how it's styled | web/assets/map/, one module per concern (how many): map-init.js (the map object, controls, basemap labels), render.js (which features draw), coverage.js (the PMTiles source and its layers), spotlight.js (region mask and circle), osm-pools.js (clustered dot layers), picking.js / scope-ui.js (click handling), drawer.js (the detail panel). map.js itself is only imports plus the boot sequence; see on-screen.md |
| Change what comes out of OpenStreetMap (which objects, which tags) | pipeline/coverage/extract.py (which objects survive osmium tags-filter), pipeline/coverage/parse.py (which tag keys survive onto the stored row), and the shared contract, pipeline/contract/coverage-contract.json (read by pipeline/coverage/contract.py::load_contract()), see osm-to-database.md |
| Change how tiles are built (zoom range, clustering, per-country layers) | pipeline/coverage/tiles.py, build_pmtiles() and export_geojsonl(), see tiles.md |
| Change which tag keys the item drawer is allowed to show | web/src/Coverage/CoverageRepository.php's TAG_WHITELIST constant, which must stay a subset of the contract's storedTagKeys (enforced by web/tests/Catalog/CoverageContractTest.php), see osm-to-database.md |
| Change how a route's surface estimate is computed | web/src/Catalog/SurfaceProfiler.php (profile(), recomputeAll()), triggered by app:catalog:route-surfaces (web/src/Catalog/Command/RouteSurfacesCommand.php), see routes.md |
| Change how a GPX upload becomes a stored route | web/src/Contribution/Gpx/GpxParser.php (reading the file), web/src/Contribution/Gpx/TrackProcessor.php (distance, ascent, simplification), web/src/Contribution/RouteProposalService.php (assembling the stored geometry), see routes.md |
| Change how a rider searches for a place by name | web/assets/settings/base-location.js and search-ui.js's runPhoton(), both call Photon directly from the browser, allow-listed in web/src/EventSubscriber/CspSubscriber.php, see spatial-questions.md |
| Change how elevation or gradients are measured | web/src/Elevation/ClimbProfiler.php (the window, the percentile, the sample count) and RouteSnapper.php; course 2's elevation chapter for why those thresholds, and the elevation runbooks for the DEM underneath them |
| Consume Commons tiles or items from your own application | The Public API course: /v1/map-config for the tile URLs and the category table, /v1/search for points, and the worked example for a complete MapLibre integration |
| Change which regions a rider's base point resolves to | web/src/Service/BaseAreaResolver.php and web/src/Catalog/RegionRegistryProvider.php, see spatial-questions.md |
Glossary¶
Every term this series defined, in one place, one sentence each. Alphabetical, not teaching order, if you want the concept built up from scratch, follow the chapter link.
Bounding box: the smallest upright rectangle that fully contains a shape; cheap to compute and
compare, and able to prove two shapes cannot touch but never that they do. making-it-fast.md
Buffer (ST_Buffer): a function that grows a shape by a fixed distance in every direction; a
point becomes a circle, a line becomes a corridor. spatial-questions.md
Buffer-based attribution: this project's technique for estimating a route's surface: draw a
corridor around the route, total the mapped road metres inside it per surface, and treat the
largest totals as the likely answer. routes.md
Candidate set: the rows a GiST index's box comparison lets through; guaranteed to contain every
true match, and guaranteed to also contain some rows that are not matches at all. making-it-fast.md
Cast (::geography): a per-expression reinterpretation of a stored value's type, applied for the
length of one function call rather than changing the column itself. metres-vs-degrees.md
check_date: an OpenStreetMap tag holding the day a mapper last stood in front of a thing and
confirmed it. Kept by the harvest and read at tile-build time into a cd property; a dated one
inside the freshness window is what lets a point claim a published witness rather than a bare copied
claim. osm-to-database.md
Cluster / clustering: merging nearby points into one feature carrying a count, so a map stays
readable when zoomed out. Here it happens in one place only: live, in the browser, for the small
confirmed-points pool. The coverage layer never clusters, because a cluster renders at its members'
centroid, a position no scope filter can vouch for (a phantom bubble outside the region); its
overview is a density heatmap instead. tiles.md, on-screen.md
Convention (tagging): the informal, voluntarily-followed agreement among OSM mappers about which
tag key and value describe a given real-world thing; nothing in OSM's own software enforces it.
osm-to-database.md
Corridor: the polygon ST_Buffer produces when applied to a line: a stadium-shaped strip
running the line's whole length. spatial-questions.md
CTE (Common Table Expression): the WITH name AS (…) block at the top of a query, naming a
subquery so the rest of the statement can refer to it by name. making-it-fast.md
Equator: the circle exactly halfway between the poles, where latitude is 0. coordinates.md
EPSG registry: the long-running public catalogue of coordinate systems that most SRIDs are drawn
from. coordinates.md
EPSG:3857 (Web Mercator): the projected, metre-labelled coordinate system tiled web maps draw on;
conformal (shapes stay right), but badly wrong about area away from the equator. coordinates.md
EPSG:4326 (WGS84): plain latitude/longitude in degrees; what GPS, GPX, OpenStreetMap and GeoJSON
all speak, and the only SRID any geometry column in this project is ever stored in. coordinates.md
Expression (MapLibre): a small nested-array formula a paint or layout property can hold instead
of a fixed value, evaluated per feature against that feature's own properties. on-screen.md
Extract: a downloaded slice of OpenStreetMap's data for one region, published by Geofabrik as a
PBF file, instead of the whole planet. osm-to-database.md
Forward geocoding: turning a place name into coordinates; the direction this project actually
uses, via Photon. spatial-questions.md
Functional index: an index built over the result of an expression rather than a bare column,
such as (geom::geography). A query only uses it when it writes the expression the same way, which
is why the cast has to appear in the query and not just in your head.
making-it-fast.md
Geocoding: a text-matching problem with a spatial tiebreak, not a spatial query with text bolted
on: finding what a place name might refer to, then picking which candidate using location.
spatial-questions.md
Geofabrik: a community mirror that continuously republishes OpenStreetMap's planet, cut into
per-country and per-region PBF files. osm-to-database.md
GeoJSON: the JSON-based text format a geometry travels in across a network or a request body;
defined to be longitude-first and to carry no SRID of its own. shapes.md
Geography: a way of reading a stored geometry value that treats the Earth as a curved surface, so
its distance and area functions return real ground measurements in metres, at any latitude.
metres-vs-degrees.md
Geometry (as a shape kind): the permissive PostGIS column type meaning "any shape", decided per
row rather than pinned once at the table level. shapes.md
Geometry (as opposed to geography): a way of reading a stored value that treats the world as a
flat plane, so its functions return plain numbers in whatever unit the SRID uses, degrees, for 4326.
metres-vs-degrees.md
GiST (Generalised Search Tree): PostgreSQL's indexing framework for data that has no natural sort
order but does have "contains" and "overlaps"; PostGIS uses it to index geometry by storing bounding
boxes in a tree. making-it-fast.md
GPX: the XML format bike computers, phone apps and GPS watches export a ride as; underneath the
XML, an ordered list of <trkpt> points. routes.md
Graph search: the general kind of problem "find the cheapest route from A to B through a network
of costed road segments" is; not something this project implements itself. routes.md
Heatmap: a smooth density surface drawn from a thinned sample of points, answering "where is
coverage dense" rather than "here is every fountain". What the low zooms of the coverage archive
feed, because those tiles are deliberately not a complete list. tiles.md
Hit-testing: answering "what did the rider just click on?" by asking already-rendered geometry in
the browser, with no request to a server, what queryRenderedFeatures does, and only possible because
vector tiles ship real geometry rather than a picture. on-screen.md
Layer (MapLibre): one drawing instruction: which source, which subset of its features, and how to
turn each one into pixels. on-screen.md
Layout: the group of a MapLibre layer's properties governing placement and participation
(visibility, icon choice, size); more expensive to change than paint, because it can force MapLibre to
redo collision and placement work. on-screen.md
LineString: an ordered list of points joined into a path, where the order is part of what the
shape means; a route is stored as one. shapes.md
MATERIALIZED: the keyword that forces a CTE to be computed once and reused, instead of being
substituted inline everywhere it is referenced. making-it-fast.md
Meridian: a line joining every point of the same longitude; unlike parallels, every meridian
meets every other one at both poles. coordinates.md
MultiLineString: more than one LineString held together as a single geometry value. shapes.md
MultiPoint: more than one Point held together as a single geometry value. shapes.md
MultiPolygon: more than one Polygon held together as a single geometry value, used for regions
with an island or an exclave. shapes.md
MVT (Mapbox Vector Tile): the small, widely-adopted binary encoding a vector tile is packed in.
tiles.md
Node: an OSM primitive: one latitude, one longitude, nothing else structural; our fountain is a
node. osm-to-database.md
Overpass API: a public, shared community service that answers ad-hoc bulk queries against live
OSM data; this project never calls it, for harvesting or for serving. osm-to-database.md
Paint: the group of a MapLibre layer's properties governing appearance only (colour, opacity,
width); cheap to change, because MapLibre can repaint already-placed pixels without recomputing where
anything goes. on-screen.md
Parallel: a line joining every point of the same latitude; parallels never meet, and shrink toward
each pole. coordinates.md
PBF: a compact binary encoding of OSM's node/way/relation/tag model, much smaller than the
older plain-text XML format. osm-to-database.md
Photon: the free, keyless, OpenStreetMap-based geocoding service this project calls directly from
the browser for place search. spatial-questions.md
PMTiles: a file format that packs an entire tile pyramid into one file with an index, read by
range request, so no dedicated tile-serving process is needed. tiles.md
Point: a single coordinate pair and nothing else; nearly every catalog item is stored as one, the
road-surface layer being the exception that stores LineStrings. shapes.md
Polygon: a closed ring of points, optionally with holes cut out, describing an area; a region's
outline is one, stored as a MultiPolygon. shapes.md
Predicate: a spatial function that answers true or false, such as ST_Contains or
ST_Intersects. spatial-questions.md
Prime meridian: the arbitrary line through Greenwich, London, agreed as 0° longitude. coordinates.md
Projection: a rule for turning a position on the curved Earth into a position on a flat plane;
every one distorts at least one of area, shape, distance or direction. coordinates.md
PostGIS: the spatial extension to PostgreSQL that turns an ordinary database into one that can
store and query shapes on the Earth. coordinates.md
Range request: an HTTP request asking a server for a specific byte range of a file rather than the
whole thing; what a PMTiles client issues to read one tile. tiles.md
Raster tile: a tile that is a finished picture (PNG/JPEG); every visual choice is frozen into the
pixels at build time. tiles.md
ref: the OpenStreetMap element a Commons row points at, written node/123 or way/456. The
same key and the same meaning in every layer, and the join key that /improve and every source link
use. osm-to-database.md
Reference vs fork: this project's stance on OpenStreetMap data: coverage_poi references OSM
objects by id and exists to serve queries fast, it is never presented as an independent survey or a
replacement authority. osm-to-database.md
Relation: an OSM primitive that groups other elements (nodes, ways, even other relations) into
something bigger, such as a bus route or a country border; this project's pipeline does not read
relations today. osm-to-database.md
Reverse geocoding: turning coordinates into a place name; this project never calls it, storing a
place name once at the moment a rider picks it instead. spatial-questions.md
ridtok / cctok: short per-feature tokens carried in the tiles so the client can tell which
scope a feature belongs to without a second request. They are scoping identifiers, never anything
about a person. on-screen.md
Routing: computing a path between two points through a road network; this project hands it to
Valhalla (RouteSnapper for the road between two points, ElevationClient for elevation) rather
than implementing it, and offers no rider-facing A-to-B planner. routes.md
Slippy map: the pan-and-drag, zoom-with-the-wheel kind of web map, as opposed to a single fixed
picture. coordinates.md
Source (MapLibre): a named pool of data a layer can read from; naming one draws nothing by
itself. on-screen.md
source-layer: which named feature collection inside a vector source's tiles a layer should read;
needed only for vector sources, which can pack more than one collection per tile. on-screen.md
Spatial question: a question whose answer depends on where things are, not just what they are:
"is this inside that", "what's nearby", "did I pass this". spatial-questions.md
SRID (Spatial Reference IDentifier): an integer naming a coordinate system, stored alongside every
PostGIS geometry so a value always carries its own answer to "what do these numbers mean?". coordinates.md
ST_Buffer: grows a shape by a distance; see Buffer. spatial-questions.md
ST_ClosestPoint: returns the specific point on one shape nearest to another shape. spatial-questions.md
ST_Contains: true if one shape lies entirely within another; a topology question needing no
geography cast. spatial-questions.md
ST_Distance: returns the shortest distance between two shapes, in whatever unit the type (geometry
or geography) implies. spatial-questions.md
ST_DWithin: true if two shapes are within a given distance of each other; the predicate behind
every "near me" question. spatial-questions.md
ST_Intersects: true if two shapes share any point at all; the broadest yes/no test, and the one
a GiST index can serve most directly. spatial-questions.md
ST_LineLocatePoint: returns a 0–1 fraction for how far along a line a point sits, measured from
its first vertex; the tool for ordering things by "how far along the ride". spatial-questions.md
ST_SetSRID: labels a geometry with a coordinate system without changing any of its numbers.
coordinates.md
ST_Transform: recomputes a geometry's numbers to genuinely convert it from one coordinate system
to another. coordinates.md
Style (MapLibre): the whole document describing everything a map draws: every source, every
layer, background colours, all of it together. on-screen.md
Tag: a free-form key=value pair any OSM element can carry, giving an otherwise-empty node, way
or relation its meaning. osm-to-database.md
Tile: one small, addressable piece of a map, covering a fixed square of the world at a fixed zoom
level. tiles.md
Tile pyramid: the whole addressing scheme by zoom level, where zoom 0 is one tile and every level
quarters every tile from the one before, giving 4^z possible tiles at zoom z. tiles.md
Vector tile: a tile that ships geometry and properties instead of a picture, letting the browser
restyle, filter and hit-test without a new request. tiles.md
Way: an OSM primitive: an ordered list of nodes joined into a path, closed into an area if the
first and last node match. osm-to-database.md
Winding: the direction (clockwise or counter-clockwise) a polygon's ring is walked in; some
renderers use it to tell a fill from a hole. shapes.md
z/x/y: the three-number address of a tile: zoom level, then column and row within that level's
grid. tiles.md
Zoom level: one level of the tile pyramid; each one covers the same whole world at four times the
tile count, and four times the resolution, of the level before it. tiles.md
Further reading¶
- RFC 7946 §5.2, antimeridian-crossing bounding boxes: the west-greater-than-east convention, in the spec's own words.
Try it¶
Hands-on: diagnose a real zero-row query
Here is a query near Namur (50.4700, 4.8700, where the coverage fixture's own nodes sit) that
runs without error and returns nothing. Work through the pitfalls table above before reading the
fix below it.
docker compose -f developers/docker/compose.yaml exec db psql -U cc -d cyclingcommons -c "
SELECT count(*) AS broken_count FROM coverage_poi
WHERE ST_DWithin(geom::geography, ST_SetSRID(ST_MakePoint(50.4700, 4.8700), 4326)::geography, 5000);
"
Zero rows within 5 km of real mapped Belgian ground, in a table that certainly holds some. No
error, no warning, exactly the shape of the longitude before latitude row in the table
above. ST_MakePoint(x, y) wants (longitude, latitude); this query hands it
(50.4700, 4.8700), latitude first, which ST_MakePoint reads as a valid point roughly 50.47°
east of Greenwich and 4.87° north of the equator, in the Indian Ocean off the Somali coast,
nowhere near Belgium. Swap the two arguments and the same query finds real rows:
docker compose -f developers/docker/compose.yaml exec db psql -U cc -d cyclingcommons -c "
SELECT count(*) AS fixed_count FROM coverage_poi
WHERE ST_DWithin(geom::geography, ST_SetSRID(ST_MakePoint(4.8700, 50.4700), 4326)::geography, 5000);
"
Real rows within 5 km, as soon as the coordinates go in (lng, lat) order. How many depends on
how much coverage your stack has loaded, five on the offline fixture, a few hundred once a real
Belgian extract is in, and that is fine, because the number is not the lesson. The transition from zero
to non-zero is. Nothing else about the query changed, same table, same radius, same cast to
::geography, which is exactly why this trap is so easy to miss under pressure: the query is
otherwise correct, and correct-looking SQL that quietly returns nothing is the signature this
whole drill is meant to train you to recognise.