OSRM’s /match service implements the same hidden Markov formulation described in map matching GPS traces to OSM networks, in C++, against a contracted graph, and it is faster than any Python implementation will be. This page covers using it well — the parameters that matter, the response shape that trips up integrations, and the chunking that longer traces require. It assumes an OSRM instance deployed per deploying OSRM with Docker for local routing, within the broader Python Routing Engines & Isochrone Mapping toolchain.
When to use this approach
Prefer the endpoint for ordinary vehicle traces against a standard OSM graph — which is most work. Write your own matcher only when you need per-observation candidate scores, an unusual emission model, or a graph OSRM does not host.
One caveat is worth stating plainly: /match uses the profile the graph was built with. Matching a cargo-bike trace against a car graph will snap it to roads the bike never used and cannot legally use. Match against a graph built with the same profile the vehicle actually obeys.
Implementation
# requires: httpx (pip install httpx)
import httpx
OSRM = "http://osrm:5000"
def match_trace(coords: list[tuple[float, float]],
timestamps: list[int],
radiuses: list[float],
*, profile: str = "driving") -> dict:
"""Call /match with per-fix radius and timestamp; split on implausible gaps."""
if not (len(coords) == len(timestamps) == len(radiuses)):
raise ValueError("coords, timestamps and radiuses must be the same length")
path = ";".join(f"{lon:.6f},{lat:.6f}" for lon, lat in coords)
params = {
"timestamps": ";".join(str(int(t)) for t in timestamps),
"radiuses": ";".join(f"{r:.1f}" for r in radiuses),
"geometries": "geojson",
"overview": "full",
"annotations": "nodes,duration",
"gaps": "split", # break where the timing makes continuity impossible
"tidy": "false", # keep the 1:1 mapping to input observations
}
r = httpx.get(f"{OSRM}/match/v1/{profile}/{path}", params=params, timeout=30.0)
r.raise_for_status()
body = r.json()
if body.get("code") != "Ok":
raise RuntimeError(f"match failed: {body.get('code')} {body.get('message')}")
return body
tidy=false is the important default here. Tidy removes redundant coordinates, which speeds the match but destroys the one-to-one correspondence between input observations and tracepoints — and that correspondence is what any per-observation analysis needs. Collapse stationary runs yourself beforehand, as covered in filtering GPS noise before map matching, and leave tidy off.
Per-fix radiuses are the other parameter worth using properly. Where the receiver reports an accuracy estimate, passing it per coordinate lets OSRM’s search adapt exactly as a per-observation sigma would in a hand-written matcher.
Reading the response correctly means going through tracepoints rather than through matchings:
# requires: none beyond the standard library
def edges_per_observation(body: dict) -> list[list[int] | None]:
"""Map each input observation to the OSM node ids of its matched leg."""
out: list[list[int] | None] = []
for tp in body["tracepoints"]:
if tp is None:
out.append(None) # unmatched — do not silently skip
continue
m = body["matchings"][tp["matchings_index"]]
wp = tp["waypoint_index"]
# The leg leaving this waypoint; the final waypoint has no leg
legs = m.get("legs", [])
out.append(legs[wp]["annotation"]["nodes"] if wp < len(legs) else [])
return out
Handling None explicitly rather than filtering it out preserves alignment with the original trace, which matters as soon as anything downstream joins matched output back to timestamps or dwell durations.
Traces beyond the coordinate cap need chunking with overlap, because a chunk boundary otherwise loses the transition across it:
# requires: none beyond the standard library
def chunk_with_overlap(n: int, size: int = 100, overlap: int = 5):
"""Index ranges covering n observations, overlapping so transitions survive."""
if n <= size:
yield (0, n)
return
start = 0
while start < n:
end = min(start + size, n)
yield (start, end)
if end == n:
break
start = end - overlap
Five observations of overlap is enough for the matcher to establish context on the far side of the boundary. Without it, the first observation of each chunk is matched with no predecessor, which is exactly the situation where nearest-segment behaviour reappears.
The annotations parameter deserves a closer look because it determines whether the response is useful for anything beyond drawing a line. Requesting nodes returns the OSM node ids along each leg, which is what lets matched output be joined back to graph edges and therefore to edge attributes such as speed limit, surface or grade. Without it you receive a geometry, which renders nicely and supports no analysis at all. Requesting duration additionally gives the engine’s own travel time per leg, which is the natural comparison point when the whole exercise is calibrating modelled times against observed ones — you get the model’s answer and the observation side by side, per edge, from one call.
Key parameters and tuning
| Parameter | Recommended value | Notes |
|---|---|---|
gaps |
split |
ignore fabricates a path across signal loss |
tidy |
false |
true breaks the observation-to-tracepoint correspondence |
radiuses |
per-fix accuracy, else 15–25 m | Per-fix is materially better where the receiver reports it |
annotations |
nodes,duration |
Node ids are what downstream edge attribution needs |
overview |
full |
simplified drops geometry detail that route-adherence checks want |
--max-matching-size |
200–500 | Default 100; beyond a few hundred, chunk instead |
| Chunk overlap | 5 observations | Enough context for the matcher at a boundary |
| Profile | same as the vehicle | A bike trace on a car graph snaps to roads it cannot use |
Integration points
With the OSRM deployment. --max-matching-size is a launch flag, so raising it means restarting the service — plan it with the other flags in deploying OSRM with Docker for local routing rather than discovering it when a long trace fails.
With the concurrency layer. Matching a fleet’s daily traces is an embarrassingly parallel batch workload against one engine, and it obeys exactly the concurrency arithmetic in parallel distance matrix requests with aiohttp: a semaphore sized to the engine’s thread count, paired with a connector limit.
With graph versioning. The match is against whichever graph the service currently holds. Matching last month’s traces against today’s graph attributes the difference between graphs to driver behaviour, so pin the traces to the graph vintage that was live when they were recorded.
Validation checklist
- Tracepoints align with input length.
len(tracepoints) == len(coordinates)whenevertidy=false. A shorter array means tidy was left on. - Nulls are counted, not dropped. Track the unmatched rate per trace; a rising rate means radiuses are too tight or the profile is wrong for the vehicle.
- Multiple matchings are handled. Feed a trace with a deliberate three-minute gap and confirm the code reads two matchings rather than assuming one.
- Chunk stitching has no duplicated legs. Sum matched distance across chunks and compare against a single unchunked match of a short trace. They should agree closely.
- The profile matches the vehicle. Match a known cycle-path trace against the car graph and confirm it produces visibly wrong output — then confirm the bike graph does not.
Related
- Map matching GPS traces to OSM networks — the underlying model and the parameters this endpoint exposes
- Hidden Markov map matching in Python — the hand-written alternative and when it is worth the effort
- Filtering GPS noise before map matching — preprocessing that replaces what tidy would otherwise do
- Deploying OSRM with Docker for local routing — the deployment whose launch flags cap match request size