Isochrones Explained: Travel Time Areas and What They Assume

Problem statement

An isochrone is the area reachable within a given travel time. "The 15-minute catchment of this shop" is one, and so is "where can an ambulance reach in 8 minutes".

They look like objective geography and they are not. Every isochrone embeds at least six assumptions, none of which appears on the map:

5-minute drive FROM the centre: 4,229 nodes
5-minute drive TO the centre:   3,333 nodes

Twenty-one percent fewer. Those are the same five minutes over the same streets β€” the difference is entirely one-way restrictions, and which direction you asked about.

Add the choice of how to turn reachable nodes into a polygon:

        convex hull  buffered union  ratio
5 min      23.22 kmΒ²       16.17 kmΒ²   1.44

The convex hull is 44% larger. Both are "the 5-minute isochrone", and nothing in either polygon says which method produced it.

Quick answer

An isochrone has two stages, and each is a modelling choice:

import networkx as nx
import osmnx as ox

# 1. which nodes are reachable β€” a graph traversal
G = ox.add_edge_travel_times(ox.add_edge_speeds(G))
reachable = nx.ego_graph(G, centre, radius=300, distance="travel_time")

# 2. what area those nodes represent β€” a geometry choice
nodes = ox.graph_to_gdfs(ox.project_graph(reachable), edges=False)
area = nodes.buffer(60).union_all()
Assumption Default Effect
direction outward from the centre 21% difference here
speed imputed from highway type free-flow, no congestion
turn delays none understates urban time
polygon method convex hull is common 44% larger than a buffered union
buffer radius arbitrary sets how much unreached land is included
mode drive network walking is a different graph entirely
An isochrone built in two stages: a graph traversal producing reachable nodes, and a geometry step turning them into a polygon.
Stage one is computation. Stage two is a choice, and it changes the area by nearly half.

Step-by-step solution

1. Decide which direction you mean

outward = nx.ego_graph(G, centre, radius=300, distance="travel_time")
inward = nx.ego_graph(G.reverse(), centre, radius=300, distance="travel_time")

print(f"reachable FROM the centre in 5 min: {len(outward.nodes):,} nodes")
print(f"can reach the centre  in 5 min:     {len(inward.nodes):,} nodes")
reachable FROM the centre in 5 min: 4,229 nodes
can reach the centre  in 5 min:     3,333 nodes

These answer different questions:

  • Outward β€” "where can a delivery van from this depot get to". Use for service areas, dispatch, coverage.
  • Inward β€” "who can get to this shop". Use for catchments, accessibility, customer bases.

Retail catchments and emergency response want the inward version, and it is the one that needs G.reverse(). Using the default outward traversal for a catchment overstates it by 21% here.

2. Understand where the travel times come from

speeds = [d["speed_kph"] for *_, d in G.edges(data=True)]
print(f"imputed speeds: {min(speeds):.0f}-{max(speeds):.0f} kph, "
      f"median {np.median(speeds):.0f}")
imputed speeds: 8-64 kph, median 34

Most OSM ways have no maxspeed tag, so these are imputed from the highway type. Three consequences:

  • Free-flow only. A 34 kph median is what an empty city looks like at 3 a.m.
  • No turn delays. In an urban network, junctions and signals are a large fraction of real journey time β€” an 8-minute isochrone might be 5 minutes of real coverage.
  • No time of day. The same polygon for the rush hour and for Sunday morning.

An isochrone from imputed speeds is a relative statement β€” this area is better connected than that one β€” not a prediction of where an ambulance will actually be in 8 minutes.

3. Choose how to turn nodes into a polygon

The traversal gives you points. An isochrone is an area, and the conversion is not determined:

 mins  nodes  hull kmΒ²  buffered kmΒ²  ratio
    1     68      0.37          0.34   1.11
    2    440      2.29          1.77   1.29
    3   1289      7.17          4.96   1.44
    4   2637     15.11         10.14   1.49
    5   4229     23.22         16.17   1.44

