Containerizing the OSRM routing engine eliminates third-party API rate limits, keeps sensitive logistics data on-premise, and delivers sub-millisecond route responses for batch geospatial workloads. This page is part of the Python Routing Engines & Isochrone Mapping guide and covers the complete pipeline from raw OSM data to a production-ready HTTP daemon: downloading a PBF extract, preprocessing it into a directed graph, tuning the Docker Compose service, and validating endpoints programmatically. Whether you are building fleet dispatch systems, accessibility models, or custom cost functions for routing solvers, a locally hosted OSRM instance gives you full control over routing logic and data freshness.
Prerequisites
Confirm these resources before starting. The preprocessing steps are memory-intensive and will abort silently if swap is exhausted.
System requirements
| Resource | Minimum | Recommended |
|---|---|---|
| Docker Engine | 20.10 | Latest stable |
| Docker Compose | v2 | v2.20+ |
| RAM | 8 GB | 16 GB+ for regional extracts |
| Storage | 2× PBF file size | NVMe SSD, 3× PBF size for workspace |
| CPU | 4 cores | 8+ cores reduces partition time |
Python environment
# Install client-side dependencies (Python 3.9+)
pip install requests geopandas pyproj shapely
OSM data source
Download a PBF extract from Geofabrik. Metropolitan-area extracts (100–400 MB) are ideal for development. Country-level extracts work in production but require proportionally more RAM for preprocessing.
Conceptual Architecture
OSRM achieves sub-millisecond routing through two algorithms built on a preprocessed directed graph derived from building directed graphs from OSM PBF files.
Contraction Hierarchies (CH) collapse the graph by adding shortcut edges between high-importance nodes, then query with a bidirectional Dijkstra that expands only the contracted hierarchy. This delivers the lowest possible single-route latency on a static graph but does not support dynamic weight updates.
Multi-Level Dijkstra (MLD) partitions the graph into nested cells, each with precomputed boundary weights. MLD is the preferred algorithm for production deployments because osrm-customize can re-apply new edge weights (for traffic overlays or time-of-day profiles) to an already-partitioned graph in minutes rather than hours. The integrating custom traffic weights into OSRM page covers the re-customization workflow in detail.
The three preprocessing binaries map to distinct build artifacts:
osrm-extractreads the PBF and applies a Lua profile to emit.osrm.edges,.osrm.nodes,.osrm.restrictions, and.osrm.turn_weight_penalties.osrm-partitiongroups nodes into hierarchical cells, producing.osrm.partitionand.osrm.cells.osrm-customizewalks the cell boundaries and stores final edge weights in.osrm.mldgr.
The Docker image bundles all three binaries alongside the standard Lua profiles (car.lua, bicycle.lua, foot.lua), so no local C++ toolchain is required.
Step-by-Step Implementation
1. Acquire the OSM PBF Extract
# Download a metropolitan-area extract for development
mkdir -p ~/osrm-data && cd ~/osrm-data
wget https://download.geofabrik.de/north-america/us/district-of-columbia-latest.osm.pbf
# Verify checksum (Geofabrik publishes .md5 alongside each file)
wget https://download.geofabrik.de/north-america/us/district-of-columbia-latest.osm.pbf.md5
md5sum -c district-of-columbia-latest.osm.pbf.md5
Routing accuracy depends on OSM tag completeness in the extract. Before preprocessing, use osmium command-line tools to inspect road network coverage and confirm that highway, maxspeed, and access tags are populated at the density your logistics profile requires.
2. Extract the Routing Graph
The car.lua profile maps OSM tags to directed edges, applies maxspeed values, and encodes oneway=yes restrictions. Run extraction inside the same image that will serve routes to guarantee binary compatibility:
docker run --rm -t \
-v "$(pwd):/data" \
osrm/osrm-backend \
osrm-extract -p /opt/car.lua /data/district-of-columbia-latest.osm.pbf
Expected output artifacts: district-of-columbia-latest.osrm, .osrm.edges, .osrm.nodes, .osrm.restrictions, .osrm.turn_weight_penalties, .osrm.names, .osrm.geometry, .osrm.datasource_names.
Extraction time scales roughly linearly with network density. A 200 MB metro PBF takes 2–5 minutes; a 3 GB country PBF can take 45–90 minutes. Plan preprocessing windows accordingly when scheduling nightly OSM refreshes.
3. Partition the Graph
osrm-partition performs recursive bisection of the network into a multi-level cell structure. This step is CPU-bound and benefits from high core counts:
docker run --rm -t \
-v "$(pwd):/data" \
osrm/osrm-backend \
osrm-partition /data/district-of-columbia-latest.osrm
New artifacts: .osrm.partition, .osrm.cells, .osrm.cell_metrics.
4. Customize Edge Weights
osrm-customize walks all cell boundary edges and computes the final weight matrix. This is the step you re-run when applying a traffic overlay without touching the partition:
docker run --rm -t \
-v "$(pwd):/data" \
osrm/osrm-backend \
osrm-customize /data/district-of-columbia-latest.osrm
New artifact: .osrm.mldgr (the customized metric graph).
5. Launch the Routing Daemon
docker run -d \
--name osrm-local \
-p 5000:5000 \
-v "$(pwd):/data" \
osrm/osrm-backend \
osrm-routed \
--algorithm mld \
--max-table-size 1000 \
--threads 4 \
/data/district-of-columbia-latest.osrm
Flag reference:
--algorithm mld— required to use the MLD artifacts; omit if you preprocessed with CH.--max-table-size 1000— caps the distance matrix endpoint at 1000×1000 to prevent OOM under load.--threads 4— match to physical CPU cores for optimal query concurrency; hyperthreading yields diminishing returns here.
Confirm the daemon is ready:
docker logs osrm-local | grep "running and waiting for requests"
curl -sf http://localhost:5000/health && echo "healthy"
6. Validate Endpoints with Python
# requires: requests (pip install requests)
import requests
import json
def validate_osrm(host: str = "http://localhost:5000") -> dict:
"""Query the OSRM route endpoint and return key metrics."""
# Coordinates for a short DC segment: [lon, lat]
coords = "-77.0365,38.8977;-77.0091,38.8895"
url = f"{host}/route/v1/driving/{coords}?geometries=geojson&overview=full&steps=false"
resp = requests.get(url, timeout=5)
resp.raise_for_status()
data = resp.json()
assert data["code"] == "Ok", f"Routing error: {data.get('message')}"
route = data["routes"][0]
print(f"Distance : {route['distance']:,.1f} m")
print(f"Duration : {route['duration']:,.1f} s")
print(f"Geometry : {route['geometry']['type']}")
return route
route = validate_osrm()
For teams building graph-aware optimization layers, this REST response can be parsed into adjacency matrices or fed into NetworkX shortest path algorithms for logistics for VRP decomposition or constraint-based heuristics.
Configuration Reference
Key osrm-routed Flags
| Flag | Default | Effect |
|---|---|---|
--algorithm |
ch |
mld enables dynamic weight updates; ch gives lower single-route latency |
--max-table-size |
100 | Maximum one-side dimension for /table requests; capped to prevent OOM |
--threads |
1 | Worker threads for concurrent HTTP requests; set to physical core count |
--max-viaroute-size |
500 | Maximum waypoints per /route request |
--max-matching-size |
100 | Maximum GPS trace points per /match request |
Lua Profile Tag Mapping (car.lua defaults)
| OSM Tag | Effect |
|---|---|
highway=motorway |
High speed, no pedestrian access |
highway=residential |
Low default speed (25 km/h) |
oneway=yes |
Edge direction enforced; reverse direction dropped |
access=private |
Edge excluded unless service=delivery override |
maxspeed=* |
Overrides profile default; parsed from numeric and tagged values |
turn:lanes=* |
Feeds turn restriction penalty logic |
Customizing the profile for freight requires editing the speed tables and access logic. The configuring edge weights for freight logistics page details hgv, weight, and maxweight tag handling for heavy vehicle routing.
Production Optimization and Scaling
Docker Compose for Persistent Deployments
Replace ad-hoc docker run commands with a declarative Compose file to enforce restart policies, resource limits, and volume isolation:
# docker-compose.yml
services:
osrm:
image: osrm/osrm-backend:latest
container_name: osrm-prod
restart: unless-stopped
ports:
- "5000:5000"
volumes:
- ./osrm-data:/data:ro
mem_limit: 16g
memswap_limit: 16g
command: >
osrm-routed
--algorithm mld
--threads 8
--max-table-size 500
/data/district-of-columbia-latest.osrm
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:5000/health"]
interval: 30s
timeout: 5s
retries: 3
Notes:
mem_limitandmemswap_limitapply to standalone Compose;deploy.resourcesonly takes effect in Swarm mode.- The
:rovolume flag prevents accidental graph file modification by a rogue container process. - Set
mem_limitto 1.2× the size of the preprocessed.osrm.*file set. The daemon memory-maps all files at startup.
Memory Sizing by Extract Scale
| Extract | Approximate RAM |
|---|---|
| Single metro area (city) | 2–6 GB |
| US state | 8–20 GB |
| Full USA | 32–64 GB |
| Western Europe | 64–128 GB |
For large extracts consider filtering the PBF to the highway types relevant to your fleet before preprocessing. osmium tags-filter can reduce a country PBF by 40–60% by retaining only highway=motorway, highway=trunk, highway=primary, and highway=secondary.
Zero-Downtime Graph Updates
OSRM does not support hot-swapping graph files at runtime. The update sequence is:
- Preprocess new
.osm.pbfinto a parallel directory (osrm-data-next/). - Start a second container (
osrm-next) pointing atosrm-data-next/. - Wait for the container health check to pass.
- Update the reverse proxy (Nginx or Traefik) upstream to point at
osrm-next. - Stop
osrm-prodand promoteosrm-next.
For teams running on cloud infrastructure, setting up OSRM on AWS EC2 with automated graph refresh covers EBS volume provisioning and the Instance Metadata Service pattern for IAM-authenticated S3 PBF downloads.
Validation and Testing
After deployment, run this validation matrix before routing traffic from production systems:
# requires: requests, geopandas, shapely (pip install requests geopandas shapely)
import requests
import geopandas as gpd
from shapely.geometry import LineString, shape
HOST = "http://localhost:5000"
def check_route_geometry(host: str) -> None:
"""Assert that a known short route returns a plausible LineString."""
coords = "-77.0365,38.8977;-77.0091,38.8895"
data = requests.get(
f"{host}/route/v1/driving/{coords}?geometries=geojson&overview=full",
timeout=5
).json()
assert data["code"] == "Ok"
geom = shape(data["routes"][0]["geometry"])
assert isinstance(geom, LineString), "Expected LineString geometry"
assert geom.length > 0, "Geometry has zero length"
print(f"[PASS] Route geometry: {geom.length:.6f} degrees")
def check_table_endpoint(host: str) -> None:
"""Assert that a 3×3 distance matrix returns numeric durations."""
coords = "-77.0365,38.8977;-77.0200,38.9000;-77.0091,38.8895"
data = requests.get(
f"{host}/table/v1/driving/{coords}?annotations=duration",
timeout=10
).json()
assert data["code"] == "Ok"
durations = data["durations"]
assert len(durations) == 3 and len(durations[0]) == 3
assert durations[0][0] == 0.0, "Self-route duration should be 0"
print(f"[PASS] Table endpoint 3×3: durations[0][1]={durations[0][1]:.1f}s")
def check_latency(host: str, max_ms: float = 50.0) -> None:
"""Assert sub-50 ms single-route latency (metro area, MLD algorithm)."""
import time
coords = "-77.0365,38.8977;-77.0091,38.8895"
start = time.perf_counter()
requests.get(f"{host}/route/v1/driving/{coords}", timeout=5)
elapsed_ms = (time.perf_counter() - start) * 1000
assert elapsed_ms < max_ms, f"Latency {elapsed_ms:.1f} ms exceeds {max_ms} ms budget"
print(f"[PASS] Latency: {elapsed_ms:.1f} ms")
check_route_geometry(HOST)
check_table_endpoint(HOST)
check_latency(HOST)
Sanity metrics to monitor in production:
- P99
/routelatency should stay below 20 ms for metro-area graphs with MLD. /tablerequests with more than 200 origins or destinations risk OOM; monitor container memory againstmem_limit.- Log
osrm-routedstartup time after each graph update; an increase of more than 30% signals the graph grew unexpectedly.
Troubleshooting
std::bad_alloc during osrm-extract
The host has insufficient RAM or swap for the PBF size. Options in order of preference: (1) increase host RAM, (2) add swap (fallocate -l 8G /swapfile && mkswap /swapfile && swapon /swapfile), (3) reduce extract scope by filtering the PBF with osmium extract to a smaller bounding box, (4) pass --memory-swap to the docker run call to allow container swap.
No route found between coordinates
The two points lie on disconnected graph components. Common causes: (1) the points snap to pedestrian paths that car.lua excludes, (2) a water body or missing bridge creates a barrier, (3) access=private tags exclude the road class in use. Debug by querying /nearest/v1/driving/{lon},{lat} for each point and inspecting the snapped node. If the snapping distance exceeds 50 m, the point is not on a routable road. Verify OSM tag coverage using graph fragmentation prevention techniques for OSM data.
High latency on /table requests
Three causes: (1) --max-table-size is set higher than memory supports, causing paging; (2) the container is running CH instead of MLD — matrix queries perform better under MLD; (3) thread contention from simultaneous /route and /table requests. Lower --max-table-size, confirm --algorithm mld, and consider a dedicated /table container if batch matrix jobs run alongside real-time routing.
Container exits immediately after starting
Most commonly, the .osrm file path inside the container does not exist. Confirm with docker run --rm -v "$(pwd):/data" osrm/osrm-backend ls /data that the expected .osrm.* files are present. A second cause is a version mismatch: if the PBF was preprocessed with one OSRM version and the daemon image is a different major version, the file format is incompatible. Re-preprocess using the same image tag as the daemon.
MLD and CH produce different routes for the same query
This is expected for some queries. MLD uses partition-constrained shortest paths, which can differ slightly from CH’s globally optimal shortcuts when the cell boundary cuts through the optimal path. For pure shortest-path correctness on a static graph, use CH. For dynamic-weight deployments where you need to call osrm-customize frequently, accept MLD’s minor path differences in exchange for update flexibility.
Related
- Integrating custom traffic weights into OSRM — re-customizing the MLD graph with speed and congestion overlays without reprocessing the full extract
- Step-by-step OSRM Docker setup on AWS EC2 — instance sizing, EBS volume provisioning, and IAM-authenticated S3 PBF downloads
- Custom cost functions for routing solvers — designing objective functions that consume OSRM duration matrices as inputs
- NetworkX shortest path algorithms for logistics — combining OSRM route data with graph-level constraint solving in Python
- Graph fragmentation prevention in OSM data — diagnosing and repairing disconnected network components before preprocessing