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.
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 togeometryonly 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.
latitudeandlongitudein an API,lat/lngproperties 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.
{
"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
crsmember 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_ForcePolygonCCWdoes 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 withST_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.
bboxis west, south, east, north. An optionalbboxmember 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.
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()
// ...
}
}use geojson::{GeoJson, GeometryValue}; // geojson crate
let geojson: GeoJson = body.parse()?;
if let GeoJson::FeatureCollection(fc) = geojson {
for feature in fc.features {
if let Some(geometry) = feature.geometry {
if let GeometryValue::Point { coordinates } = geometry.value {
let (lon, lat) = (coordinates[0], coordinates[1]);
// ...
}
}
}
}Storing Geometry: PostGIS#
PostgreSQL stores spatial data through PostGIS, which has two types for it:
geographyis 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.geometryworks 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_GeomFromGeoJSONreads only a geometry fragment and throws on a whole document, so pass it thegeometrymember of each feature. Since PostGIS 3.0 it assumes SRID 4326 when none is given.ST_AsGeoJSONwrites 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_DWithinongeographymeasures in meters and uses the spatial index. Prefer it to computingST_Distancefor 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
ANALYZEafter 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 | 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.dbftable 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.dbfloses 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_GeoHashdescribes 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
latitudeandlongitudefields in APIs; GeoJSON, asapplication/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.
geographycolumns with GiST indexes by default;geometryfor heavy processing or projected data.ST_DWithinfor distance filters;ST_Coversfor 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
- RFC 7946: The GeoJSON Format, geometry types, coordinate order, WGS 84, winding, antimeridian cutting and precision
- RFC 8142: GeoJSON Text Sequences, streaming GeoJSON one feature per record
- GDPR, Article 4(1), location data as an identifier
- OGC Simple Feature Access, the geometry model behind WKT and WKB
- TopoJSON specification, shared arcs and quantization
- Mapbox Vector Tile specification, version 2.1
- GeoParquet and its specification repository
- GeoPackage, the OGC SQLite container
- OGC KML, version 2.3
- GPX 1.1, the GPS Exchange Format
Reference
- PostGIS: Data Management, geometry versus geography, WKT, WKB, EWKT and SRIDs
- PostGIS: ST_MakePoint, X as longitude and Y as latitude
- PostGIS: ST_GeomFromGeoJSON and ST_AsGeoJSON, reading and writing GeoJSON
- PostGIS: ST_DWithin, index-assisted distance filters in meters
- PostGIS: ST_IsValid and ST_MakeValid, checking and repairing invalid shapes
- PostGIS Workshop: Spatial Indexing, GiST indexes over bounding boxes
- Google: Encoded Polyline Algorithm, the encoding and its precision
- Google Maps JavaScript API: LatLng and Leaflet: LatLng, latitude-first map libraries
- FlatGeobuf, binary features with a spatial index and HTTP range requests
- H3 and S2, hierarchical cell systems
- PostGIS: ST_GeoHash, geohash encoding and its prefix property
- Google: KML Reference, elements and
lon,lat[,alt]coordinate tuples - Esri: Shapefile Technical Description, the July 1998 white paper defining the
.shp,.shxand.dbffiles - orb, GeoJSON for Go
- geojson, GeoJSON for Rust