How to Calculate 15-Minute Drive-Time Polygons in Python

This page solves one exact task: turning a single store coordinate into a validated 15-minute drive-time polygon in Python, using network routing rather than a straight-line buffer, so the geometry is safe to feed into downstream catchment analysis.

A 15-minute drive-time isochrone is the canonical retail catchment unit: it answers “who can physically reach this site in a typical short trip” far more honestly than a radius ever can. Two locations the same distance from a candidate site can sit on opposite sides of a river, a highway interchange, or a one-way grid, and only network routing captures that asymmetry. The output of this task is a single reachability polygon that later joins against demographics and rolls up into a site score.

Prerequisites

Before running this task you need:

  • Python packages: requests for the HTTP call, plus geopandas, shapely, and pandas to parse and repair the returned geometry. Install with pip install requests geopandas shapely pandas.
  • An OpenRouteService API key. The free tier is enough to develop against; production batch runs need a higher quota or a self-hosted engine (see the scaling note below).
  • A clean input coordinate. The store location must already be a valid WGS84 longitude/latitude pair. Coordinate hygiene is its own task — see data validation rules for store coordinates — but at minimum the point must fall on the routable network, not in open water or outside the routing graph’s coverage.
  • The parent configuration context. This page assumes you have read Configuring OpenRouteService for Drive-Time Maps, which covers profile selection, header authentication, and the request lifecycle in depth.

Configuration and Execution Parameters

The task uses the ORS /v2/isochrones/driving-car endpoint, which computes reachable areas over an OpenStreetMap routing graph. A strict 15-minute polygon is defined entirely by four payload fields plus the profile embedded in the URL path.

Parameter Value for this task Notes
profile (URL path) driving-car Car routing; swap for cycling-regular or foot-walking for multi-modal urban analysis.
locations [[lon, lat]] ORS expects [longitude, latitude], not lat/lon.
range_type "time" Selects a time-based isochrone (the alternative is "distance").
range [900] 900 seconds = 15 minutes. Pass a list even for a single contour.
units "m" Units for the area attribute, not the range.
attributes ["area", "reachfactor"] Returns catchment area and the ratio of reachable to theoretical area.
smoothing 0.5 0–1; higher values simplify jagged contours at the cost of fidelity.

Two configuration facts cause most failures and deserve emphasis:

  • Coordinate order. ORS strictly expects [longitude, latitude]. Swapping the pair is the single most common source of empty or ocean-bound polygons, because the routing engine snaps the malformed point to the nearest graph node — often nowhere near the intended site.
  • Header authentication. The API key is passed as the raw Authorization header value, not as Bearer <key>. A Bearer prefix returns a 403 that looks like an invalid-key error.

Annotated Implementation

The function below constructs the payload, handles HTTP and network exceptions with exponential backoff, repairs the returned geometry, and hands back a GeoDataFrame in EPSG:4326. A shared requests.Session is accepted so callers batching many sites can reuse a connection pool.

python
import requests
import geopandas as gpd
import pandas as pd
from shapely.geometry import shape
from shapely.validation import make_valid
import time
import logging
from typing import Optional

logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')


