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: trueremains 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:hgvvsrestriction:motorcartreated differently in the same network build. - You are already running
osrm-customizefor 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: 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.
-
Restricted-turn smoke test — identify a known
no_left_turnorno_entryrelation in your PBF usingosmium 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. -
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).
-
Relation completeness audit — run
osmium tags-filter region.osm.pbf r/type=restriction -o restrictions.osm.pbfthen count relations with incomplete membership usingosmium fileinfo -e restrictions.osm.pbf. Expected: zero relations missing afromway,viamember, ortoway. -
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. -
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. -
PBF freshness gate — add a CI step that records the
osmosis --read-pbfheader 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.
Related
- Turn-restriction parsing with OSM relation tags — parent topic covering the full restriction-to-penalty pipeline across all engines
- OSM Graph Architecture & Network Modeling — foundational pillar for directed-graph construction, cost functions, and constraint modeling
- Configuring edge weights for freight logistics — edge-weight design patterns that complement restriction-enforcement logic
- Graph fragmentation prevention in OSM data — how missing
vianodes and simplified ways cause disconnected graph components