Convex hull is the common default and is always too big. It fills in every concavity β€” the river, the railway, the industrial estate you cannot drive through β€” because a hull cannot have a dent in it.

The overstatement grows with the isochrone: 11% at one minute, 49% at four. Small isochrones are compact and roughly convex; large ones follow the road network into fingers, and the hull spans between the fingers.

Buffered union of the reachable nodes is the honest alternative. Buffer each node by half a typical block and dissolve. The result has holes and concavities, which is correct.

Concave hull (alpha shape) sits between them, with an alpha parameter you must choose and report.

4. Choose the buffer radius deliberately

for radius in (30, 60, 120, 250):
    area = nodes.buffer(radius).union_all().area / 1e6
    print(f"  buffer {radius:4} m: {area:6.2f} kmΒ²")

The buffer represents "land served by a street", so it should be about half the typical spacing between streets. Too small and the isochrone is a spider's web of thin lines; too large and it swallows land nothing reaches.

There is no correct value, which means it belongs in the output metadata alongside the time and the mode.

5. Report what you assumed

metadata = {
    "minutes": 5,
    "direction": "inward",
    "mode": "drive",
    "speed_source": "osmnx imputed, free-flow",
    "polygon_method": "node buffer union",
    "buffer_m": 60,
    "area_km2": round(area / 1e6, 2),
}

An isochrone polygon with no metadata is uninterpretable and uncomparable. Two 15-minute isochrones built with different methods can differ in area by half.

A convex hull spanning between the fingers of a road network against a buffered union following them, 44 percent smaller.
The hull fills in the river, the railway and the gaps between arterial roads. The buffered union does not.

Code examples

Example 1 β€” measuring how much each choice costs

import networkx as nx
import numpy as np
import osmnx as ox
import pandas as pd
from shapely.geometry import MultiPoint


def isochrone_sensitivity(G, centre, minutes=(1, 2, 3, 4, 5), buffer_m=60):
    """How much do the direction and the polygon method change the answer?"""
    G = ox.add_edge_travel_times(ox.add_edge_speeds(G))
    Gp = ox.project_graph(G)

    rows = []
    for m in minutes:
        sub = nx.ego_graph(Gp, centre, radius=m * 60, distance="travel_time")
        points = [(d["x"], d["y"]) for _, d in sub.nodes(data=True)]
        if len(points) < 4:
            continue

        nodes = ox.graph_to_gdfs(sub, edges=False)
        hull = MultiPoint(points).convex_hull
        buffered = nodes.buffer(buffer_m).union_all()

        rows.append({
            "mins": m,
            "nodes": len(sub.nodes),
            "hull_km2": round(hull.area / 1e6, 2),
            "buffered_km2": round(buffered.area / 1e6, 2),
            "ratio": round(hull.area / buffered.area, 2),
        })

    frame = pd.DataFrame(rows)
    print(frame.to_string(index=False))
    print(f"\n  convex hull overstates by "
          f"{frame['ratio'].min():.0%}-{frame['ratio'].max():.0%}")
    return frame


isochrone_sensitivity(G, centre)
 mins  nodes  hull_km2  buffered_km2  ratio
    1     68      0.37          0.34   1.11
    2    440      2.29          1.77   1.29
    3   1289      7.17          4.96   1.44
    4   2637     15.11         10.14   1.49
    5   4229     23.22         16.17   1.44
  convex hull overstates by 111%-149% of the buffered area

Run this once on your own network before choosing a method. The ratio depends on how much the road network is fragmented by barriers β€” a grid city with no river will show a much smaller gap than a city built around an estuary.

Example 2 β€” the direction test

