Valhalla will compute an isochrone for you, against the same tiles and costing model that serve its routes. For service-area work that is usually the right choice: the polygon is consistent with the routes the same engine returns, and the whole computation happens in one request. This page covers using the endpoint well, as part of Valhalla configuration for multi-modal analysis within Python Routing Engines & Isochrone Mapping.

The parameters that matter are not the obvious ones. Contour definition is straightforward; denoise and generalize quietly determine whether the result is usable, and the difference between polygons and lines determines whether it can be used for area work at all.

When to use this approach

Prefer the engine endpoint when the isochrone needs to agree with the engine’s routes — service-area definition, coverage reporting, anything a customer will compare against a quoted travel time. Consistency between the two is worth more than control over the polygonisation.

Prefer a local pipeline, per generating isochrones with PySAL and GeoPandas, when the polygonisation itself is the subject — comparing methods, applying a custom cost surface, or needing the reachable node set rather than only its outline.

What denoise and generalize do to the output At denoise zero the contour retains many small disconnected fragments. At denoise 0.4 the fragments are dropped and one clean region remains. At generalize 500 the boundary is simplified so heavily that it no longer follows the street pattern. The same 15-minute contour under three settings denoise 0.0 speckle — every fragment retained denoise 0.4, generalize 50 one region, boundary still meaningful generalize 500 over-simplified — a rectangle, effectively Set generalize from the scale the polygon will be drawn at; a value tuned for a national overview is meaningless on a street map.

Implementation

The request is a single JSON body, and the shape of it is where most integration errors live.

# requires: httpx (pip install httpx)
import httpx

VALHALLA = "http://valhalla:8002"


def service_area(lon: float, lat: float, minutes: list[int],
                 *, costing: str = "truck", denoise: float = 0.4,
                 generalize: float = 50.0) -> dict:
    """Nested service-area polygons for one origin."""
    body = {
        "locations": [{"lon": lon, "lat": lat}],
        "costing": costing,
        # Time OR distance — never both in one request
        "contours": [{"time": m} for m in sorted(minutes)],
        "polygons": True,        # False returns lines, useless for area work
        "denoise": denoise,      # drop fragments below this share of the largest
        "generalize": generalize,  # Douglas-Peucker tolerance in metres
        "show_locations": True,
    }
    r = httpx.post(f"{VALHALLA}/isochrone", json=body, timeout=60.0)
    r.raise_for_status()
    return r.json()

polygons: True is the setting people miss. The default returns LineStrings, which render acceptably and cannot be used for any area or containment operation — a spatial join against them silently matches nothing, which looks like an empty result rather than an error.

Sorting the contour list is a small defence. Valhalla returns features in its own order and mixing unsorted inputs with an assumption about output order is a common source of mislabelled bands.

The response is a GeoJSON FeatureCollection whose features are ordered outermost first, which is the opposite of what most consumers assume:

# requires: geopandas, shapely (pip install geopandas shapely)
import geopandas as gpd
from shapely.geometry import shape


def to_frame(response: dict, crs_metric: str = "EPSG:3035") -> gpd.GeoDataFrame:
    """Parse the FeatureCollection into a metric frame with true band areas."""
    rows = []
    for feat in response["features"]:
        props = feat.get("properties", {})
        if "contour" not in props:
            continue                      # skip the location marker feature
        rows.append({"minutes": float(props["contour"]),
                     "geometry": shape(feat["geometry"])})

    gdf = gpd.GeoDataFrame(rows, crs="EPSG:4326").to_crs(crs_metric)
    gdf = gdf.sort_values("minutes").reset_index(drop=True)

    # Contours are nested, so a band is a ring rather than the whole polygon
    inner = gdf.geometry.shift(1)
    gdf["band_geometry"] = [
        g if prev is None else g.difference(prev)
        for g, prev in zip(gdf.geometry, inner)
    ]
    gdf["band_area_km2"] = (gdf["band_geometry"].area / 1e6).round(3)
    return gdf

