Google’s Distance Matrix API charges around five US dollars per thousand elements. A modest site-selection study comparing 2,000 candidate locations against 500 existing sites is a million elements — five thousand dollars, for one analysis, that you will want to re-run when the assumptions change.
The same computation runs in under a minute on a self-hosted engine costing twenty dollars a month. This article covers the four mature open engines, when each one wins, and the cases where paying is still the right call.
# Preparing the Network
All four engines consume OpenStreetMap data. Get an extract rather than the planet:
# Country or region extracts from Geofabrik
wget https://download.geofabrik.de/australia-oceania/australia-latest.osm.pbf
# Clip further with osmium if you only need a metro area
osmium extract --bbox 152.6,-27.8,153.3,-27.1 \
australia-latest.osm.pbf -o brisbane.osm.pbf
# Filter to the routable network — often halves the file
osmium tags-filter brisbane.osm.pbf \
w/highway w/railway r/route=road \
-o brisbane-network.osm.pbf
That filtering step matters more than it looks. A raw metropolitan extract is mostly buildings, land use, and address points, none of which any routing engine needs.
# Four Engines, Four Different Jobs
# pgRouting: When the Network Lives in the Database
pgRouting extends PostGIS with graph algorithms. Its distinguishing feature is that cost is just SQL, which means you can express constraints no dedicated engine will support.
osm2pgrouting -f brisbane-network.osm.pbf \
-c /usr/share/osm2pgrouting/mapconfig.xml \
-d routing -U postgres --addnodes --clean
-- Shortest path, with cost varying by a business rule the engine cannot know
SELECT seq, edge, cost, agg_cost
FROM pgr_dijkstra(
$$SELECT gid AS id, source, target,
CASE
WHEN flood_risk_zone AND $1 = 'wet' THEN cost_s * 4.0
WHEN road_closure_active THEN 1e9
WHEN bridge AND vehicle_tonnes > max_load THEN 1e9
ELSE cost_s
END AS cost,
reverse_cost_s AS reverse_cost
FROM ways$$,
(SELECT source FROM ways ORDER BY the_geom <-> ST_Point(153.021, -27.470)::geography LIMIT 1),
(SELECT target FROM ways ORDER BY the_geom <-> ST_Point(153.108, -27.556)::geography LIMIT 1),
directed := true
);
That query routes a heavy vehicle around load-restricted bridges and penalises flood-prone segments during a wet-season scenario. No dedicated routing engine exposes a hook for that. pgRouting is also the only option that joins routing results directly to the rest of your spatial data in one query.
It is also, by a wide margin, the slowest. Dijkstra over a metropolitan graph takes hundreds of milliseconds to seconds. Use it for hundreds of routes with complex rules, not millions of simple ones.
# OSRM: Raw Speed on a Fixed Profile
The Open Source Routing Machine (OSRM) precomputes contraction hierarchies, collapsing the graph so that queries traverse a fraction of the edges. The trade is explicit: the profile is baked in at preprocessing time and changing it means reprocessing.
docker run -t -v "${PWD}:/data" ghcr.io/project-osrm/osrm-backend \
osrm-extract -p /opt/car.lua /data/brisbane-network.osm.pbf
docker run -t -v "${PWD}:/data" ghcr.io/project-osrm/osrm-backend \
osrm-partition /data/brisbane-network.osrm
docker run -t -v "${PWD}:/data" ghcr.io/project-osrm/osrm-backend \
osrm-customize /data/brisbane-network.osrm
docker run -d -p 5000:5000 -v "${PWD}:/data" ghcr.io/project-osrm/osrm-backend \
osrm-routed --algorithm mld /data/brisbane-network.osrm
import requests, itertools
origins = [(153.021, -27.470), (153.045, -27.498)]
destinations = [(153.108, -27.556), (152.988, -27.441), (153.062, -27.512)]
coords = ";".join(f"{x},{y}" for x, y in itertools.chain(origins, destinations))
r = requests.get(
f"http://localhost:5000/table/v1/driving/{coords}",
params={"sources": "0;1", "destinations": "2;3;4", "annotations": "duration,distance"},
).json()
print(r["durations"]) # seconds, origins × destinations
Sub-millisecond point-to-point queries and matrix throughput that no other engine matches. If your problem is “compute an enormous number of car routes on one profile”, this is the answer and the decision is easy.
# Valhalla: Multimodal and Time-Aware
Valhalla uses hierarchical tiles rather than a single contracted graph. That makes it slower than OSRM per query and far more flexible: costing options are supplied per request, it handles time-dependent routing against turn restrictions and conditional access, and its tiled design means whole-planet coverage runs on surprisingly modest hardware.
import requests, json
req = {
"locations": [
{"lat": -27.470, "lon": 153.021},
{"lat": -27.556, "lon": 153.108},
],
"costing": "truck",
"costing_options": {"truck": {
"height": 4.3, "width": 2.5, "length": 19.0,
"weight": 42.5, "axle_load": 9.0, "hazmat": False,
"use_tolls": 0.2,
}},
"date_time": {"type": 1, "value": "2024-11-11T07:30"},
"directions_options": {"units": "kilometers"},
}
r = requests.post("http://localhost:8002/route", json=req).json()
leg = r["trip"]["legs"][0]
print(f"{r['trip']['summary']['length']} km, {r['trip']['summary']['time']/60:.0f} min")
Per-request vehicle dimensions and a departure time, without reprocessing anything. For freight, public transport, cycling, or anything where the costing varies by query, Valhalla is the right default.
# GraphHopper: Isochrone Quality and JVM Integration
GraphHopper is a Java engine whose isochrone implementation produces noticeably cleaner polygons than the alternatives, and whose flexible weighting mode allows per-request costing similar to Valhalla’s. If your platform is already built on the Java Virtual Machine (JVM), embedding it avoids running a separate service.
# The Decision, Compressed
| Need | Engine |
|---|---|
| Custom cost rules from your own data | pgRouting |
| Millions of car routes, one profile | OSRM |
| Freight dimensions, transit, time-of-day | Valhalla |
| High-quality isochrones, JVM stack | GraphHopper |
| Results joined to other spatial tables | pgRouting |
| Whole planet on modest hardware | Valhalla |
# Matrix Performance
The operation that drives commercial API bills is the matrix. Indicative figures on 8 vCPU / 32 GB against a metropolitan network, warm:
| Matrix | pgRouting | OSRM | Valhalla |
|---|---|---|---|
| 100 × 100 (10⁴) | ~45 s | 0.08 s | 0.6 s |
| 1,000 × 1,000 (10⁶) | impractical | 3.1 s | 42 s |
| 10,000 × 10,000 (10⁸) | impractical | ~6 min | hours |
Run these on your own network before committing — density, region size, and profile all move the numbers materially. The shape of the result, though, is stable: OSRM is one to two orders of magnitude faster at matrices than anything else, and pgRouting is not in the race.
That 10⁸ matrix is the one worth pricing. Six minutes of compute on a twenty-dollar-a-month virtual machine, versus roughly half a million dollars at commercial per-element rates. The comparison is unfair in one direction — the commercial result includes live traffic — and overwhelming in the other.
# Isochrones: The Genuinely Useful Output
Point-to-point routing is a solved commodity. Isochrones — the reachable area within a travel-time budget — are where routing becomes analysis.
import requests, geopandas as gpd
from shapely.geometry import shape
req = {
"locations": [{"lat": -27.470, "lon": 153.021}],
"costing": "auto",
"contours": [{"time": 5}, {"time": 10}, {"time": 20}, {"time": 30}],
"polygons": True,
"denoise": 0.4,
"generalize": 60,
}
r = requests.post("http://localhost:8002/isochrone", json=req).json()
bands = gpd.GeoDataFrame(
[{"minutes": f["properties"]["contour"], "geometry": shape(f["geometry"])}
for f in r["features"]],
crs="EPSG:4326",
)
bands.to_file("catchment.gpkg", driver="GPKG")
Two parameters control output quality and both are worth understanding. denoise between 0 and 1 discards small disconnected fragments — raise it for cleaner polygons, lower it if genuine islands matter. generalize sets the simplification tolerance in metres; 60 is reasonable for display, and you want it near zero for area calculations.
From there the analysis is ordinary spatial work. Intersect drive-time bands with census population to get reachable population. Difference two providers’ catchments to find contested territory. Subtract the union of all existing service catchments from a boundary to find genuinely unserved areas:
-- Population reachable within each band, and the unserved remainder
SELECT b.minutes, sum(c.population * ST_Area(ST_Intersection(c.geom, b.geom))
/ ST_Area(c.geom))::int AS reachable_pop
FROM catchment_bands b
JOIN census_sa1 c ON ST_Intersects(c.geom, b.geom)
GROUP BY b.minutes ORDER BY b.minutes;
Where the number of origins is large — every one of ten thousand candidate sites — generate the isochrones once and index them by H3 cell rather than repeatedly intersecting polygons.
# Where Paying Still Wins
Three cases, stated honestly.
Live traffic. Open engines route on free-flow or historical average speeds. Google, HERE, and TomTom have real-time probe data from hundreds of millions of devices. If your application gives a driver an arrival time right now, you need that feed and you cannot build it.
Global geocoding at consumer quality. Nominatim on OpenStreetMap is genuinely good in well-mapped regions and patchy elsewhere. Commercial geocoders have proprietary address data with better coverage of new developments, unit numbers, and rural addressing.
Someone else’s uptime. A self-hosted engine is a service you own. Preprocessing a fresh planet extract, monitoring the container, and handling the 3 a.m. page are real costs that do not appear in the twenty-dollar figure.
The practical resolution most teams land on is a hybrid: self-host everything analytical — matrices, isochrones, catchment studies, batch optimisation, anything historical — and pay per call only for the customer-facing arrival times that genuinely need live traffic. That typically removes 95% of the bill while keeping the 5% that matters, and it fits the broader cost pattern described in Low-Cost, High-Flexibility Spatial Architecture.
Related reading: Low-Cost, High-Flexibility Spatial Architecture Patterns · H3 as a Spatial Join Accelerator: 100 Million Points Without sjoin · The Open Source Geospatial Stack: PostGIS, GDAL, and Beyond