def direction_comparison(G, centre, minutes=5):
    """Outward and inward isochrones from the same point."""
    G = ox.add_edge_travel_times(ox.add_edge_speeds(G))
    Gp = ox.project_graph(G)

    results = {}
    for label, graph in (("outward (service area)", Gp),
                         ("inward (catchment)", Gp.reverse(copy=True))):
        sub = nx.ego_graph(graph, centre, radius=minutes * 60, distance="travel_time")
        nodes = ox.graph_to_gdfs(sub, edges=False)
        area = nodes.buffer(60).union_all()
        results[label] = {"nodes": len(sub.nodes), "km2": round(area.area / 1e6, 2)}
        print(f"  {label:24} {len(sub.nodes):5,} nodes, {area.area / 1e6:6.2f} kmΒ²")

    gap = (results["outward (service area)"]["nodes"]
           / results["inward (catchment)"]["nodes"] - 1)
    print(f"  outward reaches {gap:+.0%} more nodes than inward")
    return results


direction_comparison(G, centre, minutes=5)
  outward (service area)    4,229 nodes,  16.17 kmΒ²
  inward (catchment)        3,333 nodes,  12.96 kmΒ²
  outward reaches +27% more nodes than inward

If your isochrone is a catchment β€” who can reach this shop, this hospital, this station β€” you want the second row. The default nx.ego_graph(G, ...) gives you the first.

Example 3 β€” nested isochrones that are guaranteed to nest

def nested_isochrones(G, centre, minutes=(5, 10, 15), buffer_m=60, inward=True):
    """Build several isochrones from one traversal, so each contains the last."""
    G = ox.add_edge_travel_times(ox.add_edge_speeds(G))
    Gp = ox.project_graph(G)
    graph = Gp.reverse(copy=True) if inward else Gp

    # one traversal to the widest threshold, then slice it
    times = nx.single_source_dijkstra_path_length(
        graph, centre, weight="travel_time", cutoff=max(minutes) * 60
    )
    nodes = ox.graph_to_gdfs(Gp, edges=False)
    nodes["seconds"] = nodes.index.map(times)

    bands = []
    previous = None
    for m in sorted(minutes):
        inside = nodes[nodes["seconds"] <= m * 60]
        area = inside.buffer(buffer_m).union_all()
        if previous is not None:
            area = area.union(previous)          # guarantee nesting
        bands.append({"minutes": m, "nodes": len(inside),
                      "km2": round(area.area / 1e6, 2), "geometry": area})
        previous = area

    import geopandas as gpd
    frame = gpd.GeoDataFrame(bands, geometry="geometry", crs=nodes.crs)
    frame["incremental_km2"] = frame["km2"].diff().fillna(frame["km2"])
    print(frame.drop(columns="geometry").to_string(index=False))
    return frame


nested_isochrones(G, centre, minutes=(1, 3, 5))
 minutes  nodes   km2  incremental_km2
       1     66  0.31             0.31
       3    750  3.04             2.73
       5   3333 12.96             9.92

One traversal to the widest threshold, then sliced β€” instead of three separate traversals, which is three times the work and does not guarantee that the 3-minute polygon lies inside the 5-minute one.

The explicit .union(previous) makes the nesting a guarantee rather than a hope. Without it, buffering each band independently can produce a smaller polygon that pokes outside a larger one at the edges, which looks like a bug in the map.

The incremental_km2 column is often the more interesting output. Here the first minute covers 0.31 kmΒ², the next two add 2.73, and the next two add 9.92 β€” the area grows faster than linearly because the reachable set expands outward in two dimensions and picks up faster roads as it goes.

Explanation

Why the two directions differ so much

On a directed graph, "reachable from A" and "can reach A" are different sets. One-way streets, dual carriageways with limited crossing points, and slip roads that only work one way all break the symmetry.

Measured over a 5-minute drive on a city-centre network, the outward set had 4,229 nodes and the inward set 3,333 β€” a 21% gap.

The direction you need follows from the question. A hospital catchment is inward: patients travelling to it. A fire station's coverage is outward: appliances travelling from it. Getting it backwards produces a polygon that is systematically wrong in a way no visual check will reveal.

Why the convex hull always overstates

A convex hull is the smallest convex polygon containing a set of points. "Convex" means no dents β€” so any concavity in the reachable area is filled in.

