Skip to content
syntaqx

Fundamentals, chapter 07 of 07

Coordinates & Geometry

Storing and exchanging points and shapes: WGS 84, longitude-latitude order, GeoJSON, PostGIS geography versus geometry, and formats to use instead of GeoJSON.

11 min readUpdated

Location data starts as a pair of numbers and grows into shapes: a pin for a customer's door, a line for a delivery route, a polygon for a service territory or a geofence. The numbers are easy. What goes wrong is everything around them: which earth model they're measured against, which of the two comes first, how many digits mean anything, and which format carries them between systems. A swapped pair doesn't throw an error; it puts the customer in the ocean.

What I Choose#

  • One coordinate system: WGS 84 longitude and latitude in decimal degrees (EPSG:4326), the system GeoJSON requires and PostGIS assumes by default. Anything projected for a map or a local calculation is derived from it, never stored instead of it.
  • Points in an API as named fields: {"latitude": -33.8568, "longitude": 151.2153}. Names make the order impossible to get wrong.
  • Shapes in an API as GeoJSON, served as application/geo+json. It's an IETF standard, and PostGIS, Leaflet and the Google Maps JavaScript API all read it directly.
  • Storage in PostGIS geography, so distances and areas come back in meters on the real earth. I switch to geometry only for the cases below.
  • Six decimal places on the way out, about 10 centimeters, and the measurement's accuracy stored beside it.

The sections below cover each of these, then the other formats and when they're the better choice.

Coordinates: Datum, Order and Precision#

A coordinate is only meaningful against a datum, the model of the earth it's measured on. GeoJSON fixes its datum as WGS 84, and PostGIS's geography type defaults to SRID 4326, the same system. Data from surveys, national mapping agencies or older GIS exports can arrive in a local projection instead; convert it to WGS 84 at import and record what it came in as.

Which Number Comes First

There is no single convention, and that's the most common bug in location code:

Where Order
GeoJSON Longitude, latitude (then optional altitude)
WKT, WKB and PostGIS ST_MakePoint X, Y: longitude, latitude
TopoJSON Longitude, latitude
KML <coordinates> Longitude, latitude (then optional altitude), comma-separated
Google Maps LatLng Latitude, longitude
Leaflet LatLng and its [lat, lng] arrays Latitude, longitude
Google's encoded polyline examples Latitude, longitude

Map libraries say "latitude, longitude" because that's how people read a position aloud; data formats say "longitude, latitude" because longitude is the X axis. Leaflet ships a coordsToLatLng helper precisely to swap GeoJSON's pairs into its own.

  • Name the fields wherever you can. latitude and longitude in an API, lat/lng properties in client code, never a bare two-element array outside a format that defines its order.
  • Swap in one place. Convert at the boundary with the map library, in one function with a test, not wherever a component happens to need a point.
  • Test with a point that can't survive a swap. Latitude only runs from -90 to 90. A fixture like Sydney (latitude -33.86, longitude 151.21) becomes an invalid latitude of 151.21 when reversed, so validation catches the bug; a fixture near the equator and the prime meridian swaps silently.

Precision Isn't Accuracy

RFC 7946 notes that six decimal places amount to about 10 centimeters, and that the number of digits says nothing about how uncertain the position is. A phone fix good to tens of meters still serializes every digit a floating-point number holds. Round output to six places, and store the accuracy the device reported or the geocoder's precision in its own column (A Position Is a Measurement, Geocode and Precision).

A Coordinate Is Personal Data

A point at six decimal places picks out one house, and a series of them shows where someone sleeps, works and worships. The GDPR names "location data" among the identifiers that make a person identifiable (GDPR). Treat stored coordinates like any other personal data, and coarsen them before showing one person's location to another: round to fewer decimal places, snap to a grid cell (Cells Instead of Shapes), or show an area instead of a pin. Consent and retention for tracked positions are in Consent, Law and Retention.

GeoJSON#

RFC 7946 (August 2016) defines GeoJSON: seven geometry types (Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon, GeometryCollection), plus a Feature that pairs a geometry with properties, and a FeatureCollection of features.

