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 |
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.
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.
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
alphaare 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_graphfollows outgoing edges on a directed graph. PassG.reverse()for an inward isochrone.radiusis in the units ofdistanceβ seconds fortravel_time, metres forlength.- 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.
Internal links
- How to generate isochrones in Python β the implementation with these choices made explicit
- Street networks as graphs explained β why direction matters
- How to build a street network graph in Python with OSMnx β the graph and its imputed speeds
- How to measure network distance for many origin-destination pairs β the same traversal, tabulated
- Network distance vs straight-line distance explained β why not just draw a circle
- How to calculate the shortest path along a street network β travel time as a routing weight
- How to create buffers in GeoPandas for spatial analysis β the geometry stage
- How to calculate a viewshed in Python β another product whose parameters decide the answer
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.