5.1. Network Fundamentals and Data Acquisition#

5.1.1. Learning objectives#

By the end of this section, you should be able to:

  • describe a spatial network as a graph whose nodes and edges have locations and attributes;

  • choose between directed, undirected, weighted, and multigraph models;

  • explain how direction and parallel edges change a routing answer;

  • choose a study-area method and the query parameters that shape a downloaded graph;

  • explain what simplification does and why it changes node and edge counts;

  • convert a graph to node and edge layers and read its identifiers and units; and

  • record the provenance of a downloaded network.

Section goal

Build a correct mental model of a street network before downloading one. A small synthetic network with known lengths shows how direction, simplification, and units behave, and the same steps then run on a live OpenStreetMap query.

5.1.2. What makes a network spatial?#

A network is a graph whose nodes and edges have geographic locations and attributes. For a street network, nodes are usually intersections and endpoints, while edges are street segments. The attributes carry costs and constraints such as length, speed, travel time, access, and direction. Table 5.2 defines the terms used throughout this chapter.

Table 5.2 Graph vocabulary and what each term means in a street network.#

Term

Meaning

In a street network

Node

A point where edges meet or end

An intersection or a dead end

Edge

A connection between two nodes

A street segment between intersections

Directed edge

A connection usable from u to v only

One side of a one-way street

Parallel edges

Several edges joining the same two nodes

A dual carriageway, or a service road beside a street

Degree

The number of edges that meet at a node

An intersection where four streets meet

Path

A sequence of edges joining two nodes

A route

Weight (cost)

A number on an edge that routing minimizes

Length or travel time

Connected component

A set of nodes that can all reach one another

A group of streets cut off from the rest

OSMnx’s street_count attribute counts the physical streets at a node, so it differs from the directed degree, which counts each direction separately.

OSMnx [Boeing, 2025] downloads OpenStreetMap features and builds NetworkX [Hagberg et al., 2008] graphs. A common result is a MultiDiGraph: direction matters and multiple edges can connect the same pair of nodes. That representation can model one-way streets and parallel carriageways, but the mode and graph type must match the question. Table 5.3 lists the choices.

Table 5.3 Graph model choices and when to use each.#

Graph choice

Use when

Directed

One-way or flow direction affects travel

Undirected

Direction is irrelevant or deliberately ignored

Weighted

Edges have a cost such as length or time

MultiGraph

Parallel connections must be retained

The toy network below has six nodes at real coordinates near Columbia, Missouri. One street between nodes 2 and 5 is one-way, and nodes 3 and 6 are joined by two parallel edges, a street and a service road. Edge lengths are computed from the coordinates, in metres, the way OSMnx computes them.

Hide code cell source

import networkx as nx
import osmnx as ox

coords = {  # node: (longitude, latitude)
    1: (-92.340, 38.950), 2: (-92.335, 38.950), 3: (-92.330, 38.950),
    4: (-92.340, 38.955), 5: (-92.335, 38.955), 6: (-92.330, 38.955),
}

toy = nx.MultiDiGraph(crs="EPSG:4326")
for node, (x, y) in coords.items():
    toy.add_node(node, x=x, y=y)


def add_street(u, v, oneway=False, highway="residential"):
    (x1, y1), (x2, y2) = coords[u], coords[v]
    length = ox.distance.great_circle(y1, x1, y2, x2)
    toy.add_edge(u, v, length=length, highway=highway, oneway=oneway)
    if not oneway:
        toy.add_edge(v, u, length=length, highway=highway, oneway=oneway)


for u, v in [(1, 2), (2, 3), (4, 5), (5, 6), (1, 4), (3, 6)]:
    add_street(u, v)
add_street(2, 5, oneway=True)                 # one-way: 2 -> 5 only
add_street(3, 6, highway="service")           # a parallel edge between 3 and 6

one_way_edges = sum(1 for *_, data in toy.edges(data=True) if data["oneway"])
print(f"{toy.number_of_nodes()} nodes, {toy.number_of_edges()} directed edges, "
      f"{one_way_edges} one-way, {toy.number_of_edges(3, 6)} parallel edges 3 -> 6")

