Skip to content

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.

Parallels: a stack of circles that never meetOn the left, a globe tilted so a little of the northern hemisphere shows from above. Eleven circles of constant latitude are drawn around it, one every fifteen degrees, solid where they cross the near side and faintly dashed where they continue round the back. The circle at zero degrees, the equator, is the widest and is drawn in the accent colour, with a short tick and a zero at its western end. Every circle above and below it is smaller than the one before, and they shrink toward each pole without ever touching. The poles at plus ninety and minus ninety are single dots, because a parallel there has closed to a point. On the right, the same idea in words: circles of constant latitude every fifteen degrees; they stack, they shrink and they never meet; at each pole the last one closes to a point; and zero is not arbitrary, because the equator is real.+90−900ParallelsCircles of constantlatitude, every 15°.They stack, they shrink,and they never meet.At each pole the lastone closes to a point.Zero is not arbitrary:the equator is real.
Because parallels never meet, one degree of latitude buys the same ground everywhere: about 111 km in Belgium, in Kenya and in Antarctica alike. It is the only one of the two angles you can turn into a distance without asking a second question.

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.

Meridians: half-circles that all meet at both polesOn the left, the same tilted globe drawn with twelve meridians, one every thirty degrees of longitude, each half of a full circle running pole to pole, solid across the near side and faintly dashed round the back. Unlike parallels these are not parallel at all: they are widest apart at the equator and converge until every one passes through the same two points. Those points are marked with filled dots and labelled all three hundred and sixty meet here, and here. The meridian through Greenwich is drawn in the accent colour. On the right, the same idea in words: half-circles of constant longitude every thirty degrees; not parallel at all, widest at the equator, converging until all three hundred and sixty pass through two points; and zero is an agreement, Greenwich by consent rather than by any fact about the planet. Beneath the globe a small inset shows those meridians seen from straight above the pole, where they appear as spokes radiating from one centre rather than as rails running side by side.all 360 meet hereand hereMeridiansHalf-circles of constantlongitude, every 30°.Not parallel at all:widest at the equator,converging until all 360pass through two points.Zero is an agreement.Greenwich, by consent.seen from straight above thepole: spokes, not rails.
Because meridians converge, one degree of longitude is not a distance until you say where you are standing. At the equator it is about 111 km; in Belgium about 71; at the pole itself it is nothing at all. The next section is that arithmetic.

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.

A stone spring set into a wall, water running from a short iron spout into a shallow basin cut in the rock. The name SAUVENIERE is carved into the stone above the spout, and the stone around it is stained orange by the iron in the water. Fallen leaves lie in the basin and on the wet paving in front of it.

This is the fountain. The Pouhon La Sauvenière in Spa, an iron-rich mineral spring that has been running since long before anybody thought to give it coordinates, and the worked example every chapter of this course follows. It is 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) means ST_Point(lng, lat). Every ST_-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.php each 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:

'letter' => 'N', 'name' => 'Côte de la Redoute', 'lat' => 50.49222, 'lng' => 5.69924,

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:

public function resolve(float $lat, float $lng): array

The SQL it builds a few lines later flips the order:

ST_Contains(geom, ST_SetSRID(ST_Point(:lng, :lat), 4326))

: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:

const n=64, lat=center[0];

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:

ring.push([center[1]+dLng*Math.cos(a), lat+dLat*Math.sin(a)]);

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:

one degree of longitude ≈ 111.32 km × cos(latitude)

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.

One degree of longitude at the equator and at 50 degrees northA globe seen from the side. Meridians, the lines of constant longitude, are drawn as curves that spread apart at the equator and converge to a single point at each pole. The strip of surface between two neighbouring meridians is tinted from pole to pole; it is at its widest on the equator and narrows steadily toward the top and bottom of the globe. On the equator that strip is marked as about 111 kilometres of ground for one degree; on the 50 degrees north parallel the very same strip is marked as about 71 kilometres for one degree. Below the globe, three bars compare the three distances at the same scale: one degree of latitude is 111 kilometres at any latitude; one degree of longitude is 111 kilometres at the equator; one degree of longitude at 50 degrees north is only 71 kilometres, and a double-headed arrow marks the missing length as 36 per cent shorter.meridians meet at the polesequator0°50° N1° ≈ 111 km1° ≈ 71 kmThe same one degree, measured1° of latitude, at any latitude111 km1° of longitude, at the equator111 km1° of longitude, at 50° N71 km36% shorter
Meridians are furthest apart on the equator and meet at the poles, so the ground covered by one degree of longitude shrinks as you move away from the equator. A degree of latitude does not: the bars are measured against the same scale. That is why a “0.1 degree box” is not a fixed patch of ground, the same two numbers in the code enclose a different amount of the world depending on where you are. The tinted strip is drawn far wider than a single degree so that it is visible at all; the ratio between the two marked spans is exact, and equals cos 50° ≈ 0.64.

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.

The same two landmasses drawn in EPSG:4326 and in EPSG:3857Two maps of the North Atlantic side by side, covering the same range of longitude and the same range of latitude, drawn at the same horizontal scale. The left map, labelled EPSG:4326, plots degrees straight onto the page: its parallels at 40, 60 and 80 degrees north are evenly spaced, and the whole map is short and wide. Greenland appears roughly eleven times the area of France. The right map, labelled EPSG:3857, uses Web Mercator: the same parallels spread further and further apart toward the top, so the map is nearly three times as tall, and the tinted band between 60 and 80 degrees north is more than three times its height on the left. Greenland is enormously inflated and appears roughly thirty times the area of France. In reality Greenland is about four times the area of France. A key below the maps identifies the tinted band, the pale outlined shape as Greenland, and the small orange shape as France.EPSG:4326degrees plotted flat40°60°80°EPSG:3857Web Mercator40°60°80°Greenland looks≈ 11× FranceGreenland looks≈ 30× FranceIn reality, Greenland is about 4× France.60°–80° NGreenlandFrance
Both maps cover the same longitudes and the same latitudes, at the same horizontal scale, so every difference you see is what the projection did to the north-south axis. On the left the parallels are evenly spaced; on the right they spread apart the further north they go, which is what stretches Greenland. The tinted band is 60°–80° N in both: on the right it is more than three times as tall. Neither map is “wrong”. They distort different things, and that is exactly why the numbers cannot travel on their own, something has to say which system they are in.

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:

geometry(Geometry, 4326)

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:

  • Geometry is the shape type: which kind of shape the column may hold. Geometry is 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.
  • 4326 is 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

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);

 nearby
--------
     56
(1 row)

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);

 nearby
--------
      0
(1 row)

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.