The Earth is awkward¶
Somewhere in Wallonia, a little east of Spa, there is a drinking-water fountain. It is an ordinary physical object. You can ride up to it, fill a bottle, and carry on. It sits in exactly one place, and that place does not move.
Somebody wants to put it on a map. So somebody has to write its position down.
That is where every problem in this series starts. The fountain itself is simple; turning "where it is" into numbers a computer can store, compare, and draw is not. The moment a position becomes numbers, the shape of the planet starts leaking into your code, and it keeps leaking for the next nine chapters. This one covers the two numbers themselves: what they mean, how they behave, and why they do not behave like the x and y of a plane.
Latitude and longitude¶
A position on the Earth's surface is normally written as two angles: one measured north or south of the equator, the other east or west of a reference meridian.
Latitude is how far north or south you are. It runs from −90 at the South Pole, through 0 at the equator, the circle exactly halfway between the poles, to +90 at the North Pole. A line joining every point with the same latitude is called a parallel, because those lines never meet: they are a stack of circles, each one smaller than the one below it, shrinking to nothing at each pole.
Longitude is how far east or west you are. It runs from −180 to +180, with 0 at the prime meridian, an arbitrary line through Greenwich in London, fixed by international agreement in 1884. A line joining every point with the same longitude is called a meridian. Unlike parallels, meridians are not parallel at all. Every one of them is half of a full circle running from the North Pole to the South Pole, so all 360 of them meet at both ends.
Our fountain is at roughly 50.4851° N, 5.8983° E. As a pair of signed numbers, that is
50.4851, 5.8983, positive latitude means north, positive longitude means east. A fountain in
Chile would have two negative numbers; one in Nairobi would have a small negative latitude and a
positive longitude.

