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_speed scalar 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_surfaces needs 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.

Costing JSON Knobs to Route Behavior — Cargo Bike Profile Six rows, each pairing a Valhalla bicycle costing_options field on the left with the observable route behavior it produces on the right, connected by an arrow. costing_options.bicycle Resulting route behavior "bicycle_type": "Cross" closest stock geometry to a box-frame bike Stable frame baseline for a loaded box bike "cycling_speed": 14 reduced cruise speed for loaded mass Realistic ETA at a loaded cruising pace "use_hills": 0.15 low tolerance for steep grades Detours around climbs that stall a loaded bike "avoid_bad_surfaces": 0.8 strong penalty on unpaved/cobbled ways Skips cobblestone and loose gravel that shift a load "use_roads": 0.6, "gate_penalty": 90 accept arterial lanes, resist gated chicanes Prefers road lanes over narrow, gated bike paths exclude_locations: [...] request-level, not a costing field Removes bollards narrower than the box beam

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

  1. Loaded vs. unloaded divergence. Run the same origin-destination pair with loaded=True and loaded=False. The loaded route should show a longer trip.summary.time, and on hilly terrain a different shape (path geometry) as use_hills steers around climbs — if the paths are identical, the hill penalty is not taking effect.
  2. 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.
  3. ETA sanity against field data. Compare trip.summary.time against GPS-logged delivery run times for the same OD pairs. If the modeled ETA is consistently faster than observed, lower cycling_speed further or raise maneuver_penalty.
  4. Surface avoidance spot-check. Spatially join the returned route geometry against a known cobblestone or unpaved-street layer for your city. With avoid_bad_surfaces at 0.8, overlap should be minimal outside of unavoidable segments near the destination.
  5. Exclusion budget check. Assert len(exclude_locations) <= 50 before 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.
  6. Regression on gate crossings. After raising gate_penalty, count maneuvers in 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.