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 .osrm dataset 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-customize matter more than per-request dimension flexibility

Choose Valhalla when:

  • You need per-request tuning of truck dimensions without rebuilding tiles — costing_options.truck accepts weight, height, width, length, and axle_count on 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: truck plus 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
Freight-capability comparison matrix: OSRM vs Valhalla vs GraphHopper A grid with six freight-relevant capability rows (HGV weight/height limits, hazmat exclusion, turn costs, dynamic traffic, isochrones, per-request API tuning) against three engine columns. Filled circles indicate native support, half-filled circles indicate partial or scripted support, and outline circles indicate no built-in support. OSRM Valhalla GraphHopper HGV weight / height limits Hazmat exclusion tags Turn costs / restrictions Dynamic / time-dependent traffic Isochrone generation Per-request truck dimensions Raw query throughput Native support Partial / scripted Not built-in — custom work required Circle fill density = degree of native support at query time (not build time)

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

  1. 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.

  2. Height-limit enforcement. Repeat with a 4.2 m profile against a maxheight=3.5 tunnel or underpass. Expected: detour in all three engines; OSRM specifically because its Lua profile has no default height check unless you added one.

  3. Hazmat exclusion. Tag a test edge hazmat=no and confirm a hazmat-enabled truck profile avoids it while a standard car profile routes through freely.

  4. 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.

  5. 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.

  6. 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.