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=ON and ENABLE_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 .pbf extract covering your operational region, sourced from Geofabrik or a custom osmium extract.
    • GTFS (General Transit Feed Specification) archives for every transit agency in scope. These must be current: expired calendar.txt dates cause transit edges to vanish without any build-time warning.
  • 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:

Valhalla Multi-Modal Tile Architecture Diagram showing OSM PBF and GTFS feeds flowing into Valhalla build tools that produce three tile levels and a transit supergraph, which are then queried by the Valhalla routing service. OSM .pbf road / ped / bike GTFS archives stop_times / trips valhalla_build_admins admin + timezone sqlite databases valhalla_build_transit schedule supergraph valhalla_build_tiles OSM + transit fused Tile Hierarchy (mjolnir.tile_dir) Level 0 — arterial (coarse) Level 1 — regional Level 2 — local (fine) Transit supergraph stop nodes + schedule edges

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:

  1. 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.
  2. 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_distance bound.
  3. Date boundary test. Query with a date one day before and one day after your GTFS calendar.txt date range. Both should return fallback pedestrian-only routes rather than errors.
  4. 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.
  5. Concurrent load test. Issue 20 simultaneous /route requests and monitor /status for thread pool queue depth. If service_threads < 8 and queues build up, increase the setting and restart.
  6. 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.txt date range does not include the query date. Check with grep on the start_date and end_date columns.
  • agency_timezone in agency.txt does 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_transit ran. 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.