Setting turn restrictions in GraphHopper vs OSRM requires fundamentally different configuration paradigms that matter most when the precision of handling turn restrictions in routing graphs directly affects delivery compliance or safety. GraphHopper handles turn costs through its Java routing core using a per-profile turn_costs: true flag and standard OSM type=restriction relations, while OSRM relies on Lua profile scripts to parse restriction tags during the osrm-extract phase. Both engines sit squarely within the broader OSM Graph Architecture & Network Modeling pipeline, but GraphHopper’s approach is configuration-driven and leverages OSM relations directly, whereas OSRM’s is script-driven and highly customisable at extraction time. For logistics engineers and Python backend developers, this means GraphHopper offers faster deployment with strict OSM compliance, while OSRM provides granular control over penalty scaling, vehicle-class filtering, and time-dependent routing logic.

Feature GraphHopper OSRM
Restriction processing Graph preparation phase (Java core) osrm-extract phase (Lua profile)
Configuration style Declarative (config.yml profiles) Imperative (*.lua scripts)
OSM compliance Strict native mapping Customisable via Lua
Penalty control Boolean block or turn-cost weights Dynamic scaling and conditional logic
Rebuild requirement On PBF or config change After Lua or PBF changes

When to use this approach

The choice between the two engines is rarely about correctness — both engines correctly parse type=restriction relations when configured properly — and more about how much extraction-time flexibility your fleet requires.

Choose GraphHopper when:

  • Your OSM PBF data contains well-formed restriction relations and you need them enforced without custom scripting.
  • You run multiple vehicle profiles (car, van, HGV) and want profile-isolated turn-cost matrices built from the same PBF in a single preparation step.
  • Rapid iteration on routing profiles matters more than sub-second query latency at scale — GraphHopper’s YAML config is faster to change than Lua.
  • Contraction-hierarchy builds must complete in under an hour on regional PBF files; CH with turn_costs: true remains feasible up to country-scale extracts.

Choose OSRM when:

  • Your operational area has non-standard restrictions (access-time windows, load-class overrides, municipal turning bans coded as conditional tags) that require imperative per-turn logic.
  • You need restriction:hgv vs restriction:motorcar treated differently in the same network build.
  • You are already running osrm-customize for live traffic updates via segment-speed files and want turn penalties to slot into the same pipeline.
  • Query throughput matters more than build flexibility — OSRM’s MLD (Multi-Level Dijkstra) and CH bake penalties into compact edge arrays that serve thousands of requests per second.

Implementation

The diagram below shows how the two pipelines diverge at the restriction-processing stage.

GraphHopper vs OSRM turn-restriction processing pipeline Two parallel pipeline flows showing that GraphHopper processes restriction relations during graph preparation from config.yml, while OSRM processes them during osrm-extract via a Lua profile. Both converge on a routing graph used for query-time pathfinding. GraphHopper OSRM OSM PBF extract config.yml (profiles) car.lua (Lua profile) Graph preparation (restriction → turn-cost matrix) osrm-extract + osrm-contract (process_turn → edge weights) GH graph-cache (binary) .osrm contracted files Route query (HTTP API)

GraphHopper: declarative configuration

GraphHopper processes turn restrictions during graph preparation. When turn_costs: true is set on a profile in config.yml, the engine automatically parses type=restriction relations and converts them into turn-cost entries in the routing graph. Routing algorithms (Dijkstra, A*, or ALT) then respect these constraints natively.

# config.yml — GraphHopper 8.x declarative profile configuration
graphhopper:
  datareader.file: region.osm.pbf
  graph.location: ./graph-cache
  profiles:
    - name: car
      vehicle: car
      weighting: fastest
      turn_costs: true
    - name: hgv
      vehicle: truck
      weighting: fastest
      turn_costs: true
  profiles_ch:
    - profile: car
    - profile: hgv

turn_costs: true is specified per profile under profiles:, not as a standalone top-level key. When contraction hierarchies (profiles_ch) are enabled, CH preprocessing encodes turn restrictions natively so query-time routing never needs to re-evaluate them. The graph.location directory must be deleted and rebuilt whenever turn_costs is toggled or a new PBF is ingested.

The following Python snippet queries a running GraphHopper instance and checks that a known restricted turn increases travel time compared to a bypass route:

# requires: requests>=2.28
import requests

GH_BASE = "http://localhost:8989"