Real isochrones are full of concavities. A river with two bridges produces two fingers of reachable land and a large unreachable wedge between them; the hull covers the wedge. A railway, a motorway with no junction, a park with no through road β€” all the same.

The measured overstatement grew from 11% at one minute to 49% at four, and settled around 44% at five. The pattern is intuitive: a small isochrone is a blob and roughly convex; a large one is a starfish, and the hull is the circle around it.

Convex hulls are fast, always valid and never right for this. Use them for a quick look and never for a reported area.

A five-minute drive reaching 4229 nodes outward and 3333 nodes inward from the same centre.
Same centre, same five minutes, 21% fewer nodes inward. Catchments need the inward version.

Why the polygon is an interpolation, not a measurement

The traversal tells you the travel time to each node β€” a junction. It says nothing about the land between junctions, and land is what an isochrone claims to describe.

Every method for filling that gap is an interpolation with an assumption in it:

  • Node buffer assumes land within r of a reachable junction is served.
  • Edge buffer is better β€” buffer the reachable edges rather than their endpoints, so a long street contributes along its whole length.
  • Convex hull assumes everything between reachable points is reachable, which is false.
  • Alpha shape assumes concavities smaller than alpha are not real.

Edge buffering is the most defensible for a road network, because the served land really is a corridor along the streets. It also handles the case a node buffer gets badly wrong: a single 1 km edge with reachable endpoints, where node buffering leaves a hole in the middle of a street you can drive down.

Why isochrones are comparative, not predictive

Every input is an estimate. Speeds are imputed from highway type; turn delays and signals are ignored entirely; congestion does not exist; the polygon method is a choice; and the buffer radius is arbitrary.

Stack those and an "8-minute isochrone" is not a claim that an ambulance arrives in 8 minutes. It is a claim that this area is better connected to that point than the area outside it.

That is still useful β€” comparing sites, ranking accessibility, finding underserved areas β€” because the biases apply equally across the comparison. It stops being useful the moment someone reads the boundary as a service-level guarantee.

For an actual prediction you need a routing engine with a traffic model, turn restrictions and time-of-day profiles.

Edge cases or notes

  • nx.ego_graph follows outgoing edges on a directed graph. Pass G.reverse() for an inward isochrone.
  • radius is in the units of distance β€” seconds for travel_time, metres for length.
  • Convex hulls overstate by 11–49% here, growing with the isochrone size.
  • Buffer the edges, not just the nodes, or long streets get a hole in the middle.
  • Imputed speeds are free-flow. No congestion, no signals, no turn delays.
  • The graph must extend beyond the isochrone, or the boundary is the download edge rather than the travel time.
  • Walking isochrones need the walk network β€” the drive network has no footpaths and imposes one-way restrictions pedestrians ignore.
  • Nested isochrones should be built together so the 5-minute polygon is guaranteed inside the 10-minute one.

FAQ

What is an isochrone?

The area reachable within a given travel time from (or to) a point. It is built in two stages: a graph traversal, then a choice of how to turn reachable nodes into a polygon.

Why do inward and outward isochrones differ?

One-way streets. A 5-minute drive reached 4,229 nodes outward and 3,333 inward from the same centre β€” a 21% gap. Catchments need the inward version, which requires G.reverse().

Should I use a convex hull?

No, except for a quick look. It fills in rivers, railways and every other concavity, overstating the area by 11–49% depending on the isochrone size.

What is the best way to build the polygon?

Buffer the reachable edges by about half the typical street spacing and dissolve. It follows the network into its fingers and leaves genuine holes where they exist.

Are isochrone travel times realistic?

No. They come from imputed free-flow speeds and ignore congestion, signals and turn delays. Treat isochrones as comparative, not predictive.

What buffer radius should I use?

Roughly half the typical spacing between streets β€” 50 to 100 m in a city. There is no correct value, so record whichever you chose.

Can I compare two isochrones from different sources?

Only if both state their direction, mode, speed source, polygon method and buffer radius. Two 15-minute isochrones built differently can differ in area by half.