def fetch_15min_isochrone(
    api_key: str,
    longitude: float,
    latitude: float,
    retries: int = 3,
    timeout: int = 30,
    session: Optional[requests.Session] = None
) -> gpd.GeoDataFrame:
    """
    Calculate a 15-minute drive-time polygon using the OpenRouteService API.

    ORS expects [longitude, latitude] order in the 'locations' array.
    The API key is passed in the 'Authorization' header (not as a Bearer token).

    Returns:
        GeoDataFrame with the isochrone polygon in EPSG:4326.
    Raises:
        RuntimeError: if all retry attempts are exhausted.
    """
    url = "https://api.openrouteservice.org/v2/isochrones/driving-car"
    headers = {
        "Authorization": api_key,          # raw key, NOT "Bearer <key>"
        "Content-Type": "application/json"
    }
    payload = {
        "locations": [[longitude, latitude]],   # ORS: [lon, lat]
        "range_type": "time",
        "range": [900],                          # 900 seconds = 15 minutes
        "units": "m",
        "attributes": ["area", "reachfactor"],
        "smoothing": 0.5
    }

    client = session or requests.Session()

    for attempt in range(retries):
        try:
            response = client.post(url, json=payload, headers=headers, timeout=timeout)
            response.raise_for_status()
            geojson = response.json()

            # An empty FeatureCollection means the point did not snap to the graph.
            if "features" not in geojson or not geojson["features"]:
                raise ValueError("API returned an empty feature collection.")

            feature = geojson["features"][0]
            raw_geom = shape(feature["geometry"])
            valid_geom = make_valid(raw_geom)        # repair self-intersections

            gdf = gpd.GeoDataFrame(
                pd.DataFrame([feature.get("properties", {})]),
                geometry=[valid_geom],
                crs="EPSG:4326"                      # ORS always returns WGS84
            )
            logging.info(
                "Generated 15-minute isochrone for (%.6f, %.6f)", longitude, latitude
            )
            return gdf

        except requests.exceptions.HTTPError:
            status = response.status_code
            if status == 429:                        # rate limited: back off hard
                wait_time = (2 ** attempt) * 5
                logging.warning("Rate limit hit. Retrying in %ds...", wait_time)
                time.sleep(wait_time)
            elif status >= 500:                      # transient server error
                wait_time = (2 ** attempt) * 2
                logging.warning("Server error (%d). Retrying in %ds...", status, wait_time)
                time.sleep(wait_time)
            else:                                    # 4xx other than 429: fail fast
                logging.error("HTTP %d: %s", status, response.text)
                raise
        except requests.exceptions.RequestException as e:
            wait_time = (2 ** attempt) * 3
            logging.warning("Network error: %s. Retrying in %ds...", str(e), wait_time)
            time.sleep(wait_time)

    raise RuntimeError(f"Failed to fetch isochrone after {retries} attempts.")

A single call is then trivial:

python
gdf = fetch_15min_isochrone(api_key="YOUR_KEY", longitude=-73.985428, latitude=40.748817)
print(gdf[["value", "area", "reachfactor"]])
15-minute isochrone request lifecycle with retry branches A store coordinate is serialized as [lon, lat] and POSTed to the ORS isochrones endpoint with a raw Authorization header. The response branches four ways: a 200 with features proceeds to geometry repair; a 429 rate limit and a 5xx server error each wait with exponential backoff and retry; an empty feature collection or exhausted retries raises an error. A valid 200 runs make_valid() and returns a GeoDataFrame in EPSG:4326. Build payload locations [[lon, lat]] · range [900] · time POST /v2/isochrones/driving-car Authorization: raw key (no "Bearer") Inspect response 429 / 5xx Wait + backoff sleep 2^attempt · k retry empty / 4xx Raise error no snap / fail fast 200 + features Repair geometry shape() → make_valid() Return GeoDataFrame EPSG:4326 · validated polygon
Equal area, different people A circular buffer sized to match the drive-time polygon's area covers the same number of square kilometres but a different set of them: it includes land across a river with no crossing and misses the corridor the highway serves. The population inside differs by 19 per cent even though the areas are identical. Matching the area does not match the customers candidate site drive-time polygon reaches along the highway equal-area circle crosses a river with no bridge both cover 84 km² circle: 71,400 people polygon: 59,900 people the 19% gap is not noise — it is the households the circle counts and no car can reach

Failure Modes and Debugging

Symptom Cause Fix
Empty feature collection Point did not snap to the routing graph (offshore, outside coverage, or lat/lon swapped) Confirm [lon, lat] order; nudge the coordinate onto a road; widen the engine’s snap radius if self-hosting.
403 on a known-good key Bearer prefix on the Authorization header Pass the raw key string.
429 storm during batch runs Quota exceeded on the free tier The backoff above absorbs short bursts; for sustained volume add a caching layer (below) or self-host.
TopologyException downstream Self-intersecting ring returned by the router on dense or coastal grids Already handled here by shapely.validation.make_valid(), which splits invalid rings into a valid multi-polygon.
Distorted area / proximity results Measuring area in degrees on EPSG:4326 Reproject to a metric CRS before any area or buffer math (see Verification).