def route(origin: tuple[float, float], destination: tuple[float, float], profile: str = "car") -> dict:
    """Query GraphHopper HTTP API and return the first path object."""
    resp = requests.get(
        f"{GH_BASE}/route",
        params={
            "point": [f"{origin[0]},{origin[1]}", f"{destination[0]},{destination[1]}"],
            "profile": profile,
            "instructions": "false",
            "locale": "en",
        },
    )
    resp.raise_for_status()
    paths = resp.json()["paths"]
    return paths[0] if paths else {}

# Example: two routes near the same intersection — one crosses a no-left-turn node
direct   = route((52.51703, 13.38886), (52.51821, 13.39012))
bypassed = route((52.51703, 13.38886), (52.51900, 13.39150))

print(f"Direct  — distance: {direct.get('distance', 0):.1f} m, time: {direct.get('time', 0) / 1000:.1f} s")
print(f"Bypassed— distance: {bypassed.get('distance', 0):.1f} m, time: {bypassed.get('time', 0) / 1000:.1f} s")

GraphHopper’s strength lies in its strict adherence to OSM standards. If your PBF contains valid type=restriction relations with from, via, and to members assigned correct roles, the engine maps them directly to turn-cost matrix entries without any scripting.

OSRM: Lua-driven extraction pipeline

OSRM processes turn restrictions during osrm-extract via the Lua profile. The process_turn callback receives an ExtractionTurn object and writes a duration and weight back onto it. The is_restricted field is populated automatically when the extractor encounters a type=restriction relation whose from/via/to members match the turn being evaluated.

-- car.lua — OSRM Lua profile, process_turn callback
-- Requires: OSRM backend ≥ 5.27 (ExtractionTurn API)
function process_turn(profile, turn)
  local penalty = 0

  if turn.is_u_turn then
    -- U-turn penalty in seconds; fall back to 20 s if not set in profile
    penalty = profile.properties.u_turn_penalty or 20
  end

  if turn.has_traffic_light then
    -- Additional signal delay — typical urban value is 2–5 s
    penalty = penalty + (profile.properties.traffic_light_penalty or 3)
  end

  -- Hard block: any finite value would be routable under certain conditions;
  -- math.huge makes the edge genuinely unreachable in CH and MLD graphs.
  if turn.is_restricted then
    penalty = math.huge
  end

  -- Vehicle-class-aware example: soften HGV penalties for explicit truck allowances
  -- if turn.restriction == "restriction:hgv" and profile.name == "truck" then
  --   penalty = math.huge  -- enforce for HGV; car profile would see no restriction
  -- end

  turn.duration = penalty
  turn.weight   = penalty
end

turn.is_restricted is populated automatically by osrm-extract when it parses type=restriction relations from the PBF — you do not manually query OSM IDs. Because restrictions are baked into .osrm files during extraction, any Lua change requires a full osrm-extract followed by osrm-contract (for CH) or osrm-partition + osrm-customize (for MLD) rebuild.

# requires: requests>=2.28
import requests

OSRM_BASE = "http://localhost:5000"

def osrm_route(origin: tuple[float, float], destination: tuple[float, float]) -> dict:
    """Query the OSRM HTTP API and return the first route object."""
    coords = f"{origin[1]},{origin[0]};{destination[1]},{destination[0]}"
    resp = requests.get(
        f"{OSRM_BASE}/route/v1/driving/{coords}",
        params={"overview": "false", "steps": "false"},
    )
    resp.raise_for_status()
    data = resp.json()
    return data["routes"][0] if data.get("routes") else {}

result = osrm_route((52.51703, 13.38886), (52.51821, 13.39012))
print(f"OSRM — distance: {result.get('distance', 0):.1f} m, duration: {result.get('duration', 0):.1f} s")

Key parameters and tuning

Parameter GraphHopper OSRM (Lua) Recommended range / notes
turn_costs true / false per profile N/A Always true for urban delivery; optional for long-haul highway-only profiles
u_turn_penalty Not separately tunable in YAML (implicit from restriction relation) profile.properties.u_turn_penalty 20–60 s for urban; 0 for off-road
traffic_light_penalty N/A (GH derives signal delay from OSM highway=traffic_signals nodes) profile.properties.traffic_light_penalty 2–8 s; calibrate from GPS trace dwell times
Hard-block value Automatic when type=restriction matched math.huge Never use a large finite value — it leaves the turn theoretically reachable
CH profiles profiles_ch list in config.yml osrm-contract step Remove a profile from CH list to allow query-time penalty overrides
Lua profile variant N/A *.lua file passed to osrm-extract --profile Keep one file per vehicle class to avoid conditional branching complexity
graph.location cache Path to directory; delete to force rebuild N/A Mount on NVMe; 5–20 GB for country-scale PBF with CH
osrm-extract threads N/A --threads flag Match to physical core count; I/O-bound above ~8 cores on spinning disk

