A stock bicycle_type: "Road" profile in Valhalla assumes a narrow, light, easily maneuverable machine — exactly the opposite of a loaded front-loader or extended-frame cargo bike carrying 80-150 kg of parcels through a last-mile delivery zone. This page covers the specific costing JSON adjustments that make Valhalla’s bicycle model behave like a heavy, wide, grade-sensitive vehicle rather than a road bike, and it sits inside the broader Valhalla configuration for multi-modal analysis workflow that also covers profile setup and tile builds for pedestrian and transit modes.
The underlying problem is that Valhalla ships no dedicated cargo-bike profile, and its bicycle costing model exposes no weight or width fields at all — those only exist on the truck costing model. Getting realistic routes therefore means re-purposing the bicycle knobs that do exist (bicycle_type, use_hills, avoid_bad_surfaces, use_roads) as proxies for load and beam, and layering a request-level exclusion pass on top for physical pinch points. This is one instance of the general engine-selection and tuning work covered across Python Routing Engines & Isochrone Mapping; the technique generalizes to any Valhalla profile that needs a constraint the built-in costing model does not natively express.
When to Use This Approach
Tune a dedicated cargo-bike costing block, rather than routing on Valhalla’s default bicycle profile, when any of the following apply:
- The vehicle beam exceeds roughly 0.75-0.9 m. Standard bollards, cycle-path chicanes, and gate barriers are frequently spaced for single-rider bikes and will physically block a box-frame cargo bike even though Valhalla considers the way bicycle-accessible.
- Loaded mass materially changes climbing behavior. A 120 kg gross weight (bike + cargo) loses far more speed on a 6% grade than the flat
cycling_speedscalar implies, so hill-heavy default routes produce unrealistic ETAs and rider fatigue. - Surface quality affects load stability, not just comfort. Cobblestone and loose gravel that a road cyclist tolerates can shift or tip an unsecured cargo load, so
avoid_bad_surfacesneeds to be pushed well above its default. - Dispatch planning needs defensible ETAs. Fleet operators feeding Valhalla output into delivery-window commitments cannot afford the 15-25% underestimate typical of an unloaded-bike default profile applied to a loaded cargo run.
If your fleet runs unloaded e-bikes or standard courier bikes with no meaningful width constraint, the stock bicycle profile with a modest cycling_speed override is usually sufficient — the full technique below is worth the added complexity specifically for box-frame and extended-frame cargo bikes operating in dense urban infrastructure.
Implementation
The snippet below composes the costing_options.bicycle block, layers on a dimensioned-access exclusion list, and posts a single /route request. It assumes a running Valhalla instance and tile build — that setup is covered in the Valhalla configuration for multi-modal analysis guide and is not repeated here.
# requires: requests>=2.31, pandas>=2.0 (pip install requests pandas)
from __future__ import annotations
import requests
import pandas as pd
VALHALLA_URL = "http://localhost:8002/route"
CARGO_BIKE_WIDTH_M = 0.90 # beam of a typical front-loader "bakfiets"
MAX_EXCLUDE_LOCATIONS = 50 # Valhalla request-level cap on exclude_locations
def build_cargo_costing(cycling_speed_kmh: float = 14.0, loaded: bool = True) -> dict:
"""
Compose costing_options.bicycle for a loaded cargo bike.
Valhalla's bicycle costing model has no weight or width field, so mass
and beam are approximated through cruise speed, hill/surface avoidance,
and gate/maneuver penalties rather than a native dimension parameter.
"""
return {
"bicycle_type": "Cross", # stiffest stock geometry Valhalla offers
"cycling_speed": cycling_speed_kmh,
"use_roads": 0.6, # accept arterial lanes over pinch-point paths
"use_hills": 0.15, # steer hard around steep grades when loaded
"avoid_bad_surfaces": 0.8, # cobblestone/gravel destabilize a loaded box
"maneuver_penalty": 25 if loaded else 5,
"gate_penalty": 90 if loaded else 10,
"gate_cost": 60,
"service_penalty": 5,
"shortest": False, # keep time-optimal weighting, not pure distance
}
def load_narrow_barriers(barrier_csv: str) -> list[dict]:
"""
Return {lat, lon} exclusion points for barriers narrower than the cargo
bike's beam. barrier_csv columns: lon, lat, maxwidth_m — pre-extracted
from OSM barrier=bollard / barrier=cycle_barrier nodes via pyosmium.
"""
df = pd.read_csv(barrier_csv)
narrow = df.loc[df["maxwidth_m"] < CARGO_BIKE_WIDTH_M, ["lat", "lon"]]
return narrow.to_dict("records")[:MAX_EXCLUDE_LOCATIONS]
def route_cargo_bike(
origin: tuple[float, float],
destination: tuple[float, float],
barrier_csv: str,
loaded: bool = True,
) -> dict:
lon0, lat0 = origin
lon1, lat1 = destination
payload = {
"locations": [
{"lat": lat0, "lon": lon0},
{"lat": lat1, "lon": lon1},
],
"costing": "bicycle",
"costing_options": {"bicycle": build_cargo_costing(loaded=loaded)},
"exclude_locations": load_narrow_barriers(barrier_csv),
"units": "kilometers",
}
response = requests.post(VALHALLA_URL, json=payload, timeout=30)
response.raise_for_status()
return response.json()
trip = route_cargo_bike(
origin=(4.8952, 52.3702), # Amsterdam depot, (lon, lat)
destination=(4.9130, 52.3791), # delivery zone centroid
barrier_csv="narrow_barriers.csv",
)
print(trip["trip"]["summary"]["time"], "seconds,", trip["trip"]["summary"]["length"], "km")
The exclude_locations list is the mechanism that stands in for a native width field: Valhalla removes those coordinates from the routable graph for this request only, so a bollard tagged maxwidth=0.7 that would silently pass a road-bike profile is excluded before the beam search runs.
Read as a group rather than as a table, these settings encode one physical fact: a loaded box bike is heavy, wide and long. Each parameter addresses one consequence of that, and leaving any at its stock value reintroduces the failure it was meant to prevent.
Key Parameters and Tuning
| Parameter | Location | Range / Default | Cargo-Bike Value | Why |
|---|---|---|---|---|
bicycle_type |
costing_options.bicycle |
Road / Hybrid / Cross / Mountain | Cross |
closest stock geometry to a loaded box-frame bike; Road biases toward narrow-tire, high-pressure assumptions |
cycling_speed |
costing_options.bicycle |
km/h, default 20 |
12-16 |
reflects loaded mass and reduced acceleration; keeps dispatch ETAs realistic |
use_hills |
costing_options.bicycle |
0-1, default 0.5 |
0.1-0.2 |
strongly avoid grades — loaded box bikes lose far more speed climbing than unloaded riders |
use_roads |
costing_options.bicycle |
0-1, default 0.5 |
0.5-0.7 |
prefer arterial lanes with predictable width over chicaned or bollarded bike paths |
avoid_bad_surfaces |
costing_options.bicycle |
0-1, default 0.25 |
0.7-0.9 |
unpaved and cobbled surfaces destabilize an unsecured cargo load |
maneuver_penalty |
costing_options.bicycle |
seconds, default 5 |
20-30 |
approximates the extra time an extended cargo frame needs to execute tight turns |
gate_penalty |
costing_options.bicycle |
seconds, default 10 |
60-120 |
penalizes gates and chicanes a wide bike must slow to walking pace to clear |
shortest |
costing_options.bicycle |
bool, default false |
false |
keeps time-optimal weighting; pure distance ignores the hill/surface penalties above |
exclude_locations |
request root, not costing_options |
list of {lat, lon}, max 50 per request |
narrow-barrier coordinates | enforces dimensioned access — bicycle costing has no maxwidth field to check against |
exclude_locations is capped at 50 points per request. For a delivery zone with more known pinch points than that, partition barriers by geographic tile and route through the subset relevant to each origin-destination pair rather than sending the full citywide list on every call.
The 50-point cap on exclude_locations is the constraint that shapes the deployment. A citywide barrier list will not fit in one request, and truncating it silently reintroduces exactly the pinch points the list exists to avoid. Partitioning by tile keeps every request under the cap while covering the corridor each trip actually uses.
Integration Points
Feeding matrix computation for hub siting. The same costing_options.bicycle block used for a single /route call passes unchanged into Valhalla’s sources_to_targets endpoint. Reuse build_cargo_costing() when adapting the pattern described in Valhalla cost matrix generation for urban planners to evaluate candidate micro-hub locations against a set of delivery zone centroids — the matrix ranks hubs by loaded-bike travel time instead of straight-line distance.
Combining with rail or ferry legs. Cargo-bike last-mile legs frequently start from a freight rail terminal or a ferry landing rather than a fixed depot. When the upstream trip involves those modes, implementing multi-modal transit layers in the graph-modeling section covers how boarding and alighting connectors are attached to the road graph; the cargo-bike leg picks up at that connector node as its origin coordinate.
Dispatch systems. The trip.summary.time and trip.summary.length fields returned above slot directly into delivery-window commitments — treat the loaded-profile ETA as the commitment basis and the unloaded-profile ETA (call build_cargo_costing(loaded=False)) as the return-leg estimate for round-trip scheduling.
Validation Checklist
- Loaded vs. unloaded divergence. Run the same origin-destination pair with
loaded=Trueandloaded=False. The loaded route should show a longertrip.summary.time, and on hilly terrain a differentshape(path geometry) asuse_hillssteers around climbs — if the paths are identical, the hill penalty is not taking effect. - Exclusion effectiveness. For a known narrow bollard in your
barrier_csv, request a route that would naturally pass through it without exclusions, then confirm the excluded version detours around that coordinate rather than snapping through it. - ETA sanity against field data. Compare
trip.summary.timeagainst GPS-logged delivery run times for the same OD pairs. If the modeled ETA is consistently faster than observed, lowercycling_speedfurther or raisemaneuver_penalty. - Surface avoidance spot-check. Spatially join the returned route geometry against a known cobblestone or unpaved-street layer for your city. With
avoid_bad_surfacesat0.8, overlap should be minimal outside of unavoidable segments near the destination. - Exclusion budget check. Assert
len(exclude_locations) <= 50before sending the request — Valhalla silently truncates or rejects oversized exclusion lists depending on version, so enforce the cap in code rather than relying on the API’s error handling. - Regression on gate crossings. After raising
gate_penalty, countmaneuversin the response referencing gates or barriers across a fixed test set of OD pairs; the count should drop relative to the default-profile baseline.
Why does Valhalla still route my cargo bike through a narrow bollard?
exclude_locations only removes a route option near the exact coordinate supplied, within a small snap radius. If the barrier coordinate is offset from the actual bollard position by more than a few meters — common with hand-digitized OSM points — Valhalla will still snap through the way. Buffer each exclusion point by sampling two or three coordinates along the way on either side of the barrier rather than relying on a single point.
Can I add a true weight or width limit to bicycle costing like truck costing has?
No. Valhalla’s bicycle costing model has no weight, width, or height fields — those only exist on the truck costing model. The practical workaround is the exclude_locations technique described above, or pre-filtering the OSM extract with osmium so ways tagged maxwidth below your cargo bike’s beam never enter the bicycle-accessible tile graph at build time.
Related
- Valhalla configuration for multi-modal analysis — profile setup, tile builds, and costing JSON schema this page’s cargo-bike block plugs into
- Valhalla cost matrix generation for urban planners — reusing the same costing_options block for
sources_to_targetshub-siting studies - Implementing multi-modal transit layers — attaching cargo-bike last-mile legs to upstream rail and ferry boarding connectors
- Python Routing Engines & Isochrone Mapping — the broader engine-selection and tuning workflow this technique fits into