node/6863042080 in
OpenStreetMap and a row in this project's own catalog, both at 50.4851, 5.8983. Note
what the picture can tell you and what it cannot: the name is cut into the rock, and the two numbers
are not. A place knows what it is called. It does not know where it is.Photograph by Romaine, via Wikimedia Commons, released under CC0.
Written out in a sentence, the units are degrees, and a degree divides further. You will still meet
the old sexagesimal notation, 50° 29' 21.8" N, on signposts and in camera metadata. Nothing in
this codebase uses it: everything here is decimal degrees, all the way down.
The trap: which number comes first¶
The two orders look identical in a variable name, and swapping them does not fail loudly.
Humans say "lat, long". Most software wants longitude first.
The reason is boring, which is exactly why it catches people out. Software treats a position as a
point on a plane, and on a plane the horizontal axis x conventionally comes before the vertical
axis y. Longitude is the east-west one, so longitude is x. Latitude is the north-south one, so
latitude is y. Hence (x, y) = (longitude, latitude).
So:
- GeoJSON (chapter 2) is defined by its specification as
[longitude, latitude]. Always. - PostGIS, the spatial extension to PostgreSQL, which is what turns this project's ordinary
database into one that can store and query shapes on the Earth, follows the same rule in its
constructors:
ST_Point(x, y)meansST_Point(lng, lat). EveryST_-prefixed function in this chapter and the rest of the series is PostGIS, not stock PostgreSQL. - Most maths and geometry libraries do too, because to them these are just numbers on a plane.
And on the other side:
- Humans, road signs, and every "what are your coordinates?" conversation say latitude first.
- Some map libraries take
[lat, lng], Leaflet is the best-known example. This project does not use Leaflet; its map is MapLibre GL JS, which is longitude-first like GeoJSON. But you will meet the other convention as soon as you read anybody else's map code. - Some of this repository's own data is written latitude-first, in human order, because a person
typed it. In
web/src/Catalog/Command/SeedManualCatalogCommand.phpeach catalog entry carries explicit'lat'and'lng'keys, and the route paths beside them are arrays of[lat, lng]pairs.
Here is one such entry, exactly as a person typed it:
and here is that same pin, a few dozen lines later in the same file, on its way into a geometry:
'geom' => json_encode(['type' => 'Point', 'coordinates' => [$pin['lng'], $pin['lat']]], \JSON_THROW_ON_ERROR),
Human order in, longitude-first coordinates out, the same flip, inside a single file, that the
next two examples show at the seam of a request and the seam of a map.
You can see the flip happen at a real boundary in this codebase. SpatialResolver answers "which
region contains this point?", see web/src/Contribution/SpatialResolver.php,
SpatialResolver::resolve(). Its signature takes latitude before longitude, in human order, because
that is how the calling code thinks:
The SQL it builds a few lines later flips the order:
:lng before :lat. The method is the seam where human order becomes machine order, and it is
deliberate. Open the file: the whole class is under forty lines, and the seam is visible at a
glance.
The same flip happens on the front end. In web/assets/map/spotlight.js, setCircleSpotlight() receives
a center in human order and reads the latitude straight out of it:
A few lines later, the same function builds the ring it hands to MapLibre the other way round,
[lng, lat], because that is what GeoJSON requires:
One function, both conventions, a few lines apart.
How this bug shows up
Swapping the two numbers rarely throws an error, because both are plain floats and both are
plausible. 50.4851, 5.8983 reversed is 5.8983, 50.4851, a perfectly valid position in the
Indian Ocean off the coast of Somalia, about 6,400 km away. Your code runs, your query returns
zero rows, and nothing tells you why. If a spatial query mysteriously finds nothing, check the
argument order before you check anything else. Latitude can never exceed 90, so any number above
90 in the latitude slot is a free giveaway, but only when the longitude is large enough for the
swap to produce one.
A degree is not a distance¶
Here is the second thing that trips people up, and the one that produces wrong answers rather than empty ones.
A degree of latitude and a degree of longitude are both "one degree", but they do not cover the same amount of ground, and only one of them is even constant.
One degree of latitude is about 111 km, everywhere. Going one degree north always means travelling along a meridian, every meridian is the same size circle, and equal angles on equal circles cut equal arcs. The Earth's pole-to-pole circumference is about 40,008 km; divide by 360 and you get 111.1 km. That number holds in Belgium, in Kenya, and in Antarctica.
One degree of longitude is about 111 km at the equator, and shrinks from there. Going one degree east means travelling along a parallel, and parallels are not all the same size. The equator is a full-size circle around the planet, about 40,075 km, so one degree of it is 111.3 km. The parallel at 50° north is a much smaller circle, because it is a slice taken near the top of the sphere. All 360 degrees of longitude still have to fit around that smaller circle, so each degree is shorter:
At the equator, cos(0°) = 1, so you get the full 111.3 km. At 50° north, cos(50°) ≈ 0.643, so
you get about 71 km. At our fountain's latitude of 50.4851°, about 70.8 km. At 70° north, about
38 km. At the pole itself, cos(90°) = 0: all 360 degrees of longitude collapse into a single
point, and "one degree east" means standing still.
The figure below shows why. The strip between two meridians is the same number of degrees wide all the way from pole to pole, but the ground it covers narrows the whole way up.
The immediate consequence is that a "0.1 degree box" is not a fixed size. Near the equator it is about 11 km by 11 km. At our fountain it is about 11.1 km tall and 7.1 km wide. In northern Norway, at 70°, it is about 11.1 km tall and 3.8 km wide, the same box in the code, a third of the ground.
This has a direct effect on code you will be tempted to write. A query like this looks like it asks for everything within about 5 km:
-- Wrong, and wrong by a different amount depending on where you run it.
WHERE lat BETWEEN :lat - 0.045 AND :lat + 0.045
AND lng BETWEEN :lng - 0.045 AND :lng + 0.045
It does not. It asks for a rectangle roughly 10 km tall and, at our fountain, about 6.4 km wide, and if the same query runs for a rider in Norway, that rectangle is about 3.4 km wide instead. The same numbers, the same code, a different question depending on latitude.
For the same reason, you cannot take two positions in degrees, apply Pythagoras, and call the result a distance. The two axes are not in the same units as each other, and the longitude axis is not even in consistent units with itself.
Where this project genuinely does need a rough distance in degrees, it applies the cos(latitude)
correction on purpose rather than hoping it does not matter. setCircleSpotlight() in
web/assets/map/spotlight.js draws the "my area" circle on the map, and to do that it converts a radius
in kilometres into a step in degrees:
const cosLat=Math.max(0.01, Math.cos(lat*Math.PI/180));
const dLat = rkm/111.32, dLng = rkm/(111.32*cosLat);
The latitude step is the radius over 111.32; the longitude step is the radius over 111.32 times the cosine of the latitude. The line above it clamps that cosine to a small minimum, because near the poles the cosine goes to zero and the longitude step would go to infinity. Every idea in this section is in those two lines.
Chapter 3 is entirely about how to ask for a real distance instead, the proper way, in the database, without hand-rolled trigonometry. For now, the thing to carry forward is smaller and simpler: degrees are angles, not lengths.
Why maps lie¶
The fountain is on a curved surface. Your screen is flat. Somewhere between the two, something has to give.
A projection is a rule for turning a position on the curved Earth into a position on a flat plane. Every map you have ever looked at applied one, whether or not it said so.
There is no perfect projection, and that is not a software problem waiting for a better algorithm. It is a proved fact about geometry: a sphere and a plane have genuinely different curvature, so no rule can flatten one onto the other without stretching, tearing, or both. The everyday version of this is peeling an orange and trying to press the peel flat, it splits, or it stretches, and you get to choose which.
So every projection distorts at least one of four things:
| Property | What it means | Kept by |
|---|---|---|
| Area | Two regions that are equally big really look equally big | equal-area projections |
| Shape | Angles are locally correct, so small shapes are not skewed | conformal projections |
| Distance | Measured lengths match reality | only along particular lines, never everywhere |
| Direction | A bearing on the map is a bearing on the ground | some, at a cost elsewhere |
You do not get to keep all four. You pick the lie you can live with, for the job you are doing.
Two of those choices matter here.
Plotting latitude and longitude straight onto the page, as if latitude were y and longitude
were x, is itself a projection. It has a name, plate carrée, or the equirectangular projection,
and it is the one people apply by accident, because it looks like no projection has been applied at
all. It is easy and it is fine for a rough sketch, but it stretches everything east-west as you move
away from the equator, by exactly the 1 / cos(latitude) factor from the previous section.
Web Mercator is the projection tiled web maps use, and it is conformal: it keeps shapes and angles locally correct, so a town looks like the right shape and a right-angled junction still looks like a right angle. It pays for that by getting area badly wrong. Away from the equator it stretches north-south by the same factor it stretches east-west, which is why the shapes survive, and why the areas balloon by that factor squared. Web maps use it anyway, because north is always up, a tile stays square at every zoom level, and a constant compass bearing is a straight line on the map. For a slippy map, the pan-and-drag, zoom-with-the-wheel kind every web map is now, as opposed to a fixed picture, that is worth more than honest area.
The usual demonstration is Greenland. On a Web Mercator map it looks about the size of Africa. Africa is roughly fourteen times larger.
SRIDs: naming the system¶
50.4851, 5.8983 is not a place. It is two numbers. It only becomes a place once you also know
which coordinate system it belongs to, which model of the Earth's shape, which starting lines,
which units.
An SRID, Spatial Reference IDentifier, is an integer that names that system. PostGIS stores an SRID alongside every geometry it holds, so a value in the database always carries its own answer to "what do these numbers mean?".
Most SRIDs are simply the identifiers from the EPSG registry, a long-running public catalogue of
coordinate systems, each with its own number. When you see EPSG:4326 written in documentation and
4326 written in SQL, they are the same thing.
Two of them come up constantly.
EPSG:4326, WGS84, in degrees. Latitude and longitude as described at the top of this page, measured against WGS84, a specific agreed model of the Earth's shape. This is what a GPS receiver gives you, what a GPX file from a bike computer contains, what OpenStreetMap stores, and what GeoJSON is defined to use. The units are degrees.
EPSG:3857, Web Mercator, in metres. The projected plane that tiled web maps are drawn on. The
units are called metres, and near the equator they behave like metres, but they are not ground
distances. Web Mercator stretches by 1 / cos(latitude), so at 60° north one projected metre
corresponds to roughly half a metre of actual ground. Measuring in EPSG:3857 and reporting the
answer in metres is a real and popular bug.
Web Mercator also cannot represent the poles at all: the formula sends the projected y value off
to infinity as latitude approaches 90. The convention is to cut the world off at ±85.05112878°,
which is not an arbitrary number, it is precisely the latitude at which the projected height equals
the projected width, making the whole world a square. That squareness is what lets the tile scheme
in chapter 7 divide the world into four, then four again, forever.
Two more things are worth knowing before you write any SQL.
First, PostGIS refuses to compare geometries with different SRIDs. That looks like an obstacle the first time you hit it, and it is actually the system saving you from a silently wrong answer.
Second, and easy to confuse: ST_SetSRID and ST_Transform are not the same operation.
ST_SetSRID only labels a geometry, it changes the stated system and leaves every number
untouched. ST_Transform genuinely converts, recomputing the numbers from one system into
another. Using the first where you needed the second gives you a geometry that claims to be
somewhere it is not, with no error anywhere. Nothing in this repository calls ST_Transform,
because nothing here ever needs to leave 4326, which is the subject of the last section. That is a
claim with a shelf life, so course 2's reprojection chapter opens
by teaching you the one-line grep that checks it rather than asking you to take it on trust.
What we store¶
Every geometry column in this project is declared the same way:
Every migration's copy of that string is generated from one place, so no table can quietly get a
different one. See web/src/Catalog/Doctrine/GeometryType.php,
GeometryType::getSQLDeclaration(), a Doctrine custom type, registered as the DBAL type
geometry in web/config/packages/doctrine.yaml, whose entire job is to say what a geometry column
looks like in SQL. Every entity that stores a shape declares #[ORM\Column(type: 'geometry')],
some add nullable: true, but none of them names a type or an SRID of its own, so the six
Doctrine-managed geometry columns that exist today, region.geom, item.geom,
recommended_route.geom, heat_point.geom, submission.geom and users.base_point, all got
their declaration from this one method. You will find the same literal string written out in each
of the migrations that created them; those are copies of this method's output, frozen at the moment
the migration was generated, not six independent decisions. One more geometry column exists in the
same database and is not in that list: coverage_poi.geom, which the Python pipeline creates and
owns rather than Doctrine. It carries the same SRID for the same reason. Chapter 6 covers it.
The declaration has two halves:
Geometryis the shape type: which kind of shape the column may hold.Geometryis the permissive option, meaning any of them, a point, a line, an outline. Chapter 2 covers what those shapes are, and which of them each table actually holds.4326is the SRID. Degrees of latitude and longitude, in WGS84.
Why 4326 and not something else. Everything that feeds this system already speaks it. GPS devices produce it, the GPX files riders upload contain it, OpenStreetMap publishes it, and GeoJSON, the format geometries travel in on the way to and from PHP, is defined in terms of it. Storing anything else would mean converting on the way in and converting back on the way out, on every single row, with a fresh chance to get it wrong in each direction. Storing what the world hands you means the conversion count is zero.
The same class shows the boundary in action. GeometryType::convertToDatabaseValueSQL() wraps every
write in ST_SetSRID(ST_GeomFromGeoJSON(…), 4326), and GeometryType::convertToPHPValueSQL() wraps
every read in ST_AsGeoJSON(…). Note which function that is on the write side: ST_SetSRID, not
ST_Transform. There is nothing to convert, because GeoJSON is already defined to be in this
system; the call is there to stamp the label explicitly rather than rely on a default. Chapter 2
picks that round-trip up and explains the GeoJSON half of it.
So where does 3857 appear? Only at the very end, on the way out to the browser, in the tile
build, and even there, we do not do the projecting ourselves. build_pmtiles() in
pipeline/coverage/tiles.py shells out to tippecanoe, the tile cutter, handing it our
geometries in 4326:
for (letter, cc) in sorted(layer_files):
cmd += ["-L", f"{letter.lower()}_{cc.lower()}:{layer_files[(letter, cc)]}"]
subprocess.run(cmd, check=True)
tippecanoe is what projects them to Web Mercator and slices the result into tiles. That is the moment this project's data leaves 4326, and it happens inside a third-party tool, on the way out, to a copy.
The Web Mercator formula is written out in our own code exactly once, a few functions further down the same file:
def _tile_y(lat: float, n: int) -> int:
lat = max(min(lat, 85.05112878), -85.05112878)
return min(n - 1, max(0, int((1 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2 * n)))
_tile_y() clamps a latitude to that same ±85.05112878 limit and returns which tile row it falls
in. It is a private helper with a single caller, verify_pmtiles(), the
sanity gate that runs after the archive is built, works out which tile ought to contain the
data's bounding box, fetches it, and checks it decodes. So _tile_y() is not the project
projecting anything; it is the project checking tippecanoe's homework, and it is the only place a
reader will find the maths spelled out. The database itself never holds a projected coordinate.
Chapter 7 covers the tile pyramid all of this belongs to.
What to carry into chapter 2¶
- Latitude is north-south, −90 to 90. Longitude is east-west, −180 to 180.
- Humans say latitude first. GeoJSON, PostGIS and most libraries want longitude first.
- A degree of latitude is about 111 km anywhere. A degree of longitude is about 111 km at the
equator and shrinks by
cos(latitude)from there. - Degrees are angles. They are not a unit of distance, and you cannot do arithmetic on them as if they were.
- Flattening the Earth always costs you something. Every map has chosen which error to accept.
- Numbers without an SRID are meaningless. In this codebase the SRID is always 4326, everywhere it is stored.
The fountain now has a position and a coordinate system to interpret it in. Next it needs a shape, because the same column type that holds this single point also has to hold a rider's whole route and the outline of Wallonia.
Further reading¶
- EPSG:4326 at epsg.io: the registry entry for the coordinate system everything here is stored in.
- RFC 7946, the GeoJSON specification: short, readable, and the authority for how coordinates are ordered.
- PostGIS: spatial reference systems: what an SRID is, from the database's own manual.
Try it¶
Hands-on: watch the swap go quiet
Ask the dev catalog for everything within 5 km of the fountain, the right way round and then the
wrong way round, and watch the second answer disappear without an error. This runs against the
live item table (chapter 5, making-it-fast.md, shows how to
open a psql session against it).
First, longitude before latitude, ST_Point(lng, lat), exactly as PostGIS wants it:
SELECT count(*) AS nearby
FROM item
WHERE ST_DWithin(geom::geography,
ST_SetSRID(ST_Point(5.8983, 50.4851), 4326)::geography, 5000);
56 is what a clone seeded by make course-data holds within 5 km of the fountain; import more
data and it only goes up. The number is not the lesson, a non-zero one is.
Now swap the two numbers into ST_Point, as if you had typed them in the order a human says them
out loud, latitude first:
SELECT count(*) AS nearby
FROM item
WHERE ST_DWithin(geom::geography,
ST_SetSRID(ST_Point(50.4851, 5.8983), 4326)::geography, 5000);
Same predicate, same radius, same fountain, the only change is which number went into which
argument slot. ST_Point(50.4851, 5.8983) is a real, valid point, about 6,400 km from here, in
the Indian Ocean off the coast of Somalia, the exact place the warning above named. Nothing
threw an error, because both numbers are plausible floats in range. The query simply came back
empty, and empty is precisely what a longitude-first bug looks like from the outside.