print(f"route 2 -> 5: {nx.shortest_path_length(toy, 2, 5, weight='length'):,.0f} m")
print(f"route 5 -> 2: {nx.shortest_path_length(toy, 5, 2, weight='length'):,.0f} m "
      f"via {nx.shortest_path(toy, 5, 2, weight='length')}")

undirected = toy.to_undirected()
print(f"undirected {type(undirected).__name__}, route 5 -> 2: "
      f"{nx.shortest_path_length(undirected, 5, 2, weight='length'):,.0f} m")
6 nodes, 15 directed edges, 1 one-way, 2 parallel edges 3 -> 6
route 2 -> 5: 556 m
route 5 -> 2: 1,421 m via [5, 6, 3, 2]
undirected MultiGraph, route 5 -> 2: 556 m

Direction changes the answer. Going from node 2 to node 5 uses the one-way street and covers 556 m, but the return trip cannot use it and detours around the block for 1,421 m, about 2.6 times as far. The undirected copy ignores the restriction and reports 556 m in both directions, which is acceptable only when ignoring direction is the intent. The multigraph keeps both parallel edges between nodes 3 and 6, so a routing algorithm can choose between them, where a simple graph would silently keep only one.

5.1.3. Configure a small, repeatable query#

Network downloads use an external service [OpenStreetMap Contributors, 2026], and the query decides which graph comes back. Two choices matter most: how the study area is defined, and which parameters shape the graph. Table 5.4 lists the main ways to define the area.

Table 5.4 Ways to define the study area of an OSMnx query.#

Function

The area is defined by

Typical use

Caution

graph_from_place

A geocoded place name or boundary

A city or administrative unit

Roads just outside the boundary are excluded

graph_from_address

An address and a distance

The surroundings of a facility

The distance can be a box or a network distance

graph_from_point

A coordinate and a distance

A buffer around a known location

Same choice of box or network distance

graph_from_bbox

A bounding box

A rectangular study area

Streets are cut at the rectangle

graph_from_polygon

Any polygon

A custom or buffered study area

The polygon must be in longitude and latitude

Table 5.5 lists the parameters that change the graph itself.

Table 5.5 Query parameters that change the downloaded graph.#

Parameter

What it controls

Effect on the graph

network_type

Which ways and access rules are included

Different edges for driving, walking, or cycling

custom_filter

A custom OpenStreetMap filter that replaces network_type

Full control, and full responsibility for the result

simplify

Whether intermediate shape points become nodes

Far fewer nodes and edges when True

retain_all

Whether disconnected pieces are kept

When False, only the largest weakly connected component stays

truncate_by_edge

How edges crossing the boundary are treated

Keeps boundary-crossing edges when True

Simplification deserves a closer look because it changes every count. OpenStreetMap stores a street as a chain of nodes, including nodes that only mark a bend. A simplified graph keeps nodes at intersections and dead ends, and folds the bends into each edge: the edge stores the full shape as a geometry and the summed length. The toy chain below has two bend nodes between two endpoints.

Hide code cell source

bends = {1: (-92.340, 38.950), 2: (-92.338, 38.9505), 3: (-92.336, 38.9505), 4: (-92.334, 38.950)}

raw = nx.MultiDiGraph(crs="EPSG:4326")
for node, (x, y) in bends.items():
    raw.add_node(node, x=x, y=y)
for u, v in [(1, 2), (2, 3), (3, 4)]:
    (x1, y1), (x2, y2) = bends[u], bends[v]
    length = ox.distance.great_circle(y1, x1, y2, x2)
    for a, b in [(u, v), (v, u)]:
        raw.add_edge(a, b, length=length, highway="residential", osmid=100 + u, oneway=False)

simple = ox.simplify_graph(raw)
print(f"raw:        {raw.number_of_nodes()} nodes, {raw.number_of_edges()} edges")
print(f"simplified: {simple.number_of_nodes()} nodes, {simple.number_of_edges()} edges")