The nesting is the detail that produces wrong numbers when missed. A 5, 10 and 15 minute request returns three polygons where each contains the smaller ones, so summing their areas triple-counts the innermost. Differencing successive contours gives the ring that actually represents “reachable between 10 and 15 minutes”.

Reprojecting before computing area is the usual requirement — a .area call on EPSG:4326 geometry returns square degrees, which vary with latitude and mean nothing.

It is worth being deliberate about the denoise and generalize parameters rather than accepting whatever the defaults produce, because they trade fidelity for vertex count and the right trade differs by use. A polygon destined for a web map benefits from aggressive generalization: the viewer cannot resolve ten-metre detail and the payload shrinks by an order of magnitude. A polygon destined for zonal statistics does not, because generalization moves the boundary across whichever population cells happen to sit near it, and the resulting area error is systematic rather than random. Requesting the geometry twice with different settings costs one extra call and avoids the temptation to reuse a display polygon for analysis.

Equally, treat the isochrone as a snapshot of one departure rather than a property of the location. Two requests from the same origin an hour apart will differ, sometimes substantially, and for transit costing they will differ enormously. Storing the departure timestamp alongside the geometry is what makes a later comparison meaningful.

Key parameters and tuning

Parameter Recommended value Notes
polygons True The default returns lines, which cannot be used for area or containment
contours time or distance, not both Mixing them returns an error
denoise 0.3–0.5 Below 0.2 the output speckles; above 0.6 real satellite regions are dropped
generalize 50 m street scale, 500 m regional Set from the drawing scale, not from file size
costing match the fleet A truck service area computed with auto costing overstates reach
date_time set for transit Without it a multimodal isochrone uses an unspecified departure
Area CRS equal-area projection Degrees are not an area unit

Integration points

Against a local pipeline. Comparing Valhalla’s polygon with one built locally from the same origin is a useful cross-check on both, but the two will differ by several percent for structural reasons covered in comparing alpha shapes and concave hulls. Treat a large divergence as a signal and a small one as expected.

Into accessibility scoring. The band geometries feed zonal statistics the same way a locally built polygon does — see population-weighted accessibility scoring. Using the differenced bands rather than the nested contours is what keeps the population counts additive.

With multimodal costing. For transit isochrones the departure time is as consequential as it is for routing, because service frequency dominates reach. The parameters are the same ones covered in Valhalla configuration for multi-modal analysis, and omitting date_time produces an isochrone for an unspecified moment.

Nested contours against differenced bands The endpoint returns three nested polygons, each containing the smaller ones. Summing their areas counts the innermost region three times. Differencing successive contours produces rings whose areas are additive. The response is nested; the numbers you want are not as returned — nested polygons 15 min: 24.1 km² 10 min: 11.2 km² 5 min: 3.1 km² sum = 38.4 km², which is wrong after differencing — rings 10–15: 12.9 km² 5–10: 8.1 km² 0–5: 3.1 km² sum = 24.1 km², matching the outer contour The check is simple: the differenced bands must sum to the outermost contour's area. If they do not, the nesting was not undone. Generalize against response size Leaving generalize at zero returns 4.1 megabytes for three contours. Fifty metres reduces that to 210 kilobytes with the boundary still following the street pattern; five hundred reaches 31 kilobytes but loses the shape entirely. Response size by generalize setting, 3 contours generalize 0 4.1 MB — every vertex generalize 50 210 KB generalize 200 84 KB generalize 500 31 KB — shape is lost The setting is a map-scale decision that happens to control payload size — choose it from how the polygon will be drawn, not from a transfer budget. A value tuned for a national overview produces a boundary that is visibly wrong when someone zooms into a street.

Validation checklist

  • Bands sum to the outer contour. The differenced areas must total the largest polygon’s area to within rounding.
  • The response contains polygons, not lines. Assert the geometry type. A LineString result means polygons was left at its default.
  • Areas are computed in a projected CRS. Assert the frame’s CRS is projected before any .area call.
  • The origin falls inside the smallest contour. A trivial containment check that catches transposed coordinates immediately.
  • Denoise is not dropping real regions. Compare feature counts at your setting and at zero. A large difference means genuine disconnected service areas — an island, a valley — may be being discarded.