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