Documentation menu
Truck and ADR routing
What a lorry may legally drive is not a preference, it is a set of rules. This page states exactly which of those rules our routing enforces, which it does not, and where the data comes from, so you can check our answers against the standards rather than take them on trust.
Two sources are normative here:
- ADR 8.6.4, the tunnel restriction provisions of the European Agreement concerning the International Carriage of Dangerous Goods by Road (UNECE, public).
- Council Directive 96/53/EC, which sets the maximum authorised dimensions and weights for vehicles in international traffic.
What is enforced
Send costing: "truck" with a truck profile. Every field below is passed
to the routing engine and applied to the graph search, so a restricted way
is not merely penalised, it is excluded.
| Field | Unit | Sent |
|---|---|---|
height | metres | always |
width | metres | always |
length | metres | always |
weight | tonnes, gross combination | always |
axle_load | tonnes, heaviest single axle | when supplied |
axle_count | count | when supplied |
hazmat | boolean | always |
adr_tunnel_code | ADR 8.6.4 code | when supplied |
Omit axle_load or axle_count and no axle constraint is applied. We do
not guess them, because a guessed axle load is worse than none: it would
silently exclude legal roads or admit illegal ones, and you would have no
way to tell which.
Defaults
An omitted dimension falls back to the Directive 96/53/EC maximum for a
standard articulated vehicle: 4.0 m height, 2.55 m width, 16.5 m length,
40 t gross weight. These are deliberately the legal maxima rather than a
typical vehicle, so an unspecified request errs towards over-restriction.
A route we return for the defaults is drivable by anything smaller in
dimensional terms. It says nothing about the load. A consignment's ADR
tunnel restriction code belongs to what is in the vehicle, not to how big
the vehicle is, so a smaller lorry carrying a restricted load can be refused
a tunnel this route goes through. Declare tunnel_code and hazmat either
way.
ADR tunnel categories
Tunnel categories run A to E. Category A carries no restriction. Categories B to E progressively exclude loads by their tunnel restriction code.
check_adr_tunnel answers one question: may a load carrying tunnel code X
pass through a category Y tunnel? It is a pure lookup against the ADR 8.6.4
matrix. It performs no network call, consults no map data, and is therefore
free to run and identical for everyone.
Two deliberate choices, both conservative:
- A declared code is read at its worst case. Where a code covers several transport categories, we apply the most restrictive reading.
hazmat: truewith no declared code is treated as code B, the broadest restriction, rather than assumed unrestricted.
Both choices can refuse a passage that a more precise declaration would permit. That is the intended direction of error. If you need the less restrictive answer, declare the code.
Which engine, and what each needs
The gateway runs on one of two routing engines, and the ADR support differs. This matters if you self-host.
- GraphHopper backend: ADR tunnel enforcement needs no modified
engine. Forbidden categories are emitted as custom-model rules against
GraphHopper's own
hazmat_tunnelencoded value, using the stock server. - Valhalla backend: dimensional and
hazmatconstraints work on a stock build, butadr_tunnel_coderequires our bounded ADR costing fork. On an unmodified upstream Valhalla the field is ignored, and no tunnel-category restriction is applied.
That fork is the single piece of engine code we modify, it is scoped to ADR tunnel-category costing alone, and the change has been offered upstream. See licensing and the lineage table in the repository for how every component is consumed.
If you self-host on stock Valhalla and need tunnel-category enforcement, either run the GraphHopper backend or use our distro build. Do not assume the field took effect: check that a code D load is refused a category E tunnel on a route you know.
Limits, stated plainly
Restrictions come from OpenStreetMap, and OSM truck tagging is uneven. Height, weight and hazmat tags are well populated on some networks and sparse on others. Where a restriction is not tagged, no engine can honour it, including ours. We built an open audit tool that measures truck-attribute completeness cell by cell for exactly this reason, and we publish its findings: the current Great Britain report is at /docs/truck-data-audit, denominators included, so the gaps are measured rather than assumed away.
This is a routing engine, not a compliance certificate. We tell you what the map and the ADR matrix say. We do not certify that a vehicle, load or journey is lawful, and we do not issue clearance documents. The duty holder remains the operator and, where appointed under ADR 1.8.3, their Dangerous Goods Safety Adviser.
An unchanged route is not a clearance. Send a 4.2 m height and get the
same road back, and what you have learned is that nothing in the graph
excluded it, which on an unevenly tagged network is mostly a fact about the
tagging. It is not a measurement of the structure you are about to drive
under, and it should never be read as one. Where you need the measurement,
POST /v1/clearance/along walks the corridor over a
survey's own geometry and reports coverage gaps as gaps rather than as
clear road.
Speed limits and restrictions are never fabricated. If the graph does not carry a value, nothing is surfaced rather than a plausible guess.
Clean-air zones: what the road costs, not just how long it takes
A route that is eighteen minutes quicker through central London is not cheaper if it costs £100 a day in Low Emission Zone charges. No routing engine knows that: the charge is levied by an authority on the vehicle, not carried by the road. So we carry the zones ourselves and put the money in the response.
Every POST /route reply whose geometry crosses a UK clean-air or
low-emission zone gains a top-level zones block. No flag is needed,
and a route that crosses none carries nothing new.
Say what you drive, get a figure
Whether a zone charges you depends on your vehicle's category and Euro
standard, which a routing request has no other reason to carry. Add an
emissions object, shaped like adr:
{
"locations": [
{ "lat": 51.6562, "lon": -0.3903 },
{ "lat": 51.3762, "lon": -0.0982 }
],
"costing": "truck",
"emissions": {
"vehicle_category": "van",
"fuel": "diesel",
"euro_standard": 5
}
}
vehicle_category is one of car, van, minibus, hgv, bus,
coach, taxi, phv, motorcycle, motorhome. fuel is petrol,
diesel, electric, hydrogen or gas. euro_standard is 1–6; declare
a heavy-duty Euro VI vehicle as 6. It is required for any combustion
fuel and optional for electric and hydrogen, which every scheme treats
as compliant.
That Watford-to-Croydon run crosses both London schemes, and the reply says so:
"zones": {
"crossed": [
{
"id": "gb-london-ulez",
"name": "London Ultra Low Emission Zone",
"operator": "Transport for London",
"type": "ulez",
"enforcement": "charge",
"assessment": "charged",
"hours": "24 hours a day, 7 days a week, every day of the year, except Christmas Day (25 December)",
"currency": "GBP",
"entry_arc_m": 3500,
"exit_arc_m": 37150,
"distance_in_zone_m": 33650,
"applicable_rule": { "min_euro": { "diesel": 6, "petrol": 4 }, "daily_charge": 12.5 },
"source": {
"url": "https://tfl.gov.uk/modes/driving/ultra-low-emission-zone/cars",
"publisher": "Transport for London",
"checked": "2026-09-03"
},
"explanation": "this van is below London Ultra Low Emission Zone's minimum emission standard, so it is charged GBP 12.50 a day to drive in the zone (published schedule: https://tfl.gov.uk/modes/driving/ultra-low-emission-zone/cars)"
},
{
"id": "gb-london-lez",
"assessment": "compliant",
"explanation": "this van meets London Low Emission Zone's minimum emission standard, so no charge applies"
}
],
"estimated_charges": {
"currency": "GBP",
"daily_total": 12.5,
"unresolved_zones": 0,
"banned_zones": 0,
"basis": "sum of each crossed zone's published daily charge for the declared vehicle, one charge per zone per day"
}
}
entry_arc_m and exit_arc_m are distances along the returned route, so
they move with the road the engine picks; the figures above are measured
against the shipped boundary for a straight Watford-to-Croydon line.
A Euro 5 diesel van misses the ULEZ standard of Euro 6 diesel and is charged £12.50; it clears the LEZ standard of Euro 3 for particulates and pays nothing there. Both zones are reported, because "no charge here" is an answer worth having.
What we will not do
We will not guess your vehicle. Send no emissions object and every
crossed zone comes back as "assessment": "unknown", with the words
charge depends on vehicle emissions, supply emissions to resolve. A
wrong charge is worse than no charge: it is a number somebody budgets
against.
We will not fold the charge into a cost. estimated_charges is its
own line. Nothing in trip.summary changes, because a road charge is not
a routing cost and a total that quietly contains both is a total nobody
can reconcile.
We will not call a ban a charge. Scotland's LEZs (Glasgow, Edinburgh,
Aberdeen, Dundee) do not charge. A non-compliant vehicle is prohibited and
is issued a penalty charge notice; there is no daily fee that buys entry.
Those zones report "assessment": "banned", carry the penalty ladder in a
penalty field, and add nothing to the money total.
We will not quote a figure you cannot check. Every entry carries the
authority's own page, the publisher and the date a human last read it.
GET /v1/zones serves the whole dataset with the same provenance, so you
can audit every number before you rely on one. Schedules change; checked
is a date, not a promise.
avoid_zones, and why it mostly cannot
Set "avoid_zones": true and the search is told to keep out of every zone
this vehicle would be charged or banned in. It costs no extra engine call
and no extra metering: the decision is made from your own waypoints before
the route is computed, and applied with the engine's own exclude_polygons.
Two limits bite, and the response reports both rather than hiding them.
The routing engine accepts at most 10 km of exclusion perimeter across a whole request. Most zone boundaries are bigger than that. Of the thirteen zones we carry, only five fit — and because the budget is shared, in practice one at a time. The London ULEZ boundary is 236 km around. So a request that asks to avoid it gets this, not a silent failure:
"avoidance": {
"requested": true,
"excluded": [],
"not_avoidable": [
{
"id": "gb-london-ulez",
"reason": "exceeds_engine_exclusion_budget",
"detail": "this zone's boundary is 236.4 km around and 51 vertices, and the routing engine accepts at most 10 km of exclusion perimeter … Simplifying the ring further does not lift the perimeter limit: simplification removes vertices, and the perimeter of a simplified ring is essentially the perimeter of the original. The zone is annotated in crossed[] instead, so the charge is still reported."
}
]
}
And a zone containing one of your own waypoints can never be avoided —
excluding it would leave the request unroutable — so it comes back as
"reason": "waypoint_inside_zone".
Avoid means exclude, for v1. There is no soft-cost mode: a chargeable
zone is excluded exactly as a banned one is, so a route that would have
been worth £12.50 is not offered at all. If you would happily pay to save
forty minutes, leave avoid_zones off — annotation is on by default — and
decide from the figure.
Coverage and its edges
Thirteen zones: the London ULEZ and LEZ; the seven English Clean Air Zones (Bath, Birmingham, Bradford, Bristol, Portsmouth, Sheffield, Tyneside); and the four Scottish LEZs. Boundaries come from each authority's own published data and are simplified — each zone states the tolerance it was simplified to, and a route running within that distance of a boundary may be classified either way.
Exemptions, discounts and residency schemes are not modelled. Neither
is anything that turns on a weight the request does not state: where a
council charges a motor caravan £9 under 3.5 tonnes and £50 over it, the
answer is unknown with the council's own wording, not a coin flip.
POST /optimise takes the same emissions object, but says less and says
so: the optimiser is fed a travel-time matrix rather than roads, so its
zones block reports the zones the problem's own locations sit in and
the avoidance applied to the matrix. Route each vehicle's ordered stops
through POST /route for the per-route charges.
Using it
POST /routewithcosting: "truck", see the API reference.POST /optimisecarries the same constraints through multi-vehicle planning, see optimisation.- Agents can call
routeandcheck_adr_tunnelas tools over the MCP server. - Tunnel code tables and parameter conventions are on the conventions page.