The geometry-repair step is not optional in production. Routing engines routinely emit topologically invalid polygons on complex urban grids and coastal routes; passing those straight into a spatial join raises TopologyException and aborts the run. make_valid() resolves them deterministically so the polygon is safe to consume.

Five checks that take a second and catch a wasted week Confirm the polygon is valid, that it contains its own origin, that its area falls inside a plausible band for the profile and time, that the coordinate reference system is the one expected, and that nested bands nest. Each has a clear failing example, and each failure has a distinct cause. Five assertions, five different upstream bugs assertion when it fails, the cause is geometry is valid smoothing produced a self-intersection contains its own origin latitude and longitude were swapped area within a plausible band the wrong profile, or minutes read as seconds CRS is the declared one a library returned WGS84 where metres were expected 10-minute band inside the 15 bands were requested separately, not together Run them in the function that returns the contour, not in a notebook afterwards — the point is that a bad polygon never leaves.

Verification

Confirm correct output before trusting the polygon downstream:

  1. Geometry validity: assert gdf.geometry.is_valid.all() — should pass after make_valid.
  2. Bounding-box sanity: the polygon’s centroid should land within a few hundred meters of the input coordinate. gdf.geometry.centroid far from the request point signals a snap failure.
  3. Reach factor: reachfactor near 1.0 means the contour fills most of its theoretical area (open road network); a low value flags barriers like water or sparse roads and is expected, not an error.
  4. Area, measured correctly: reproject before measuring, because area in WGS84 degrees is meaningless.
python
metric = gdf.to_crs(gdf.estimate_utm_crs())   # auto-pick the right UTM zone
sq_km = metric.geometry.area.iloc[0] / 1_000_000
print(f"15-min catchment: {sq_km:.2f} km^2")

Projecting to the local UTM zone removes WGS84 distortion and gives an honest catchment size — the same projection step every later acreage or buffer calculation depends on.

Scaling beyond a single point

For one site this synchronous call is fine. Across hundreds of candidate locations, serial requests become the bottleneck and you will hit rate limits. Two levers apply: cache identical-coordinate responses so repeat runs never re-hit the network — covered in caching strategies for repeated network queries — and move high-volume work to a self-hosted engine, covered in optimizing batch isochrone generation with OSRM, which removes the external dependency and stabilizes latency.

Frequently Asked Questions

What is a plausible area for a 15-minute car contour?

Somewhere between roughly 20 and 200 square kilometres for most markets, which is wide enough to be useless as a precise expectation and exactly right as a sanity band. A dense city centre with signals every block produces the low end; a rural site on a highway produces the high end and occasionally more. What the band catches is the order-of-magnitude error — seconds read as minutes, a walking profile applied by accident, a contour computed around the wrong point — and those are the failures that otherwise survive all the way to a committee pack.

Should the polygon be simplified before it is stored?

Store the engine’s output and simplify on the way out. The full-resolution contour is the measurement, and once it has been thinned it cannot be recovered; the simplified version is a delivery format whose tolerance depends on where it is going. Keeping both is cheap in a columnar store and it means a question about a boundary can be answered from the original rather than from an approximation nobody recorded the tolerance of.

How do I compute the population inside the polygon correctly?

Reproject both the contour and the demographic layer to an equal-area system, intersect, and apportion partially covered units by area rather than counting them whole. Counting a block group as inside because its centroid falls in the polygon is fast and wrong at the edge, where the largest units live; area-weighting is barely more work and is defensible. Whichever you choose, apply it consistently across every candidate, because a mixed method makes two sites’ reach figures incomparable.

← Back to Configuring OpenRouteService for Drive-Time Maps