Multi-modal routing requires seamless transitions between transportation networks, accurate schedule alignment, and dynamic cost modelling. As part of the broader Python Routing Engines & Isochrone Mapping engineering stack, Valhalla occupies a specific niche: it is the only widely-deployed open-source C++ engine that evaluates mode-switching penalties, transit headways, and real-time accessibility constraints inside a single unified directed graph rather than stitching them together post-query. This guide details the full configuration workflow — data ingestion, valhalla.json schema tuning, tile generation, service deployment, and Python client patterns — required to run production-grade multi-modal pipelines for logistics, urban planning, and accessibility analysis.
Prerequisites
Before configuring the routing engine, verify that your environment satisfies all of the following baseline requirements. Gaps at this stage cause silent failures during tile generation rather than loud build errors.
- Operating system: Linux (Ubuntu 22.04 LTS or Debian 12) for optimal C++ compilation and memory-mapped file support.
- Valhalla build: Version 3.5.0+ compiled with
ENABLE_TRANSIT=ONandENABLE_PYTHON_BINDINGS=ON. Consult the official Valhalla build documentation for platform-specific CMake flags. - Python stack: Python 3.9+,
requests>=2.31,geopandas>=0.14,shapely>=2.0,pyproj>=3.6. - Spatial data:
- An OpenStreetMap
.pbfextract covering your operational region, sourced from Geofabrik or a customosmium extract. - GTFS (General Transit Feed Specification) archives for every transit agency in scope. These must be current: expired
calendar.txtdates cause transit edges to vanish without any build-time warning.
- An OpenStreetMap
- System resources: 16 GB RAM minimum for city-region builds; 64 GB+ for national-scale multi-modal tile generation. Valhalla memory-maps tiles at query time, so RAM is a hard constraint, not a tunable parameter.
Install Python dependencies with:
pip install requests>=2.31 geopandas>=0.14 shapely>=2.0 pyproj>=3.6
Conceptual Architecture
Valhalla’s tile system differs structurally from the flat-file approach used in deploying OSRM with Docker for local routing. OSRM pre-contracts the graph into a single .osrm bundle; Valhalla instead partitions the world into a three-level tile hierarchy (local, regional, arterial) and memory-maps them independently. This design reduces cold-start latency for localised queries but requires careful ulimit and vm.max_map_count tuning under concurrent load.
For multi-modal routing, the tile graph incorporates two graph types fused together: the OSM road/pedestrian/cycling graph and a transit supergraph built from GTFS schedule data. Transit stops become special nodes connected to the road graph by short walking edges; trip sequences from stop_times.txt become time-dependent transit edges with headway and dwell-time attributes baked in. The multimodal costing profile resolves optimal itineraries across both subgraphs simultaneously.
The diagram below shows how the three tile levels and the GTFS transit layer relate to each other:
Step-by-Step Implementation
1. Validate and Ingest GTFS Transit Feeds
Valhalla requires GTFS archives to live in a flat directory under mjolnir.transit_dir before any tile extraction. The engine parses stop_times.txt, routes.txt, and trips.txt to construct temporal edges. Misaligned timezones or missing calendar definitions drop transit availability silently — there is no build error, just missing transit legs in routing responses.
mkdir -p /opt/valhalla/gtfs
# Copy all agency archives into the transit directory
cp agency_a.zip /opt/valhalla/gtfs/
cp agency_b.zip /opt/valhalla/gtfs/
# Pre-validate feeds before handing them to Valhalla
pip install gtfs-validator # or use the canonical Java CLI
gtfs-validator --input /opt/valhalla/gtfs/agency_a.zip
Ensure every agency.txt agency_timezone field matches the geographic bounds of your OSM extract. For instance, an extract of New York City combined with a feed declaring America/Chicago will produce temporal edge mismatches of one hour, causing wrong-day schedule lookups. Consult the GTFS Reference specification for the full schema contract.
2. Author the Core Configuration (valhalla.json)
Valhalla’s behaviour is governed by a centralized JSON file. A critical structural rule: tile_dir, transit_dir, admin, timezone, and elevation must nest inside the mjolnir object — not at the document root. Top-level keys are silently ignored.
{
"mjolnir": {
"tile_dir": "/opt/valhalla/tiles",
"transit_dir": "/opt/valhalla/gtfs",
"admin": "/opt/valhalla/admins.sqlite",
"timezone": "/opt/valhalla/tz_world.sqlite",
"elevation": "/opt/valhalla/elevation",
"max_concurrent_reader_users": 4
},
"service_limits": {
"max_locations": 50,
"max_matrix_locations": 100,
"max_timedep_distance": 500000,
"max_timedep_minutes": 1440
},
"costing_options": {
"auto": {
"use_ferry": 0.5,
"use_toll": 0.2,
"maneuver_penalty": 5
},
"pedestrian": {
"walking_speed": 5.1,
"use_ferry": 0.8,
"max_distance": 16000
},
"transit": {
"use_bus": 0.9,
"use_rail": 0.8,
"transfer_penalty": 600,
"transit_start_end_max_distance": 2145
}
},
"httpd": {
"service_threads": 8
},
"logging": {
"type": "std_out",
"color": true,
"file_name": "/var/log/valhalla/valhalla.log",
"long_request": 100
}
}
The three most consequential multi-modal parameters are:
transfer_penalty— seconds added per mode switch. 600 s (10 minutes) is a reasonable default for urban networks; lower it for dense interchanges where transfers are physically fast.max_timedep_minutes— temporal search window for time-dependent routing. Raising this beyond 1440 dramatically increases query latency for no practical benefit on standard GTFS feeds.transit_start_end_max_distance— maximum walking distance in metres to reach the first or last transit stop. Reduce to ~800 m in dense grids; raise to ~3000 m in low-density suburban zones.
3. Build Administrative Boundaries, Transit Graph, and Routing Tiles
Run the three build commands in strict order. Each step depends on outputs from the previous one.
# 1. Build admin and timezone lookup databases (required for routing context)
valhalla_build_admins \
--config /opt/valhalla/config/valhalla.json \
/path/to/region.pbf
# 2. Build temporal transit supergraph from GTFS feeds
valhalla_build_transit /opt/valhalla/config/valhalla.json
# 3. Fuse OSM road graph with transit supergraph into the tile hierarchy
valhalla_build_tiles \
--config /opt/valhalla/config/valhalla.json \
/path/to/region.pbf
Memory pressure during valhalla_build_tiles is the most common failure point on large extracts. Reduce mjolnir.max_concurrent_reader_users to 1 or 2 to constrain parallel tile reads. For national deployments, split the OSM extract into regional sub-bounding-boxes using osmium extract and process each sequentially, then merge the tile directories before starting the service.
4. Start the Service and Validate Multi-Modal Endpoints
valhalla_service /opt/valhalla/config/valhalla.json 1
Validate with a curl request targeting the /route endpoint with "costing": "multimodal":
curl -s -X POST http://localhost:8002/route \
-H "Content-Type: application/json" \
-d '{
"locations": [
{"lat": 40.7128, "lon": -74.0060, "type": "break"},
{"lat": 40.7580, "lon": -73.9855, "type": "break"}
],
"costing": "multimodal",
"costing_options": {
"transit": {
"use_bus": 0.9,
"use_rail": 0.8,
"transfer_penalty": 600
}
},
"date_time": {"type": 1, "value": "2026-07-01T08:30"},
"directions_options": {"units": "meters"}
}' | python3 -m json.tool | grep -E '"type"|"travel_mode"|"time"'
A successful response returns trip.legs entries where each leg has a travel_mode field alternating between "drive", "pedestrian", and "transit". If every leg shows "pedestrian" only, the transit supergraph was not fused correctly — check that mjolnir.transit_dir is an absolute path and that calendar.txt dates cover your query date.
5. Build a Resilient Python Client
Production pipelines require a Python client that handles retries, coordinate validation, and structured response parsing. The class below wraps the Valhalla /route and /isochrone endpoints with explicit error handling.
# Required imports: requests, json, typing
import requests
import json
from typing import List, Dict, Optional
class ValhallaMultiModalClient:
"""Thin wrapper around the Valhalla HTTP API for multi-modal routing."""
def __init__(self, base_url: str, timeout: float = 20.0) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self._session = requests.Session()
self._session.headers.update({"Content-Type": "application/json"})
def _post(self, endpoint: str, payload: dict) -> dict:
url = f"{self.base_url}{endpoint}"
resp = self._session.post(url, json=payload, timeout=self.timeout)
# Valhalla returns 400 with a JSON error body for routing failures
if not resp.ok:
try:
err = resp.json()
except ValueError:
err = {"error": resp.text}
raise RuntimeError(f"Valhalla {endpoint} failed [{resp.status_code}]: {err}")
return resp.json()
def route_multimodal(
self,
coords: List[Dict[str, float]],
departure_time: str,
transfer_penalty: int = 600,
use_bus: float = 0.9,
use_rail: float = 0.8,
) -> dict:
"""Request a time-dependent multi-modal itinerary.
Args:
coords: List of {"lat": float, "lon": float} dicts, at least two.
departure_time: ISO-8601 local datetime string, e.g. "2026-07-01T08:30".
transfer_penalty: Seconds added per mode switch (default 600).
use_bus: Preference for bus routes, 0.0–1.0 (default 0.9).
use_rail: Preference for rail routes, 0.0–1.0 (default 0.8).
Returns:
Valhalla /route JSON response dict.
"""
if len(coords) < 2:
raise ValueError("At least two coordinate pairs are required.")
# Validate precision: Valhalla needs >= 6 decimal places
for i, c in enumerate(coords):
for key in ("lat", "lon"):
if round(c[key], 3) == c[key]:
raise ValueError(
f"coords[{i}]['{key}'] has < 4 decimal places; "
"rounding below 1e-4 deg causes ~10 m edge-match errors near stops."
)
payload = {
"locations": [
{"lat": c["lat"], "lon": c["lon"], "type": "break"} for c in coords
],
"costing": "multimodal",
"costing_options": {
"transit": {
"use_bus": use_bus,
"use_rail": use_rail,
"transfer_penalty": transfer_penalty,
}
},
"date_time": {"type": 1, "value": departure_time},
"directions_options": {"units": "meters", "language": "en-US"},
}
return self._post("/route", payload)
def isochrone(
self,
origin: Dict[str, float],
contours_minutes: List[int],
costing: str = "pedestrian",
polygons: bool = True,
) -> dict:
"""Generate accessibility isochrones from a single origin point.
Returns a GeoJSON FeatureCollection. For pedestrian-transit isochrones
use costing='multimodal' with a departure_time parameter.
"""
payload = {
"locations": [{"lat": origin["lat"], "lon": origin["lon"]}],
"costing": costing,
"contours": [{"time": m} for m in contours_minutes],
"polygons": polygons,
}
return self._post("/isochrone", payload)
# --- Usage example ---
if __name__ == "__main__":
client = ValhallaMultiModalClient("http://localhost:8002")
result = client.route_multimodal(
coords=[
{"lat": 40.712800, "lon": -74.006000},
{"lat": 40.758000, "lon": -73.985500},
],
departure_time="2026-07-01T08:30",
)
summary = result["trip"]["summary"]
print(f"Duration: {summary['time']} s | Distance: {summary['length']:.1f} m")
legs = result["trip"]["legs"]
for i, leg in enumerate(legs):
mode = leg.get("maneuvers", [{}])[0].get("travel_mode", "unknown")
print(f" Leg {i+1}: {mode}, {leg['summary']['time']} s")
When you need to apply domain-specific constraints on top of Valhalla’s output — such as fleet dispatch windows, HOS (hours-of-service) limits, or vehicle capacity checks — bridge the itinerary results with NetworkX shortest-path algorithms for logistics to layer graph-based heuristics over the raw route data.
Configuration Reference
Key parameters that govern multi-modal routing behaviour, with calibration ranges for common deployment scenarios:
| Parameter | Location in valhalla.json |
Default | Urban dense | Suburban |
|---|---|---|---|---|
transfer_penalty |
costing_options.transit |
300 s | 600 s | 900 s |
transit_start_end_max_distance |
costing_options.transit |
2145 m | 800 m | 3000 m |
max_timedep_minutes |
service_limits |
1440 | 720 | 1440 |
max_timedep_distance |
service_limits |
500 000 m | 200 000 m | 500 000 m |
walking_speed |
costing_options.pedestrian |
5.1 km/h | 4.5 km/h | 5.1 km/h |
use_bus |
costing_options.transit |
0.5 | 0.9 | 0.7 |
use_rail |
costing_options.transit |
0.5 | 0.8 | 0.6 |
max_concurrent_reader_users |
mjolnir |
4 | 4 | 2 (build only) |
service_threads |
httpd |
1 | 8–16 | 4 |
OSM tags that map to Valhalla’s costing model:
| OSM tag | Valhalla cost dimension |
|---|---|
highway=footway / highway=pedestrian |
pedestrian graph edges |
cycleway=* |
bicycle graph edges |
access=private or access=no |
edge exclusion |
route=bus / route=tram |
GTFS route type cross-reference |
public_transport=stop_position |
stop-node snapping |
For an advanced discussion of how transit stop nodes connect to the OSM pedestrian graph in Valhalla’s tile format, refer to the upstream work on implementing multi-modal transit layers in OSM graph architecture.
Production Optimization and Scaling
Memory-mapped tile limits. On Linux, Valhalla memory-maps tile files at query time. With a large national tile set, the default vm.max_map_count limit (65 530) is exhausted under concurrent load, causing silent tile unloading. Set it persistently:
# Apply immediately (resets on reboot)
sudo sysctl -w vm.max_map_count=262144
# Persist across reboots
echo "vm.max_map_count=262144" | sudo tee -a /etc/sysctl.d/99-valhalla.conf
Temporal query scope. max_timedep_minutes is the single largest lever for query latency. Reducing it from 1440 to 720 roughly halves search-space size for typical urban commute scenarios without degrading result quality. Only raise it above 1440 for long-distance inter-city transit routing.
Tile partitioning for large regions. For multi-country deployments, use osmium extract with a bounding-polygon file to split the source .pbf before building tiles. Process each region independently, then serve from merged tile directories. This avoids the non-linear RAM growth that occurs when valhalla_build_tiles processes a continent-scale extract in one pass.
Containerised deployment. A Valhalla container needs elevated ulimit -n (open file descriptors) and the --privileged or --cap-add SYS_ADMIN flag when tile directories are mounted as tmpfs. Without these, memory-mapped files fail under concurrent multi-modal queries.
Origin-destination matrices. When computing travel-time matrices between hundreds of origin-destination pairs — common in logistics accessibility studies — use the /sources_to_targets endpoint rather than batching individual /route calls. Valhalla’s matrix solver shares graph traversal state across pairs in the same request, reducing latency by 60–80% at matrix sizes above 20×20. For advanced matrix-based urban planning use cases, the Valhalla cost matrix generation for urban planners guide covers batching strategies and result post-processing.
Validation and Testing
After building tiles and starting the service, run these checks before promoting to production:
- Transit coverage check. Query a known transit corridor at a known departure time and verify that at least one leg has
travel_mode: "transit". Use a GTFS viewer (e.g. Transitland) to confirm which routes should be active at that time. - Walking-leg sanity. The first and last legs of a multi-modal itinerary are always walking. Verify that their distances are within your
transit_start_end_max_distancebound. - Date boundary test. Query with a date one day before and one day after your GTFS
calendar.txtdate range. Both should return fallback pedestrian-only routes rather than errors. - Coordinate precision test. Submit coordinates rounded to 3 decimal places and confirm the engine falls back gracefully (or raises a clear error) rather than silently matching the wrong edge.
- Concurrent load test. Issue 20 simultaneous
/routerequests and monitor/statusfor thread pool queue depth. Ifservice_threads< 8 and queues build up, increase the setting and restart. - Regression snapshot. Serialize a representative set of origin-destination pairs with expected durations to a JSON fixture. Re-run after every GTFS feed update and flag changes above 10% as requiring manual review.
Troubleshooting
Transit legs are absent from the multi-modal response
Root causes:
calendar.txtdate range does not include the query date. Check withgrepon thestart_dateandend_datecolumns.agency_timezoneinagency.txtdoes not match the timezone of the OSM extract’s centroid. Off-by-one-hour errors produce this symptom only during daylight-saving transitions.- GTFS archives were placed after
valhalla_build_transitran. The transit supergraph must be rebuilt whenever feed files change.
Fix: Re-validate feeds with gtfs-validator, confirm timezone alignment, then re-run valhalla_build_transit followed by valhalla_build_tiles.
`valhalla_build_tiles` exits with OOM or is killed by the kernel
Root cause: Non-linear memory growth when processing large .pbf extracts in a single pass.
Fix: Reduce mjolnir.max_concurrent_reader_users to 1. If that is insufficient, split the OSM extract into sub-bounding-boxes using osmium extract --bbox and process each independently. Increase swap to at least 2× physical RAM as a safety buffer.
valhalla.json changes are ignored after restart
Root cause: tile_dir, transit_dir, admin, or timezone keys were placed at the document root rather than inside the mjolnir object. Valhalla silently ignores root-level keys.
Fix: Confirm your JSON has { "mjolnir": { "tile_dir": "...", "transit_dir": "..." } } and not { "tile_dir": "...", "transit_dir": "..." } at the top level. Use python3 -m json.tool valhalla.json to detect structural errors.
High `/route` latency under concurrent load
Root cause: Thread pool exhaustion or excessive max_timedep_minutes expanding the temporal search space.
Fix: Increase httpd.service_threads (default 1) to match your CPU core count. Lower service_limits.max_timedep_minutes to 720 for urban-focused deployments. Monitor the /status endpoint for per-thread queue depth. If vm.max_map_count is not set to 262144, tile unloading under concurrent memory pressure also produces latency spikes.
Python client raises timeout errors intermittently
Root cause: Tile unloading under memory pressure causes cold-path remapping, adding 200–800 ms per affected query.
Fix: Verify vm.max_map_count=262144 and ensure the tile directory is on a fast local SSD (not NFS). If you are running inside Docker, add --ulimit nofile=65536:65536 to the container run command. Increase the client timeout to 30 s while investigating rather than masking the root cause with aggressive retries.
Related
- Valhalla cost matrix generation for urban planners — batch origin-destination travel-time matrices using the
/sources_to_targetsendpoint - Deploying OSRM with Docker for local routing — containerised OSRM setup to compare against Valhalla’s tile architecture
- NetworkX shortest-path algorithms for logistics — apply graph-based heuristics and domain constraints on top of Valhalla route outputs
- Implementing multi-modal transit layers in OSM graph architecture — how GTFS stop nodes are integrated into the OSM directed graph at the data model level
- Generating isochrones with PySAL and GeoPandas — accessibility polygon generation that can consume Valhalla’s
/isochroneGeoJSON output