raw_length = sum(d["length"] for u, v, d in raw.edges(data=True) if u < v)
for u, v, d in simple.edges(data=True):
    if u < v:
        print(f"edge {u}-{v}: length {d['length']:.1f} m (raw chain {raw_length:.1f} m), "
              f"{d['geometry'].geom_type} with {len(d['geometry'].coords)} vertices")
raw:        4 nodes, 6 edges
simplified: 2 nodes, 2 edges
edge 1-4: length 536.3 m (raw chain 536.3 m), LineString with 4 vertices

Simplification removes the two bend nodes and the four edges they split, but not any length: the single edge keeps all four vertices of the shape and the full 536 m. Node and edge counts therefore depend on this one setting. A graph with simplify=False holds a node at every bend, which inflates the counts and makes metrics such as intersection density meaningless, so compare counts only between graphs built the same way.

Cache responses while developing and keep the place and network type explicit. The query below downloads the drivable network of Columbia, Missouri, as a graph in longitude and latitude:

Hide code cell source

from pathlib import Path

import osmnx as ox

ox.settings.use_cache = True
ox.settings.cache_folder = str(Path("osmnx_cache"))
if hasattr(ox.settings, "log_console"):
    ox.settings.log_console = False

place = "Columbia, Missouri, USA"
G = ox.graph_from_place(
    place,
    network_type="drive",
    simplify=True,
    retain_all=False,
)

print(G.number_of_nodes(), G.number_of_edges(), G.graph.get("crs"))
5766 13888 epsg:4326

The graph has 5,749 nodes and 13,854 directed edges, about 2.4 edges per node. Each two- way street contributes two directed edges, so the edge count is not a count of streets, and these numbers mean something only beside the query that produced them: drivable network, simplified, largest connected component.

Record the place or coordinates, query method, network type, retrieval date, and OSMnx version. A rerun can return a different graph because OpenStreetMap is edited continuously. OpenStreetMap data is licensed under the Open Database License [Open Data Commons, 2026, OpenStreetMap Contributors, 2026], so published results should credit “© OpenStreetMap contributors.”

Hide code cell source

import datetime

query_record = {
    "place": place,
    "method": "graph_from_place",
    "network_type": "drive",
    "simplify": True,
    "retain_all": False,
    "osmnx_version": ox.__version__,
    "retrieved": datetime.date.today().isoformat(),
    "nodes": G.number_of_nodes(),
    "edges": G.number_of_edges(),
}
print(query_record)
{'place': 'Columbia, Missouri, USA', 'method': 'graph_from_place', 'network_type': 'drive', 'simplify': True, 'retain_all': False, 'osmnx_version': '2.1.1', 'retrieved': '2026-10-25', 'nodes': 5766, 'edges': 13888}

5.1.4. Network types are analytical choices#

The network_type decides which OpenStreetMap ways enter the graph and which access rules apply, so it is part of the analytical question and not a technical default. Table 5.6 lists the common types.

Table 5.6 Network types, their typical use, and the assumption behind each.#

Network type

Typical use

Important assumption

drive

Vehicle routing

One-way and access restrictions matter

walk

Pedestrian accessibility

Paths and pedestrian links are included

bike

Cycling analysis

Bikeable links and restrictions matter

all

Exploratory infrastructure analysis

It may include links unsuitable for a mode

Do not compare node or edge counts across modes as if they were quality scores. The counts reflect different inclusion rules, coverage, and data completeness [Barrington-Leigh and Millard-Ball, 2017].

5.1.5. Convert a graph to spatial layers#

ox.graph_to_gdfs turns a graph into two GeoDataFrames, which are the bridge back to the tools of Chapter 4. Nodes are indexed by their osmid and carry x, y, and street_count. Edges are identified by the triple u, v, and key, which names the start node, the end node, and which of several parallel edges it is. In current OSMnx versions that triple is the index of the edge table, not a set of columns, so select it after reset_index(); asking for u, v, and key directly raises a KeyError.

Units need the same care as in Chapter 4. The length attribute is in metres, computed by OSMnx, whatever the CRS. The geometry of an unprojected graph is in degrees, so geometry-based lengths and areas need a projected graph first. The cell below shows all three on the toy network.

Hide code cell source