Point [x, y] MultiPoint [[x, y], ...] LineString [[x, y], ...] MultiLineString [[[x, y], ...], ...] Polygon [[[x, y], ...], hole, ...] MultiPolygon [[[[x, y], ...]], ...] GeometryCollection geometries: [...] Seven small drawings, each labeled with its type name and the nesting of its coordinates array. Point: a single dot, [x, y]. MultiPoint: four scattered dots, [[x, y], ...]. LineString: a stepped line through four dotted vertices, [[x, y], ...]. MultiLineString: two separate stepped lines, [[[x, y], ...], ...]. Polygon, accented: a filled rectangle with a rectangular hole, [[[x, y], ...], hole, ...]. MultiPolygon: two separate filled rectangles, [[[[x, y], ...]], ...]. GeometryCollection: a dot, a line and a rectangle together, geometries: [...].
The seven geometry types. Each level of nesting in the coordinates array adds one kind of grouping: a position, a list of positions, a list of rings or lines, a list of polygons.
{
  "type": "Feature",
  "id": "0193d1a2-7c4e-7b10-9f2a-3e4d5c6b7a81",
  "geometry": {
    "type": "Polygon",
    "coordinates": [[
      [151.2140, -33.8580],
      [151.2165, -33.8580],
      [151.2165, -33.8555],
      [151.2140, -33.8555],
      [151.2140, -33.8580]
    ]]
  },
  "properties": { "name": "Harbour depot service area" }
}

The rules that trip people up:

  • Longitude first, and WGS 84 only. The 2008 community format allowed a crs member naming another coordinate system; RFC 7946 dropped it. A GeoJSON file in another projection isn't valid GeoJSON.
  • Rings close. A polygon ring has four or more positions, and the first and last must be identical: the square above has five.
  • Rings wind by the right-hand rule. The outer ring runs counterclockwise and holes run clockwise. Parsers should not reject the wrong winding, for backwards compatibility, so data from the wild arrives both ways. Normalize it on import; PostGIS's ST_ForcePolygonCCW does it.
  • Valid isn't the same as well-formed. A ring that crosses itself (a "bowtie"), a hole that pokes outside its polygon, or two rings that overlap all parse as GeoJSON, and then point-in-polygon tests and areas come out wrong with no error. Check shapes on import with PostGIS's ST_IsValid, and either send invalid ones back for correction or repair them with ST_MakeValid, which attempts a repair without losing any of the shape's vertices. Check a repaired shape before trusting it: a self-crossing ring can come back as two pieces.
  • Cut at the antimeridian. A shape that crosses longitude 180, such as a territory spanning Fiji or the Aleutians, should be split into two parts, or a naive renderer draws it the long way around the world.
  • bbox is west, south, east, north. An optional bbox member gives the extent, ordered west, south, east, north, which for a box crossing the antimeridian means west is greater than east.
  • Extend with properties, not types. Extra members are allowed, but implementations must not add geometry types. Your own data goes in properties.
X · LONGITUDE Y · LATITUDE 1 2 3 4 EXTERIOR · COUNTERCLOCKWISE HOLE · CLOCKWISE POSITION X · LONGITUDE Y · LATITUDE 1 151.2140 -33.8580 2 151.2165 -33.8580 3 151.2165 -33.8555 4 151.2140 -33.8555 5 = 1 151.2140 -33.8580 THE LAST POSITION REPEATS THE FIRST A polygon drawn on two axes, x for longitude along the bottom and y for latitude up the side. The exterior ring, accented, runs counterclockwise through numbered corners 1 southwest, 2 southeast, 3 northeast and 4 northwest. A rectangular hole inside it runs clockwise. A table beside it lists the exterior ring's five positions as longitude and latitude, from 151.2140, -33.8580 through the other three corners and back to 151.2140, -33.8580, noting that the last position repeats the first.
The example polygon's exterior ring: longitude is x, latitude is y, the ring runs counterclockwise and closes on its first position. A hole would run clockwise.
180° 180° THE SAME MERIDIAN, AT BOTH EDGES OF A FLAT MAP CUT AT 180°: TWO PARTS 165°E 165°W "bbox": [165, -10, -165, 10] · west > east ONE RING, NOT CUT: DRAWN ACROSS THE REST OF THE WORLD -180 -90 0 90 180 A flat world map from -180 to 180 longitude, with the 180 degree meridian marked at both edges as the same line. Top row, accented: a shape from 165 degrees east to 165 degrees west, cut at 180 degrees into two slivers, one at each edge of the map, with the bbox [165, -10, -165, 10], west greater than east. Bottom row, dashed: the same shape as one uncut ring, drawn from -165 to 165 across the rest of the world.
A shape that crosses 180 degrees, cut into two parts as RFC 7946 asks, next to the same shape left as one ring, which a flat map draws the long way round.