Integration points

The output of both engines is a restriction-aware routing graph served via HTTP. Downstream systems that consume routes — dispatch optimisers, ETA predictors, fleet management dashboards — do not need to know which engine produced the route, but the choice affects what penalty signals are available at the graph edge level.

GraphHopper → downstream: The /route response includes instructions with turn types and penalties encoded as turn angles. If you need the raw turn-cost matrix for a custom configuring edge weights for freight logistics pipeline, GraphHopper exposes its graph API via GraphHopperStorage in the Java library — accessible by embedding GH as a dependency rather than running it as a server.

OSRM → downstream: OSRM’s /route/v1/ response includes legs[].annotation.duration arrays when annotations=true is set. Each element represents one edge traversal, with turn penalties already folded into the edge weight. This makes it straightforward to reconstruct the per-turn cost breakdown in Python using pandas:

# requires: requests>=2.28, pandas>=1.4
import requests, pandas as pd

def osrm_annotated_route(origin: tuple, destination: tuple) -> pd.DataFrame:
    """Return per-step duration annotations as a DataFrame for post-processing."""
    coords = f"{origin[1]},{origin[0]};{destination[1]},{destination[0]}"
    resp = requests.get(
        "http://localhost:5000/route/v1/driving/" + coords,
        params={"overview": "false", "steps": "true", "annotations": "duration,nodes"},
    )
    resp.raise_for_status()
    routes = resp.json().get("routes", [])
    if not routes:
        return pd.DataFrame()

    rows = []
    for leg in routes[0]["legs"]:
        annotation = leg.get("annotation", {})
        durations   = annotation.get("duration", [])
        nodes       = annotation.get("nodes", [])
        for i, dur in enumerate(durations):
            rows.append({
                "from_node": nodes[i]     if i < len(nodes)     else None,
                "to_node":   nodes[i + 1] if i + 1 < len(nodes) else None,
                "duration_s": dur,
            })
    return pd.DataFrame(rows)

df = osrm_annotated_route((52.51703, 13.38886), (52.51821, 13.39012))
print(df.describe())

Both engines integrate naturally with building directed graphs from OSM PBF files upstream: you extract, prepare, then point either server at the prepared data directory. If you later switch engines, the only migration cost is rebuilding the graph artifact — the OSM PBF and your restriction-validation tooling remain the same.

Validation checklist

Run these checks after every graph rebuild to confirm restrictions are correctly applied before routing traffic enters production.

  1. Restricted-turn smoke test — identify a known no_left_turn or no_entry relation in your PBF using osmium getid r <relation_id> --output-format json; route from a point whose shortest path would cross that restriction and assert the engine routes around it. Expected: detour distance > 0 m.

  2. U-turn rejection — query a route from point A to a point 10 m behind point A on the same one-way street. Expected: neither engine produces a route that involves a U-turn at the intermediate node (both should route around the block).

  3. Relation completeness audit — run osmium tags-filter region.osm.pbf r/type=restriction -o restrictions.osm.pbf then count relations with incomplete membership using osmium fileinfo -e restrictions.osm.pbf. Expected: zero relations missing a from way, via member, or to way.

  4. Vehicle-class isolation (OSRM) — if your Lua profile branches on restriction:hgv, route the same origin/destination with your car profile and your truck profile across a known HGV-restricted turn. Expected: car routes through, truck routes around.

  5. CH consistency check (GraphHopper) — compare routes produced by a CH-enabled profile and a non-CH profile on the same query. Expected: identical paths; duration difference < 1 % (rounding only). Large divergence indicates a CH preprocessing bug with turn_costs: true.

  6. PBF freshness gate — add a CI step that records the osmosis --read-pbf header timestamp and fails the build if the PBF is older than your SLA window (e.g., 7 days for logistics). Stale PBF data is a leading cause of restriction-coverage drift in production.


FAQ: Does GraphHopper require a graph rebuild when I change turn_costs?

Yes. Toggling turn_costs: true or false on any profile requires deleting the graph.location directory and rerunning graph preparation. The binary graph stores edge-cost metadata that cannot be patched in place.

FAQ: Can OSRM handle vehicle-class-specific restrictions like restriction:hgv?

Yes. OSRM’s Lua profile receives the full tag set on the restriction relation. You can read restriction:hgv, restriction:motorcar, or conditional variants and apply separate penalties per class inside process_turn.

FAQ: What causes silent turn-restriction failures in both engines?

Missing or mis-typed via members in the OSM restriction relation. Both engines require a valid from way, via node or way, and to way with correct roles. Incomplete relations are silently skipped, not flagged as errors.