import warnings

nx.set_node_attributes(toy, ox.stats.count_streets_per_node(toy), "street_count")
nodes, edges = ox.graph_to_gdfs(toy)

print(nodes[["x", "y", "street_count", "geometry"]].head(3))
print()
print("edge index:", edges.index.names)
print(edges.reset_index()[["u", "v", "key", "length", "geometry"]].head(3))

projected = ox.project_graph(toy)
_, edges_m = ox.graph_to_gdfs(projected)
with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    degrees = edges.geometry.length.iloc[0]
print()
print(f"graph CRS: {toy.graph['crs']} -> {projected.graph['crs']}")
print(f"first edge, geometry length unprojected: {degrees:.4f} (degrees)")
print(f"first edge, geometry length projected:   {edges_m.geometry.length.iloc[0]:.1f} m")
print(f"first edge, length attribute:            {edges['length'].iloc[0]:.1f} m")
            x      y  street_count               geometry
osmid                                                    
1     -92.340  38.95             2   POINT (-92.34 38.95)
2     -92.335  38.95             3  POINT (-92.335 38.95)
3     -92.330  38.95             3   POINT (-92.33 38.95)

edge index: ['u', 'v', 'key']
   u  v  key      length                                  geometry
0  1  2    0  432.379220  LINESTRING (-92.34 38.95, -92.335 38.95)
1  1  4    0  555.975419  LINESTRING (-92.34 38.95, -92.34 38.955)
2  2  1    0  432.379220  LINESTRING (-92.335 38.95, -92.34 38.95)

graph CRS: EPSG:4326 -> EPSG:32615
first edge, geometry length unprojected: 0.0050 (degrees)
first edge, geometry length projected:   433.3 m
first edge, length attribute:            432.4 m

The two metric lengths differ slightly because the attribute is a great-circle distance between the end points and the projected geometry is measured on a plane. Use the length attribute for routing, and project the graph before any geometric measurement.

The same conversion on the Columbia graph, with the identifiers selected correctly, is:

Hide code cell source

nodes, edges = ox.graph_to_gdfs(G)

display(nodes[["x", "y", "street_count", "geometry"]].head())
display(edges.reset_index()[["u", "v", "key", "length", "geometry"]].head())
x y street_count geometry
osmid
93201793 -92.257014 38.960200 3 POINT (-92.25701 38.9602)
93201794 -92.249907 38.959998 3 POINT (-92.24991 38.96)
189092378 -92.271379 38.970326 3 POINT (-92.27138 38.97033)
189092380 -92.272582 38.970309 4 POINT (-92.27258 38.97031)
189092382 -92.273699 38.970326 4 POINT (-92.2737 38.97033)
u v key length geometry
0 93201793 415348196 0 273.551063 LINESTRING (-92.25701 38.9602, -92.25658 38.96...
1 93201793 93201794 0 615.061647 LINESTRING (-92.25701 38.9602, -92.25659 38.96...
2 189092378 189092380 0 104.044748 LINESTRING (-92.27138 38.97033, -92.27258 38.9...
3 189092378 189116732 0 131.430378 LINESTRING (-92.27138 38.97033, -92.27136 38.9...
4 189092378 189116734 0 220.398589 LINESTRING (-92.27138 38.97033, -92.27132 38.9...

The head rows show the shape of a simplified graph. Nodes are junctions with 3 or 4 streets, and an edge between neighbouring junctions can be 104 m or 615 m long because bends are folded into it. Node identifiers are large OpenStreetMap ids, so keep them as integers.

Nodes are point geometries and edges are usually line geometries. The u, v, and key values identify an edge in a multigraph. length is normally in meters for OSMnx graphs, but confirm the graph CRS and metadata before relying on the unit.

5.1.6. Summary#

Start with a graph model that reflects the movement question: direction, parallel edges, and the cost on each edge all change the answer. Select a network type and study area deliberately, understand that simplification changes every count, cache the source response, inspect the graph metadata, and keep node and edge layers available for spatial inspection, remembering that lengths are in metres but unprojected geometry is in degrees. Record the query, date, version, and licence with every graph.