Parsing it is a library call. Both libraries keep GeoJSON's order, so the first coordinate is longitude:

fc, err := geojson.UnmarshalFeatureCollection(body) // github.com/paulmach/orb/geojson
if err != nil {
	return err
}
for _, f := range fc.Features {
	if p, ok := f.Geometry.(orb.Point); ok {
		lon, lat := p.Lon(), p.Lat()
		// ...
	}
}

Storing Geometry: PostGIS#

PostgreSQL stores spatial data through PostGIS, which has two types for it:

  • geography is based on a spherical model of the earth. Measurements come back in meters, and distance and area are calculated on the spheroid by default. It's my default, because "within 500 meters" and "how many square kilometers" just work anywhere on the planet.
  • geometry works on a flat plane in whatever units its coordinate system uses, which for SRID 4326 means degrees: a "distance" of 0.01 is a different number of meters in Oslo than in Singapore. It has more functions and is faster, so I use it for heavy geometric processing (unions, buffers, simplification) and for data that lives in a local projected system, and cast between the two where needed.
CREATE TABLE service_areas (
  id    UUID PRIMARY KEY DEFAULT uuidv7(),
  name  TEXT NOT NULL,
  area  GEOGRAPHY(MULTIPOLYGON, 4326) NOT NULL
        CHECK (ST_IsValid(area::geometry))  -- no bowties, no stray holes
);

-- Spatial indexes are GiST indexes over bounding boxes.
CREATE INDEX service_areas_area_idx ON service_areas USING GIST (area);

-- From GeoJSON: ST_GeomFromGeoJSON takes the geometry member, not the whole Feature.
INSERT INTO service_areas (name, area)
VALUES ($1, ST_Multi(ST_ForcePolygonCCW(ST_GeomFromGeoJSON($2)))::geography);

-- Points: ST_MakePoint takes longitude first.
SELECT id, name
  FROM service_areas
 WHERE ST_DWithin(area, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, 500); -- meters

-- To GeoJSON, rounded to six decimal places (the default is nine).
SELECT ST_AsGeoJSON(area::geometry, 6) FROM service_areas WHERE id = $1;
  • ST_GeomFromGeoJSON reads only a geometry fragment and throws on a whole document, so pass it the geometry member of each feature. Since PostGIS 3.0 it assumes SRID 4326 when none is given.
  • ST_AsGeoJSON writes nine decimal places unless told otherwise. Its documentation warns that reducing them can make output geometry invalid, which is worth a check for small, detailed polygons.
  • ST_DWithin on geography measures in meters and uses the spatial index. Prefer it to computing ST_Distance for every row and filtering.
  • The index holds bounding boxes, so a spatial query first narrows to boxes that overlap and then tests the exact shapes. Run ANALYZE after a bulk load so the planner knows the index is worth using.

Point-in-polygon lookups (which territory, tax district or service area holds this address) are covered in Point in Polygon with PostGIS, including why ST_Covers beats ST_Contains on boundaries.

Other Formats#

GeoJSON is my default for exchange because everything reads it, not because it's the best at any one job. It's verbose, it repeats every shared border, and a single FeatureCollection is one JSON document that a reader parses whole.

That last one has a GeoJSON answer. RFC 8142 (April 2017) defines GeoJSON Text Sequences, media type application/geo+json-seq: one feature per record in a stream, which makes arbitrarily large datasets parseable incrementally. That's what I use for large exports and imports before reaching for another format. The rest are for jobs GeoJSON doesn't do well:

Format Encoding Defined by Use it for
WKT and WKB Text and binary OGC Simple Features 1.2.1 (ISO 19125) Moving geometry in and out of databases
TopoJSON JSON TopoJSON specification Maps of regions that share borders
Encoded polyline ASCII string Google Routes and tracks in URLs and mobile payloads
FlatGeobuf Binary (FlatBuffers) FlatGeobuf Large datasets streamed or served as static files
GeoParquet Columnar (Parquet) GeoParquet, an incubating OGC standard Analytics and warehouses
Vector tiles Binary (Protocol Buffers) Mapbox Vector Tile 2.1 Rendering maps, not exchanging data
GeoPackage SQLite file OGC 1.4.0 Handing many layers to GIS tools
KML XML OGC 2.3 Google Earth and visualization tools
GPX XML TopoGrafix 1.1 Waypoints, routes and tracks from GPS devices
  • WKT and WKB are what databases speak. WKT is text (POINT(151.2153 -33.8568), longitude first), and WKB is its full-precision binary form. PostGIS's extended versions, EWKT and EWKB, add the SRID (SRID=4326;POINT(...)) and 3D or 4D coordinates, but PostGIS warns not to rely on other systems reading them. Use these between a service and its database or a GIS tool, and GeoJSON across an API.
  • TopoJSON stores each shared border once, as an arc that neighboring shapes reference, and quantizes positions to integers. Counties, postal areas and sales territories that tile a region shrink a lot. It's a client-side rendering format; convert it back to GeoJSON before doing anything else with it.
  • Encoded polylines compress a line into one string by storing each point as an offset from the last, at five decimal places, about a meter. Google's routing APIs return them, and they fit a long route in a URL. Escape backslashes when the string goes into source code.
  • FlatGeobuf is binary, with an optional packed Hilbert R-tree index, and a client can fetch just the features in a bounding box from a static file using HTTP range requests. It's built for writing once and reading fast; it doesn't support random writes.
  • GeoParquet puts geometry into Parquet's columnar format for warehouses and data science tools (Warehouses & Lakes). Version 2.0.0 is agreed by the community and pending OGC approval, and its own maintainers say it's not a good choice for write-heavy work.
  • Vector tiles cut a map into tiles of Protocol Buffers with integer coordinates relative to each tile, in Web Mercator. They're how a map client draws millions of features quickly, and they're an output: generate them from the source data, never treat them as the source.
  • GeoPackage is an OGC-standard SQLite file that holds many vector and raster layers with their metadata, which makes it the file to hand a GIS analyst. Its own FAQ calls it potentially overkill for simple exchange.
  • KML and GPX are XML formats I accept on import. KML is for visualization (Google Earth, placemarks with styling), and GPX carries waypoints, routes and tracks from GPS devices and fitness apps.
  • Shapefiles still arrive from government and GIS sources. Esri's technical description (July 1998) defines one as several files sharing a name: the geometry in .shp, an index in .shx, and the attributes in a dBASE .dbf table with one record per shape. Every non-null shape in a file must be the same type, so points and polygons need separate shapefiles, and a missing .dbf loses every attribute. Accept them on import as a zip of all the parts, convert them to GeoJSON or PostGIS immediately, and don't produce them.

Cells Instead of Shapes

Sometimes the question isn't "is this point inside that polygon" but "which area is this point in", for counting, heatmaps or grouping nearby things. Grid systems answer that with an ID per cell instead of a shape:

  • H3 divides the world into hexagons at 16 resolutions (0 to 15). A child cell is only approximately inside its parent's shape.
  • S2 projects a cube onto the sphere and divides each face into quadrants down to level 30, each cell identified by a 64-bit ID along a Hilbert curve, so nearby cells usually have nearby IDs.
  • Geohash is the older, simpler scheme: a string in which each extra character narrows a rectangle. PostGIS's ST_GeoHash describes a shorter geohash as a less precise box that contains the point, which makes geohashes searchable by prefix in any database that indexes text. It returns 20 characters for a point unless you ask for fewer, and expects longitude and latitude input.

Store the cell ID beside the coordinate, as a derived column, for aggregation and fast lookups. Keep the coordinate and the real shapes as the source of truth; a territory boundary drawn in cells is an approximation of the one on the map.

Checklist#

  • WGS 84 longitude and latitude everywhere data is stored or exchanged; anything else is converted at import.
  • Named latitude and longitude fields in APIs; GeoJSON, as application/geo+json, for shapes.
  • One function swaps between GeoJSON order and the map library's order, with a test fixture that can't survive a swap.
  • Six decimal places on output, and the accuracy or geocode precision stored in its own column.
  • Polygons normalized on import: closed rings, right-hand winding, split at the antimeridian, and checked with ST_IsValid (repaired or sent back when they fail).
  • Coordinates treated as personal data, and coarsened before one person sees another's location.
  • Large GeoJSON moves as RFC 8142 text sequences, one feature per record.
  • geography columns with GiST indexes by default; geometry for heavy processing or projected data.
  • ST_DWithin for distance filters; ST_Covers for point-in-polygon.
  • Other formats at the edges: WKB to the database, TopoJSON or vector tiles to map clients, GeoParquet to the warehouse, GeoPackage to GIS tools.

References#

Specifications

Reference