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.

What a match response actually contains A match response holds a matchings array, each with legs, and a separate tracepoints array parallel to the input coordinates. Tracepoints may be null where an observation was unmatched, and each carries the index of the matching it belongs to. 6 input coordinates, 2 matchings, 1 unmatched observation input c0 c1 c2 c3 c4 c5 tracepoints m 0, wp 0 m 0, wp 1 m 0, wp 2 null m 1, wp 0 m 1, wp 1 matchings matching 0 — geometry + legs matching 1 Tracepoints are parallel to the input; matchings are not. Zipping matchings against input coordinates is the standard integration bug. Each non-null tracepoint carries matchings_index and waypoint_index, which together locate it inside the response.

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.

Why chunks need overlap Without overlap the first observation of a chunk has no predecessor, so it is matched on proximity alone and can snap to a parallel service road. With five observations of overlap the matcher has context across the boundary and stays on the correct carriageway. Chunk boundary at observation 100 no overlap boundary 3 observations on the service road 5 overlapping overlap region gives context on both sides Deduplicate the overlap when stitching: keep the later chunk's version, which had predecessors on both sides of the boundary. Radius setting against unmatched observations A five metre radius leaves nearly a third of observations unmatched. Fifteen and twenty-five metres bring that to single figures, and passing the receiver’s own per-fix accuracy estimate does better than any fixed value. Unmatched tracepoint rate by radius setting radius 5 m 31 % unmatched — too tight radius 15 m 6 % radius 25 m 4 % per-fix accuracy 2.6 % — best A rising unmatched rate is the earliest signal that a device fleet has changed — a new tracker model with a different noise profile shows up here first. Track it per trace and alert on the trend rather than on any single trace, which will always contain a few genuinely off-network fixes.

Validation checklist

  • Tracepoints align with input length. len(tracepoints) == len(coordinates) whenever tidy=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.