GraphHopper Map Matching for Fleet Routing
GraphHopper snaps a noisy GPS trace onto the road network by running a Hidden Markov Model directly on top of its own routing graph. That coupling is the reason it is worth the operational weight of a JVM service for fleet work: the same graph that answers “what is the fastest legal route for a 40-tonne truck” also decides which road each GPS point belongs to. Turn restrictions, one-way segments, and vehicle-class access are honoured during matching, not bolted on afterward, so a heavy-goods trace does not silently snap to a pedestrian alley that happens to run parallel to the depot access road.
The cost is that GraphHopper is a Java service you host and feed, not a Python library you import. You import an OpenStreetMap extract once, define the vehicle profiles your fleet needs, start the server, and drive it over HTTP. From Python the interaction is a requests call to the /match endpoint and a parse of the JSON response. This page covers that full loop: import and profiles, the request contract, parsing snapped edges and per-edge attributes, and the failure modes that show up at fleet scale. If you have not yet decided between engines, the trade-off analysis lives in choosing a routing engine: OSRM vs Valhalla vs GraphHopper; the mathematics of the matcher itself is covered in Hidden Markov model map matching in Python.
Why Nearest-Edge Snapping Is Not Enough
The naive approach — assign each GPS point to the closest road segment — collapses the moment a trace passes anything more complex than an open motorway. Two-way roads with a physical median present two candidate edges 15 m apart; a fix with 20 m of urban-canyon error routinely lands on the wrong carriageway, and the “matched” heading reverses. Grade-separated interchanges stack three or four roads within the GPS error radius. Independent per-point snapping has no memory, so it produces a matched path that teleports between overpass and surface street, inflating mileage and fabricating turns that never happened.
GraphHopper treats matching as a most-likely-sequence problem instead. Each observation contributes several candidate edges (the emission model, weighted by distance and gps_accuracy), and consecutive candidates are linked by the routing cost of actually driving between them on the graph (the transition model). The Viterbi decoder then selects the globally cheapest consistent path. Because the transition cost is a real route on the profile’s graph, an impossible hop — across a motorway barrier, the wrong way down a one-way street, or through a turn restriction — is expensive and gets rejected in favour of the legal alternative. That is the property fleet routing needs and the reason engine choice matters when auditability is a requirement.
Prerequisites
GraphHopper server. A packaged graphhopper-web-*.jar (an executable “fat” JAR) or the official Docker image, plus a JRE. The map-matching module ships inside the main GraphHopper distribution and is reached through the /match endpoint of the web server; you do not need a separate download on current releases. See the GraphHopper documentation and the GraphHopper source on GitHub.
Java runtime. Java 17 or newer for GraphHopper 8.x/9.x. Confirm with java -version before import — a mismatched JVM is the single most common start-up failure.
An OSM extract. A regional .osm.pbf (for example a country or state extract) covering every road your fleet drives. The graph is only as complete as the extract; a trace that leaves the imported bounding box cannot be matched.
Python client. Python 3.10+ with requests for the HTTP calls and gpxpy (optional) if you prefer typed GPX construction over hand-built XML. pandas is convenient for turning matched output into a table. No JVM knowledge is required on the Python side.
Clean input. Map matching is not a substitute for preprocessing. Sort the trace by timestamp, drop duplicate fixes, and reject gross outliers first — ideally via Kalman filtering for GPS noise reduction and outlier removal. A single 400 m spike is enough to break a sequence that would otherwise match cleanly.
Step-by-Step Workflow
Step 1 — Configure profiles and import the OSM extract
GraphHopper builds its routing graph once from a config.yml and reuses it for every request. The profiles you declare here are exactly the profiles you may later name in a /match call, so define the vehicle classes your fleet needs up front.
# config.yml
graphhopper:
datareader.file: region.osm.pbf
graph.location: graph-cache
profiles:
- name: car
custom_model_files: []
vehicle: car
- name: truck
vehicle: truck
custom_model_files: [truck.json]
# Encoded values the custom model needs to read at match time
graph.encoded_values: road_class, road_environment, max_weight, max_height, max_width, hgv
import.osm.ignored_highways: footway,cycleway,path,pedestrian
Run the import (the first invocation builds graph-cache; later starts reuse it):
java -Xmx8g -Xms8g -jar graphhopper-web-9.1.jar server config.yml
Expected output: log lines reporting nodes and edges imported, followed by Started server ... :8989. The graph-cache directory now holds the prepared graph. Deleting that directory forces a full re-import — necessary whenever you change graph.encoded_values or swap the .osm.pbf.
Step 2 — POST a GPS sequence to /match
The /match endpoint accepts a GPX document. Each trackpoint carries latitude, longitude, and ideally a timestamp; the timestamps let the transition model reason about elapsed time, which sharpens matching on sparse traces. Send the ordered trace, name a profile, and set gps_accuracy to the receiver’s real 1-sigma error in metres.
import requests
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
GRAPHHOPPER_URL = "http://localhost:8989"
def build_gpx(points: list[dict]) -> str:
"""
Serialise an ordered fix list to a minimal GPX 1.1 document.
points : list of {"lat": float, "lon": float, "time": datetime}
MUST be sorted ascending by time with duplicates removed.
"""
gpx = ET.Element("gpx", version="1.1", creator="fleet-matcher")
trk = ET.SubElement(gpx, "trk")
seg = ET.SubElement(trk, "trkseg")
for p in points:
pt = ET.SubElement(
seg, "trkpt", lat=f"{p['lat']:.6f}", lon=f"{p['lon']:.6f}"
)
t = p["time"].astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
ET.SubElement(pt, "time").text = t
return ET.tostring(gpx, encoding="unicode", xml_declaration=True)
def match_trace(points: list[dict], profile: str = "car",
gps_accuracy: float = 12.0) -> dict:
"""POST an ordered trace to /match and return the parsed JSON."""
gpx = build_gpx(points)
resp = requests.post(
f"{GRAPHHOPPER_URL}/match",
params={
"profile": profile,
"gps_accuracy": gps_accuracy, # metres, ~1-sigma of the receiver
"points_encoded": "false", # decoded [lon, lat] arrays in JSON
"details": ["road_class", "max_speed"],
},
data=gpx.encode("utf-8"),
headers={"Content-Type": "application/gpx+xml"},
timeout=30,
)
resp.raise_for_status()
return resp.json()
Note on points_encoded. Left at its default true, GraphHopper returns the matched geometry as a Google-encoded polyline string. Setting it to false returns plain [lon, lat] coordinate arrays — easier to parse and to feed into downstream geometry, at the cost of a larger payload. Whichever you pick, remember GraphHopper speaks [longitude, latitude], the same axis order as OSRM and Valhalla and the reverse of the [lat, lon] order most Python GIS code assumes.
Step 3 — Parse matched edges, snapped points, time and distance
A successful response nests the result under paths. The map_matching block reports how much of the trace was matched, which is your first-line quality signal.
def parse_match(result: dict) -> dict:
"""Extract the fields a fleet pipeline needs from a /match response."""
path = result["paths"][0]
mm = result.get("map_matching", {})
summary = {
"matched_distance_m": path["distance"], # metres along the graph
"matched_time_ms": path["time"], # ms, from edge speeds
"raw_gps_length_m": mm.get("original_traversal_time_or_distance"),
"matched_edge_count": len(path.get("details", {}).get("road_class", [])),
"snapped_coords": path["points"]["coordinates"], # [[lon, lat], ...]
}
# Per-edge attributes arrive as [from_index, to_index, value] triples
# over the coordinate array, letting you attribute mileage by road class.
road_class = path.get("details", {}).get("road_class", [])
summary["distance_by_road_class"] = _mileage_by_detail(
path["points"]["coordinates"], road_class
)
return summary
def _mileage_by_detail(coords: list, detail: list) -> dict:
"""Sum matched length per detail value (e.g. motorway vs residential)."""
from math import radians, sin, cos, asin, sqrt
def haversine(a, b):
lon1, lat1, lon2, lat2 = map(radians, [a[0], a[1], b[0], b[1]])
h = sin((lat2 - lat1) / 2) ** 2 + cos(lat1) * cos(lat2) * sin((lon2 - lon1) / 2) ** 2
return 2 * 6_371_000 * asin(sqrt(h))
totals: dict = {}
for start, end, value in detail:
seg = sum(
haversine(coords[i], coords[i + 1]) for i in range(start, end)
)
totals[value] = totals.get(value, 0.0) + seg
return totals
Expected output shape: a dict with matched_distance_m and matched_time_ms scalars, a snapped_coords list of [lon, lat] pairs tracing the road-aligned path, and distance_by_road_class breaking mileage down by OSM road class — the raw material for a compliance report that separates motorway from residential kilometres. Compare matched_distance_m against the raw great-circle length of the input: a matched path more than ~30% longer than the raw trace usually means the matcher detoured around a missing or restricted edge.
Step 4 — Bound the search with max_visited_nodes
The Hidden Markov transition step runs a shortest-path search between every pair of consecutive candidate edges. On clean, dense traces those searches are short. On sparse traces (30–60 s between fixes) or across a gap, the connecting route can be long, and GraphHopper caps the work with max_visited_nodes. When that budget is exhausted before a connection is found, matching fails with a broken-sequence error even though a valid path exists.
def match_sparse(points, profile="truck", gps_accuracy=25.0,
max_visited_nodes=10_000):
"""Match a sparse or noisy trace with a larger search budget."""
resp = requests.post(
f"{GRAPHHOPPER_URL}/match",
params={
"profile": profile,
"gps_accuracy": gps_accuracy,
"max_visited_nodes": max_visited_nodes, # default is ~3000
"points_encoded": "false",
},
data=build_gpx(points).encode("utf-8"),
headers={"Content-Type": "application/gpx+xml"},
timeout=60,
)
resp.raise_for_status()
return resp.json()
Treat max_visited_nodes as a latency-versus-recall dial. Raising it lets the matcher bridge wider gaps but lengthens the tail of your response-time distribution, because a single pathological trace can now spend far longer in search. In production, set a value that clears your normal sparse traces (5,000–10,000 is typical) and rely on splitting the trace at genuine signal-loss gaps — see interpolating GPS gaps during tunnel signal loss — rather than chasing ever-larger budgets.
Custom Model JSON for Trucks
A car profile will happily snap a 40-tonne trace onto a 3.5-tonne-limited bridge or a 3.2 m-height underpass, because geometrically that edge is the nearest road. To match heavy-goods traces correctly, back the truck profile with a custom_model that reads the relevant OSM tags and blocks non-traversable edges. Because the transition model only routes over drivable edges, an inaccessible road is simply never a candidate.
{
"priority": [
{ "if": "max_weight < 40", "multiply_by": "0" },
{ "if": "max_height < 4", "multiply_by": "0" },
{ "if": "max_width < 2.55", "multiply_by": "0" },
{ "if": "road_class == RESIDENTIAL", "multiply_by": "0.3" }
],
"speed": [
{ "if": "true", "limit_to": "80" },
{ "if": "road_class == MOTORWAY", "limit_to": "85" }
]
}
Save this as truck.json, reference it from the profile’s custom_model_files in config.yml (Step 1), and re-import so the required encoded values (max_weight, max_height, max_width) are baked into the graph. A multiply_by of 0 removes an edge from the profile entirely; a fractional value merely discourages it, which is the right tool for keeping trucks off residential streets without making a legitimate final-metre delivery impossible. The speed block matters for matching too: the transition model uses edge travel time, so a realistic truck speed curve produces better temporal reasoning than a car speed curve applied to a lorry.
How the Match Maps to the HMM
Under the hood GraphHopper’s matcher is a textbook Hidden Markov map matcher. Each GPS observation z_t has an emission probability over nearby edges that falls off with distance, governed by gps_accuracy as the measurement standard deviation σ: p(z_t | edge) ∝ exp(−d² / 2σ²), where d is the perpendicular distance from the fix to the candidate edge. The transition probability between a candidate at t and one at t+1 decays with the difference between the straight-line distance and the on-graph routing distance connecting them, penalising physically implausible jumps. The Viterbi decoder works in log-space to keep the products numerically stable and returns the single most likely edge sequence.
The practical consequence is that gps_accuracy and max_visited_nodes are the two knobs that determine most outcomes. gps_accuracy widens or narrows the emission window; max_visited_nodes bounds how hard the transition search tries to connect candidates. If you want the parameter theory in depth, including how emission and transition weights interact, see tuning HMM emission and transition probabilities. If you are still weighing HMM matching against simpler geometric snapping, choosing a map-matching algorithm lays out the decision.
Routing Engine Integration Notes
Coordinate order. GraphHopper returns and expects [longitude, latitude]. Feeding [lat, lon] into a downstream Shapely LineString silently places your trucks in the wrong hemisphere with no error. Normalise axis order at the boundary, exactly as you would for the OSRM /match service or Valhalla Meili.
Profile must exist at import time. You cannot request a profile that was not declared in config.yml before the graph was built. Adding a profile requires editing the config and re-importing; there is no runtime profile injection. Plan the full set of vehicle classes before the first import to avoid repeated multi-hour rebuilds on large extracts.
Speed mode vs flexible mode. Contraction Hierarchies (CH) speed up plain routing but constrain which parameters you can vary per request. Map matching runs in the flexible (Landmark/ch.disable) path, so ensure your profiles are not CH-only if you also intend to pass per-request custom-model overrides. For a pure matching server, disabling CH preparation shortens import time with no matching penalty.
Rate and concurrency. A self-hosted GraphHopper has no external rate limit, but it is CPU-bound and single-graph. Size a thread pool to your core count and put a circuit breaker in front of it, as covered in handling routing engine rate limits with circuit breakers. For high-volume overnight matching, the batch map matching with GraphHopper in Python pattern wraps this endpoint in a thread pool with retry and result aggregation.
Operational Troubleshooting
Import runs out of memory (OOM) on a large extract
Cause: The import phase holds the node and edge structures in the JVM heap; a country-scale .osm.pbf can need many gigabytes, and the default -Xmx is far too small.
Symptom: java.lang.OutOfMemoryError: Java heap space during “creating graph” or “preparing”, or the process is killed by the OS OOM reaper.
Fix: Raise the heap with -Xmx (for example -Xmx16g) and set -Xms to the same value to avoid heap resizing pauses. Trim the extract to only the region the fleet drives, and prune unused highway types via import.osm.ignored_highways. Once built, graph-cache reloads with a much smaller runtime footprint, so the large heap is only needed for the one-time import.
"Sequence is broken" / "Cannot find matching path"
Cause: The HMM found no drivable connection between two consecutive observations inside max_visited_nodes. Usually a GPS gap, an off-road outlier, a gps_accuracy that is too tight, or a profile that cannot reach the connecting edge.
Symptom: HTTP 400 with a map_matching error message naming the observation index where the sequence broke.
Fix: Pre-clean outliers and sort by timestamp before matching. Split the trace at gaps longer than your sampling interval allows and match each segment. Raise gps_accuracy toward the receiver’s true error, and increase max_visited_nodes. If it only fails for one vehicle class, confirm the connecting road is actually traversable by that profile’s custom model.
Profile not found
Cause: The profile named in the /match request was never declared in config.yml, or the config was edited without re-importing the graph.
Symptom: HTTP 400: Could not find profile 'truck' ... available: [car].
Fix: Add the profile block to config.yml, delete graph-cache, and re-import so the graph is rebuilt with the profile and its encoded values. Keep the profile list under version control alongside the extract version so a redeploy never silently drops a vehicle class.
Low match quality on sparse GPS (30–60 s sampling)
Cause: With long gaps between fixes, the emission window is thin and the transition search must span long distances, so the matcher either wanders onto parallel roads or gives up.
Symptom: Matched distance far exceeds raw trace length, or the snapped path zig-zags between adjacent streets.
Fix: Raise gps_accuracy to reflect the uncertainty of sparse tracking (25–40 m), increase max_visited_nodes so the transition search can reach the true connecting edge, and ensure timestamps are present so the temporal model can constrain implausible detours. Upsampling the trace before matching rarely helps and can introduce interpolated points off the real road.
Truck trace snaps to a restricted road
Cause: The request used the car profile, or the truck custom model does not read the restriction tags (max_weight, max_height, max_width), or those encoded values were absent at import time.
Symptom: Matched path crosses a weight-limited bridge or low underpass the vehicle physically cannot use; mileage-by-road-class looks wrong.
Fix: Match with the truck profile, confirm the custom model sets multiply_by: 0 for edges that violate the vehicle envelope, and verify graph.encoded_values includes every tag the model reads. Re-import after any change to encoded values.
Latency spikes under batch load
Cause: A few pathological traces exhaust max_visited_nodes and dominate the tail of the latency distribution, while a shared single-graph server serialises CPU-bound requests.
Symptom: p50 latency is healthy but p99 is many seconds; worker threads block waiting on the server.
Fix: Cap max_visited_nodes to a value that clears normal traces, add a per-request timeout, and place a circuit breaker in front of the server. Move heavy reprocessing to an off-peak batch pipeline sized to the server’s core count rather than overwhelming it from an online path.
Deployment Checklist
Parent topic: Routing Engine Integration for Fleet Telematics
Related
- Batch Map Matching with GraphHopper in Python — wrap this /match endpoint in a thread pool with retry and DataFrame aggregation for fleet-scale reprocessing
- Choosing a Routing Engine: OSRM vs Valhalla vs GraphHopper — decide whether GraphHopper’s truck profiles and turn-restriction handling justify the JVM over lighter engines
- OSRM Integration for Fleet Map Matching — the C++ alternative with a leaner footprint but weaker vehicle-class modelling
- Valhalla Meili Map Matching for Telematics — tile-based matching with rich trace attributes as a third engine option
- Hidden Markov Model Map Matching in Python — the emission/transition/Viterbi theory GraphHopper implements internally
- Kalman Filtering for GPS Noise Reduction — upstream smoothing that prevents outliers from breaking the match sequence