Freight fleets running mixed tractor-trailer, box-truck, and van profiles hit a wall that car-routing benchmarks never surface: mass limits, axle counts, bridge clearances, and hazmat exclusions all change which roads are legal, not just which roads are fastest. This page narrows the decision made in comparing routing engines for production down to the freight-specific question — which of OSRM, Valhalla, or GraphHopper actually enforces heavy-vehicle constraints correctly — and sits inside the broader Routing API Automation & Fleet Integration workflow that wires engine output into dispatch and re-routing pipelines. The short answer: GraphHopper and Valhalla both expose first-class truck costing models with weight, height, width, length, and axle-load parameters; OSRM requires you to hand-roll every one of those constraints inside a Lua profile before extraction.
When to Use Each Engine
The three engines share a common ancestor problem — shortest path over a weighted directed graph derived from OSM — but diverge sharply on how much freight-specific logic ships built-in versus how much you write yourself.
Choose OSRM when:
- Query throughput is the dominant constraint — OSRM’s contraction-hierarchy and MLD backends serve requests fastest of the three at comparable hardware
- Your fleet runs a small, fixed number of vehicle classes (for example, one HGV tier and one van tier), so building a separate
.osrmdataset per class is acceptable - You already maintain custom Lua profiles for other constraints (low-emission zones, conditional access) and want freight limits handled in the same script
- Live traffic updates via
osrm-customizematter more than per-request dimension flexibility
Choose Valhalla when:
- You need per-request tuning of truck dimensions without rebuilding tiles —
costing_options.truckacceptsweight,height,width,length, andaxle_counton every call - Multi-modal analysis (walking, cycling, transit) shares infrastructure with freight routing, since Valhalla’s costing-model architecture is uniform across modes
- Time-dependent and predictive-traffic speeds need to feed truck routing the same way they feed car routing
- Isochrone generation for depot-siting or service-area analysis needs to reuse the same tile set as turn-by-turn routing
Choose GraphHopper when:
- You want a strict OSM-native truck profile with minimal custom scripting —
vehicle: truckplus dimension parameters cover most regulatory cases - Multiple vehicle-class profiles need to be built from a single PBF in one preparation pass, each with its own contraction hierarchy
- Your team is more comfortable in Java/YAML configuration than in Lua, and you may need to embed the routing engine as a library rather than run it as a standalone server
- Custom flag encoders for niche constraints (specific hazmat categories, seasonal weight restrictions) are worth the Java development cost
Engine Comparison for Freight Routing
Each engine’s freight support lives at a different layer: OSRM bakes constraints into the graph at extraction, Valhalla evaluates costing rules per request against pre-built tiles, and GraphHopper sits in between with per-profile graph builds that still accept some request-time overrides.
| Capability | OSRM | Valhalla | GraphHopper |
|---|---|---|---|
| HGV weight limit | maxweight read in Lua, baked at extract time |
weight in costing_options.truck, per request |
truck.weight profile parameter or request override |
| HGV height/width/length | Custom Lua checks against maxheight/maxwidth |
height, width, length in costing options |
truck.height, truck.width, truck.length |
| Axle load / axle count | Not modeled natively — requires Lua extension | axle_count in costing options |
truck.axle_load via custom flag encoder |
| Hazmat exclusion | Manual tag check in process_way |
hazmat boolean excludes hazmat=no edges |
hazmat tag support in truck flag encoder |
| Turn costs / restrictions | process_turn in Lua, math.huge hard block |
Costing-model turn penalties, admin-aware | turn_costs: true per profile, OSM-native |
| Time-dependent speeds | Segment-speed CSV via osrm-customize |
Predictive + historical traffic tiles | Limited — custom_model speed rules |
| Isochrones | Not supported (routing/matrix only) | /isochrone endpoint, truck costing included |
/isochrone endpoint (GraphHopper 6+) |
| API style | REST, /route, /table, /match |
REST, /route, /matrix, /isochrone, /trace_route |
REST, /route, /isochrone, /matrix |
| Config surface | Lua profile, compiled into extract | JSON costing config, mostly per-request | YAML config.yml, per-profile build |
| Rebuild trigger | Any Lua or PBF change | Tile rebuild for topology; costing is request-time | PBF or config.yml profile change |
The practical consequence: if your dispatch system needs to route a 44-tonne artic and a 3.5-tonne van through the same API without maintaining two separate graph builds, Valhalla’s per-request costing_options.truck block is the only one of the three that supports it without deploying parallel datasets. GraphHopper gets close — it accepts some dimension overrides per request when custom_model is enabled — but its cleanest path is still profile-per-vehicle-class, matching the approach already used for setting turn restrictions in GraphHopper vs OSRM.
The following snippet queries all three engines with equivalent truck constraints and normalizes the responses for a side-by-side comparison — useful as a smoke test before committing a fleet to one engine.
# requires: requests>=2.28 (pip install requests)
# Python 3.9+
import requests
ORIGIN = (52.5170, 13.3889) # (lat, lon)
DEST = (52.5300, 13.4200)
def osrm_truck_route(origin: tuple[float, float], dest: tuple[float, float]) -> dict:
"""OSRM must already be running a truck.lua-extracted dataset — no per-request dims."""
coords = f"{origin[1]},{origin[0]};{dest[1]},{dest[0]}"
r = requests.get(f"http://localhost:5001/route/v1/driving/{coords}",
params={"overview": "false"})
r.raise_for_status()
route = r.json()["routes"][0]
return {"engine": "osrm", "distance_m": route["distance"], "duration_s": route["duration"]}
def valhalla_truck_route(origin: tuple[float, float], dest: tuple[float, float]) -> dict:
body = {
"locations": [
{"lat": origin[0], "lon": origin[1]},
{"lat": dest[0], "lon": dest[1]},
],
"costing": "truck",
"costing_options": {
"truck": {"weight": 20.0, "height": 4.1, "width": 2.6, "length": 16.5, "axle_count": 5}
},
}
r = requests.post("http://localhost:8002/route", json=body)
r.raise_for_status()
summary = r.json()["trip"]["summary"]
return {"engine": "valhalla", "distance_m": summary["length"] * 1000, "duration_s": summary["time"]}
def graphhopper_truck_route(origin: tuple[float, float], dest: tuple[float, float]) -> dict:
r = requests.get(
"http://localhost:8989/route",
params={
"point": [f"{origin[0]},{origin[1]}", f"{dest[0]},{dest[1]}"],
"profile": "truck",
"ch.disable": "true", # required to accept custom_model overrides
},
)
r.raise_for_status()
path = r.json()["paths"][0]
return {"engine": "graphhopper", "distance_m": path["distance"], "duration_s": path["time"] / 1000}
for fn in (osrm_truck_route, valhalla_truck_route, graphhopper_truck_route):
result = fn(ORIGIN, DEST)
print(f"{result['engine']:>11}: {result['distance_m']:.0f} m, {result['duration_s']:.0f} s")
Key Parameters and Tuning
| Parameter | OSRM | Valhalla | GraphHopper | Notes |
|---|---|---|---|---|
| Weight limit | maxweight Lua check |
costing_options.truck.weight (tonnes) |
truck.weight (tonnes) |
Valhalla and GraphHopper both compare against maxweight/hgv:maxweight tags |
| Height limit | maxheight Lua check |
costing_options.truck.height (m) |
truck.height (m) |
Set 0.1–0.2 m above physical vehicle height as a safety buffer |
| Hazmat class | Manual hazmat tag branch |
costing_options.truck.hazmat (bool) |
hazmat flag in truck encoder |
None encode full ADR class tables — treat as binary exclusion, not classification |
| Turn penalty | profile.properties.u_turn_penalty |
Built into costing model, not user-tunable | Derived from turn_costs: true |
See turn-restriction comparison for per-engine tuning depth |
| Traffic freshness | osrm-customize segment-speed CSV interval |
Tile rebuild + date_time request field |
custom_model speed rule refresh |
Valhalla’s predictive buckets need no rebuild for time-of-day shifts |
| Route alternatives | alternatives=true (limited) |
alternates count |
algorithm=alternative_route |
GraphHopper’s alternative-route algorithm is the most configurable of the three |
| Build memory (country PBF) | 8–15 GB with CH | 10–20 GB with traffic tiles | 6–12 GB per profile | Scale linearly per additional vehicle-class profile |
Weight and height buffers deserve special attention: bridge strikes and weight-limit violations are the two failure modes with real safety and legal consequences, so pad every dimension parameter rather than passing raw vehicle specs. A common production pattern is loading dimensions from a fleet database and adding a fixed safety margin before the request is built, rather than hardcoding per-vehicle values in the routing service.
Integration Points
Feeding fleet dispatch. All three engines return distance and duration in a consistent enough shape that a thin adapter layer — like the osrm_truck_route/valhalla_truck_route/graphhopper_truck_route functions above — lets dispatch optimizers stay engine-agnostic. Keep the adapter boundary explicit so swapping engines later touches one module, not the whole pipeline.
Upstream edge-weight design. Whichever engine you choose, the underlying cost logic mirrors the patterns in configuring edge weights for freight logistics — vehicle-class filters, elevation grades, and regulatory penalties. OSRM and GraphHopper apply these at graph-build time; Valhalla applies most of them at query time through costing options, which makes it easier to A/B test penalty weights without a rebuild.
Downstream re-routing. If you’re wiring engine output into live traffic response, the webhook-driven dynamic re-routing pattern applies to any of the three — the trigger and recomputation logic is identical, only the outbound request payload (Lua-baked vs. per-request costing) differs.
Benchmarking before commitment. Before locking in an engine for a freight fleet, run the same query set against all three under realistic concurrency. The benchmarking routing engine latency and accuracy harness produces the percentile timing and ground-truth deltas needed to make that call with data instead of vendor claims.
Validation Checklist
-
Weight-limit enforcement. Route a 40-tonne profile across a bridge tagged
maxweight=7.5. Expected: the route detours; if any engine routes across, the tag mapping or costing configuration is broken. -
Height-limit enforcement. Repeat with a 4.2 m profile against a
maxheight=3.5tunnel or underpass. Expected: detour in all three engines; OSRM specifically because its Lua profile has no default height check unless you added one. -
Hazmat exclusion. Tag a test edge
hazmat=noand confirm a hazmat-enabled truck profile avoids it while a standard car profile routes through freely. -
Turn-cost parity. Cross-reference against setting turn restrictions in GraphHopper vs OSRM — confirm no-left-turn relations are respected identically across all three engines on the same PBF.
-
Dimension-change responsiveness. For Valhalla and GraphHopper, change a truck dimension parameter mid-session and confirm the next request reflects it without a rebuild. For OSRM, confirm that a dimension change requires and triggers a full
osrm-extract+osrm-contract. -
Isochrone parity (if used for depot siting). Generate a 30-minute truck isochrone from Valhalla and GraphHopper for the same origin and compare polygon area — a divergence greater than roughly 15% usually indicates a speed-profile or turn-cost mismatch between the two builds, not a data error.
Which engine handles hazmat routing restrictions natively?
None of the three engines ship a complete hazmat regulatory ruleset out of the box. GraphHopper’s truck profile reads hazmat-related OSM tags such as hazmat=no and tunnel:hazmat_category, and lets you extend custom flag encoders. OSRM and Valhalla require you to encode hazmat exclusions yourself — as Lua penalties in OSRM or as excludable edge attributes evaluated at query time in Valhalla’s costing model.
Can OSRM enforce HGV weight and height limits at query time?
No. OSRM bakes vehicle-class restrictions into the graph at osrm-extract time through a dedicated Lua profile such as truck.lua. There is no per-request weight or height parameter in the HTTP API — if you need different limits for different vehicles in the same fleet, you must build and serve a separate .osrm dataset per limit tier.
Does Valhalla support time-dependent speeds for trucks?
Yes. Valhalla’s traffic-aware tiles apply predicted and historical speed buckets per time-of-day, and the truck costing model respects them the same way the auto costing model does. You must build tiles with valhalla_build_extract and enable traffic extract support, then pass a date_time block in the request to select the relevant time bucket.
Related
- Comparing routing engines for production — the general decision framework this page specializes for heavy-vehicle constraints
- Benchmarking routing engine latency and accuracy — reproducible harness for measuring the throughput and accuracy trade-offs summarized here
- Configuring edge weights for freight logistics — the underlying cost-function patterns each engine implements differently
- Setting turn restrictions in GraphHopper vs OSRM — deeper comparison of turn-cost configuration between two of the three engines