Choosing a Routing Engine: OSRM vs Valhalla vs GraphHopper
Picking the wrong routing engine is an expensive mistake to unwind, because the choice reaches into your preprocessing pipeline, your infrastructure budget, and your operational metrics. All three leading open-source engines — OSRM, Valhalla with its Meili matcher, and GraphHopper — will map-match a clean trace competently, so a feature-checklist comparison rarely settles the question. What settles it is how each engine behaves on your traces, at your volume, under your constraints on cost, privacy, and vehicle mix. This page is a decision framework: it lays out the axes that actually differ, gives you a benchmark harness to run all three against your own data, and ends with a deployment checklist so the winner reaches production without surprises.
If you have not yet read the routing engine integration overview, start there for the pipeline context and the Python client stack; this page assumes it.
The tree is a starting bias, not a verdict. A single-profile fleet that also wants rich attributes might still pick Valhalla; a JVM shop will lean GraphHopper regardless. Use it to form a hypothesis, then confirm with the benchmark below.
Prerequisites
Before benchmarking, confirm the following.
Python environment: Python 3.10+, httpx ≥ 0.27, polyline ≥ 2.0, numpy ≥ 1.24, shapely ≥ 2.0, pyproj ≥ 3.5. Docker Engine 24+ to run the three servers.
A common map extract. Download one OpenStreetMap extract (a country or region .osm.pbf) and build all three engines from it. Comparing engines built from different extracts measures the maps, not the engines.
A representative trace set. Twenty to fifty real trips spanning your operating conditions: dense urban, motorway, and known low-signal stretches. Where possible attach ground truth — toll gantry timestamps, geofenced depot entry/exit, or a high-accuracy reference device. Clean each trace through your GPS preprocessing pipeline first; benchmarking on dirty traces measures your noise, not the matchers.
Coordinate discipline. Every trace must be in WGS84 and reordered to [longitude, latitude] before any request. This is the single most common source of wasted benchmark runs.
The Comparison That Matters
Map-matching algorithm
- OSRM matches through its map-matching plugin, a geometric-and-HMM hybrid layered on the same Contraction Hierarchies graph used for routing. It is fast and deterministic but exposes fewer tuning knobs.
- Valhalla Meili is a textbook hidden Markov model matcher with a Viterbi decoder, exposing
sigma_z(GPS noise) andbeta(transition smoothness) so you can tune it to your sampling rate. This is the same model described in hidden Markov model map matching in Python. - GraphHopper also implements an HMM matcher with a Viterbi decoder, with strong turn-cost integration so matched paths respect turn penalties, not just connectivity.
On clean, dense traces the three converge. Divergence shows up at low sampling rates (30–60 s intervals) and in ambiguous parallel-road geometry, where the tunable engines have an edge.
Speed, memory, and preprocessing
| Axis | OSRM | Valhalla (Meili) | GraphHopper |
|---|---|---|---|
| Route query latency | Sub-millisecond (CH) | Low (tiled) | Low (CH), moderate (flexible) |
| Match latency per trip | Very low | Low | Low |
| Planet-scale memory | Highest (CH, ~120 GB) | Lowest (tiled, paged) | Moderate |
| Preprocessing time | Long (CH build) | Moderate (tile build) | Moderate |
| Graph update cadence | Full rebuild | Per-tile | Rebuild or partial |
Dynamic costing and turn handling
| Capability | OSRM | Valhalla | GraphHopper |
|---|---|---|---|
| Per-request costing | No (baked at build) | Yes | Yes (flexible mode) |
| Vehicle profiles per server | One | Many | Many (flexible) |
| Turn restrictions | Yes | Yes | Yes |
| Turn costs / penalties | Basic | Yes | Strong |
| Live traffic | Custom speed reload | Native overlay | Flexible-mode speeds |
Licensing and operations
All three are permissively licensed open source — OSRM under BSD-2-Clause, Valhalla under MIT, GraphHopper under Apache-2.0 (a commercial edition also exists). All build from OpenStreetMap data, whose ODbL terms require attribution and share-alike on derived geodata. Operationally, OSRM and Valhalla are C++ and ship as lean containers; GraphHopper runs on the JVM, which integrates naturally with JVM platforms but carries heap-tuning considerations. Self-hosting effort is comparable across all three once the tile pipeline is scripted; the engine-specific pages — OSRM, Valhalla, and GraphHopper — cover the build steps.
Benchmark All Three on Your Own Fleet Trace
A feature table cannot tell you which engine matches your roads best. This workflow runs the same traces through all three and scores the results.
Step 1 — Stand up the three engines
Run each engine from the same extract. The commands differ per engine (covered on their pages); the outcome is three HTTP endpoints:
ENGINES = {
"osrm": "http://localhost:5000",
"valhalla": "http://localhost:8002",
"graphhopper": "http://localhost:8989",
}
Expected output: three reachable base URLs, each answering a health check on the same map region.
Step 2 — Normalise a trace to [lon, lat]
import numpy as np
def normalise_trace(lats, lons, region_bbox):
"""
Return an (N, 2) array of [lon, lat] pairs after asserting the trace
falls inside the operational bounding box.
region_bbox : (min_lon, min_lat, max_lon, max_lat)
"""
lats = np.asarray(lats, dtype=float)
lons = np.asarray(lons, dtype=float)
min_lon, min_lat, max_lon, max_lat = region_bbox
inside = (
(lons >= min_lon) & (lons <= max_lon)
& (lats >= min_lat) & (lats <= max_lat)
)
if not inside.all():
raise ValueError(
"Trace has points outside the region bbox — likely a lat/lon swap."
)
return np.column_stack([lons, lats])
Expected output shape: (N, 2) array, column 0 longitude, column 1 latitude, guaranteed inside the region.
Step 3 — Match a trace on each engine
Each adapter returns a common record so downstream scoring is engine-agnostic. The per-engine request bodies follow the snippets in the integration overview.
import time
import httpx
import polyline
def match_osrm(base_url, coords_lonlat, timestamps):
path = ";".join(f"{lon:.6f},{lat:.6f}" for lon, lat in coords_lonlat)
params = {
"geometries": "polyline",
"overview": "full",
"timestamps": ";".join(str(int(t)) for t in timestamps),
}
t0 = time.perf_counter()
r = httpx.get(f"{base_url}/match/v1/driving/{path}", params=params, timeout=60.0)
latency = time.perf_counter() - t0
r.raise_for_status()
m = r.json()["matchings"][0]
return {
"geometry": polyline.decode(m["geometry"]), # [(lat, lon), ...]
"distance_m": m["distance"],
"confidence": m.get("confidence"),
"latency_s": latency,
}
def match_valhalla(base_url, coords_lonlat, timestamps):
shape = [{"lon": float(lon), "lat": float(lat)} for lon, lat in coords_lonlat]
body = {"shape": shape, "costing": "truck", "shape_match": "map_snap"}
t0 = time.perf_counter()
r = httpx.post(f"{base_url}/trace_route", json=body, timeout=60.0)
latency = time.perf_counter() - t0
r.raise_for_status()
leg = r.json()["trip"]["legs"][0]
return {
"geometry": polyline.decode(leg["shape"], precision=6),
"distance_m": leg["summary"]["length"] * 1000.0, # km -> m
"confidence": None, # Meili has no single score
"latency_s": latency,
}
def match_graphhopper(base_url, coords_lonlat, timestamps):
body = {
"points": [[float(lon), float(lat)] for lon, lat in coords_lonlat],
"timestamps": [int(t * 1000) for t in timestamps], # millis
"profile": "truck",
"points_encoded": False,
}
t0 = time.perf_counter()
r = httpx.post(f"{base_url}/match", params={"profile": "truck"},
json=body, timeout=60.0)
latency = time.perf_counter() - t0
r.raise_for_status()
path = r.json()["paths"][0]
coords = path["points"]["coordinates"] # [[lon, lat], ...]
return {
"geometry": [(lat, lon) for lon, lat in coords],
"distance_m": path["distance"],
"confidence": None,
"latency_s": latency,
}
Expected output: for each engine, a dict with geometry, distance_m, confidence, and latency_s.
Step 4 — Score against ground truth
Score on distance fidelity, geometry agreement, and latency. Distance error uses independent odometry; geometry agreement uses a discrete Hausdorff distance in a metric CRS.
import numpy as np
from pyproj import Transformer
from shapely.geometry import LineString
def project_line(geometry_latlon, to_epsg=32632):
"""geometry_latlon: [(lat, lon), ...] -> shapely LineString in metres."""
tf = Transformer.from_crs("EPSG:4326", f"EPSG:{to_epsg}", always_xy=True)
xs, ys = tf.transform(
[lon for _, lon in geometry_latlon],
[lat for lat, _ in geometry_latlon],
)
return LineString(list(zip(xs, ys)))
def score_match(result, odometer_m, reference_geometry_latlon, to_epsg=32632):
"""
Compare an engine result against odometry and a reference trace.
Returns distance error fraction, Hausdorff distance (m), and latency (s).
"""
dist_err = abs(result["distance_m"] - odometer_m) / max(odometer_m, 1.0)
matched = project_line(result["geometry"], to_epsg)
reference = project_line(reference_geometry_latlon, to_epsg)
hausdorff_m = matched.hausdorff_distance(reference)
return {
"distance_error_frac": round(dist_err, 4),
"hausdorff_m": round(hausdorff_m, 2),
"latency_s": round(result["latency_s"], 4),
"confidence": result["confidence"],
}
Expected output: one score row per engine per trace. Aggregate across the trace set — median distance error, 95th-percentile Hausdorff distance, and p95 latency — to get a per-engine profile you can weight by your priorities.
Step 5 — Decide
Rank engines on the axis you weight most. A last-mile parcel fleet with fixed rounds and a tight compute budget usually weights latency and memory, favouring OSRM. A mixed courier fleet needing per-edge road classes for detailed reconstruction weights attributes and dynamic costing, favouring Valhalla. A JVM logistics platform needing precise turn costs weights those, favouring GraphHopper. There is no context-free winner — only a winner for your weighting.
Routing Engine Integration Notes
Three cross-engine details bite during benchmarking:
- Polyline precision. OSRM encodes at precision 5; Valhalla at precision 6. Decode each with its own precision or every point lands ten times too far out.
- Coordinate order. OSRM’s URL path and Valhalla/GraphHopper JSON all ultimately want longitude before latitude for positional data. The
normalise_traceguard above catches swaps early. - Rate limits. Whether you benchmark against a hosted API or hammer a single self-hosted container, throttle the client. Wrap every call in the limiter-and-breaker from handling routing engine rate limits with circuit breakers so a benchmark run does not knock the server over or get your API key throttled mid-experiment.
Operational Troubleshooting
One engine reports far shorter matched distance than the others
Cause: That engine snapped across a gap in the trace — often at a signal-loss stretch — taking a graph shortcut the vehicle never drove. Symptom: distance error fraction is large and negative (matched shorter than odometry) for one engine only. Fix: split traces at signal gaps before matching, and lower that engine’s gap tolerance (OSRM gaps=split, Valhalla gps_accuracy/breakage_distance, GraphHopper gps_accuracy). Re-run and confirm the shortcut disappears.
Matched geometry hugs a parallel service road instead of the motorway
Cause: emission probability is too permissive relative to sampling rate, so the matcher prefers a slightly closer parallel edge. Symptom: high Hausdorff distance on motorway segments with service roads alongside. Fix: on Valhalla and GraphHopper, lower the GPS-noise parameter so the matcher trusts the road network topology more; on OSRM, tighten per-point radiuses. See tuning HMM emission and transition probabilities for the underlying model behaviour.
GraphHopper rejects the request with "profile not found"
Cause: the profile in the request does not match a profile compiled into the GraphHopper config at build time. Symptom: HTTP 400 with a profile error before any matching happens. Fix: list the configured profiles in config.yml, ensure the benchmark uses one of them, and rebuild the graph if you need a new profile. Unlike Valhalla, GraphHopper profiles are fixed at graph-build time in CH mode.
Valhalla trace_route returns "No suitable edges near location"
Cause: the first or last point sits too far from any road for the configured search radius, often because a trip starts inside a building or car park. Symptom: HTTP 400 from /trace_route or /trace_attributes. Fix: raise search_radius/gps_accuracy for the endpoints, or trim leading and trailing stationary pings during preprocessing so the trace starts on a road.
Benchmark latencies are dominated by cold caches, not the engine
Cause: the first request to each engine pays a one-time cost to load graph tiles into memory. Symptom: the first trace is 10–50× slower than the rest for every engine. Fix: discard a warm-up run before timing, and report median and p95 latency over the warm runs rather than the mean, which the cold start skews.
Distances disagree because engines used different OSM extracts
Cause: the three engines were built from extracts downloaded on different dates or with different clipping. Symptom: systematic, consistent distance offsets that persist across all traces for one engine. Fix: rebuild all three from a single .osm.pbf file captured at one instant. Record the extract’s date and checksum in the benchmark output so results stay reproducible.
Deployment Checklist
Parent topic: Routing Engine Integration for Fleet Telematics
Related
- OSRM Integration for Fleet Map Matching — the /match service, radiuses, and self-host build for the fastest fixed-profile engine
- Valhalla Meili Map Matching for Telematics — HMM matching with dynamic costing and rich per-edge attributes
- GraphHopper Map Matching for Fleet Routing — HMM matching with strong turn costs on the JVM
- Handling Routing Engine Rate Limits with Circuit Breakers — throttle and protect the client during benchmarking and production batches
- Hidden Markov Model Map Matching in Python — the algorithm all three engines implement, for understanding the tuning knobs