Computing Drive-Time Isochrones with Neo4j GDS
You need the set of every location a depot can reach within 10, 20, and 30 minutes of driving, drawn as nested drive-time bands. The symptom that brings people here is an isochrone that looks reasonable but is measurably wrong: a motorway corridor shows up as slow, a dense grid of side streets shows up as fast, and the 20-minute band barely differs from the 10-minute one. The root cause is almost always the projection — the graph was weighted on segment length instead of travel time, so Dijkstra minimised kilometres when it should have minimised seconds. This page gives one complete, runnable async pipeline that projects a travel-time-weighted graph, runs a single GDS single-source Dijkstra from the origin, and buckets every reachable node into drive-time bands in one pass. It is the concrete build behind the broader isochrone and service-area analysis technique.
Prerequisites & Versions
| Library / component | Min version | Install / note |
|---|---|---|
| Python | 3.10+ | list[dict] and union syntax used below |
| Neo4j | 5.13+ | Native point, id lookup index on the origin |
| Neo4j GDS | 2.6+ | gds.graph.project, gds.allShortestPaths.dijkstra |
| neo4j (driver) | 5.x | AsyncGraphDatabase, async sessions |
pip install "neo4j>=5.18"
The graph must already store a travel-time cost on each edge — travel_s in the model below — kept distinct from geometric length_m. Deriving that per-segment time from OSM speed tags is upstream work covered under node and edge spatial mapping; this page assumes it exists and is correct.
Implementation
The pipeline is one coroutine. It projects a graph that reads travel_s as the relationship weight, runs a single Dijkstra from the origin, keeps every target whose cumulative time is under the largest band ceiling, and assigns each surviving node to its drive-time band. Band edges are 600, 1200, and 1800 seconds — 10, 20, and 30 minutes.
import asyncio
from neo4j import AsyncGraphDatabase
BANDS_S = [600, 1200, 1800] # 10 / 20 / 30 minutes, in seconds
PROJECT = """
CALL gds.graph.project(
$graph,
'RoadNode',
{ CONNECTED_TO: { properties: 'travel_s', orientation: 'NATURAL' } }
)
YIELD graphName, nodeCount, relationshipCount
RETURN nodeCount, relationshipCount
"""
# Single-source Dijkstra weighted on travel time, truncated at the outer band,
# with each reachable node bucketed into its drive-time band in the same pass.
ISOCHRONE = """
MATCH (src:RoadNode {id: $source_id})
CALL gds.allShortestPaths.dijkstra.stream($graph, {
sourceNode: src,
relationshipWeightProperty: 'travel_s'
})
YIELD targetNode, totalCost
WITH gds.util.asNode(targetNode) AS n, totalCost
WHERE totalCost <= $outer
RETURN n.id AS node_id,
n.location.latitude AS lat,
n.location.longitude AS lon,
totalCost AS seconds,
CASE WHEN totalCost <= $b0 THEN 10
WHEN totalCost <= $b1 THEN 20
ELSE 30 END AS band_min
ORDER BY totalCost ASC
"""
async def drive_time_isochrone(driver, source_id: str,
graph: str = "iso_dt") -> dict[int, list[dict]]:
b0, b1, outer = BANDS_S
async with driver.session(database="neo4j") as session:
await session.run("CALL gds.graph.drop($g, false) YIELD graphName", g=graph)
await session.run(PROJECT, graph=graph)
try:
result = await session.run(
ISOCHRONE, graph=graph, source_id=source_id,
b0=b0, b1=b1, outer=outer,
)
bands: dict[int, list[dict]] = {10: [], 20: [], 30: []}
async for record in result:
row = record.data()
bands[row["band_min"]].append(row)
return bands
finally:
await session.run("CALL gds.graph.drop($g, false) YIELD graphName", g=graph)
async def main():
driver = AsyncGraphDatabase.driver(
"neo4j+s://your-cluster.databases.neo4j.io",
auth=("neo4j", "secure-password"),
max_connection_pool_size=32,
connection_acquisition_timeout=5.0,
)
try:
# Origin: a hub in central Amsterdam.
bands = await drive_time_isochrone(driver, source_id="hub-ams-01")
for minutes in (10, 20, 30):
print(f"{minutes:>2} min band: {len(bands[minutes])} nodes")
finally:
await driver.close()
if __name__ == "__main__":
asyncio.run(main())
How It Works
Three mechanics carry the pipeline, and each maps to a line in the code.
- Travel-time projection.
gds.graph.projectcopies the topology and thetravel_sproperty into an in-memory graph. PassingrelationshipWeightProperty: 'travel_s'to Dijkstra is what makes the search minimise time. If the projection listedlength_minstead, the identical query would compute a distance isochrone and mislabel it as drive-time — this one property choice decides whether the whole result is correct. - Single-source Dijkstra.
gds.allShortestPaths.dijkstra.streamsettles every reachable node exactly once, in ascending cost order, yielding atotalCostthat is the cheapest cumulative travel time from the source. One call produces the cost-to-arrive for the entire reachable component; there is no per-node query. - In-pass bucketing with a bound. The
WHERE totalCost <= $outerpredicate truncates the search at the 30-minute ceiling so nothing beyond the outer band is materialised, and theCASEexpression tags each surviving node with the tightest band it falls in. The client then groups the flat stream into the three band lists. Because the bound and the bucketing both run server-side, the driver only ever receives nodes that belong in the answer.
orientation: 'NATURAL' preserves one-way streets. The direction of CONNECTED_TO is the direction of legal travel, so the projection must keep it; flipping to UNDIRECTED would let the frontier drive against traffic and inflate every band. The deeper mechanics of weighted single-source search — heap behaviour, settling order, and tie-breaking — are covered in Weighted Dijkstra Routing with Neo4j GDS.
Common Failure Patterns
1. Projecting distance instead of travel time. The defining bug. If gds.graph.project reads length_m, the bands measure kilometres and every band boundary is meaningless as a drive time. The tell is that motorways look no faster than surface streets. Fix the projection to read the time property, and assert it before trusting output.
# WRONG — distance isochrone mislabelled as drive-time
{ "CONNECTED_TO": { "properties": "length_m" } }
# RIGHT — travel time drives the search
{ "CONNECTED_TO": { "properties": "travel_s", "orientation": "NATURAL" } }
2. Unbounded traversal with no cost cap. Dropping the WHERE totalCost <= $outer predicate does not just return extra rows — it makes GDS settle the entire reachable component and streams all of it to the client before anything is discarded, so a 30-minute request pays the cost of an all-pairs-from-source computation over the whole map. Always push the outer-band ceiling into the query.
YIELD targetNode, totalCost
WITH gds.util.asNode(targetNode) AS n, totalCost
WHERE totalCost <= $outer // truncate the frontier at the largest band — do not omit
3. Disconnected components at the origin. If the source node sits in a fragment that an ingestion gap severed from the main network, every band comes back nearly empty because the traversal exhausts its tiny component in a few hops. Verify connectivity at the origin before concluding the area is genuinely small:
MATCH (src:RoadNode {id: $source_id})-[:CONNECTED_TO]-(nbr)
RETURN count(nbr) AS degree // a degree of 0–1 at a supposed hub signals a topology break
Performance Notes
Two costs dominate: building the projection and running the search. Project once and reuse the named graph across many origins and budgets — re-projecting per request is the most common latency regression here. For the search itself, a truncated single-source Dijkstra visits only the nodes inside the outer band, so its work grows with the reachable subgraph, not the whole network. On a sphere of radius $R$, the straight-line area a band can cover is bounded by
$$A \le \pi (v_{\max},t)^2$$
for a top speed $v_{\max}$ and time budget $t$, but real road-network reachability is a fraction of that disc because edges are sparse and indirect — which is exactly why a truncated traversal is cheap: the node count in a 30-minute band on an urban graph is typically thousands, not the millions in the full projection. Keep GDS concurrency matched to cores, size projection memory with gds.graph.project.estimate before loading a country-scale network, and pin the band ceilings to fixed tiers so the query text — and its plan — stays stable. When you need the polygon rather than the node set, the boundary step is client-side and independent of these budgets, as described in isochrone and service-area analysis.
Related
- Isochrone and Service-Area Analysis — the reachability concept, boundary hulls, and query variants this pipeline implements.
- Weighted Dijkstra Routing with Neo4j GDS — the single-source weighted search internals in detail.
- Distance Filter Query Patterns — straight-line radius predicates, and why they are not drive-time bands.
- Network Routing Algorithms in Python — algorithm selection across Dijkstra, A*, and contraction hierarchies.
This guide is part of Isochrone and Service-Area Analysis, within the Cypher Spatial Queries & Pathfinding Patterns pillar.
For authoritative reference, consult the Neo4j GDS single-source shortest-path documentation.