Exporting an OSMnx Graph to GeoPackage Jump to heading
A graph and a GIS file disagree about what a thing is. The graph has typed Python objects on edges and an implicit topology; the file has fixed columns and no notion of a node being connected to anything. The export is where that disagreement has to be settled deliberately.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
A graph exports as two layers: nodes and edges. The nodes layer is straightforward — a point per node with its identifier. The edges layer is where the problems are.
Edges need their endpoints as columns. A GIS file has no concept of connectivity, so the only way a graph can be rebuilt is if each edge row carries its source and target node identifiers explicitly.
Parallel edges need a key. Two roads can connect the same pair of junctions, and without a third component in the key the two collapse into one on reload.
List-valued attributes have no column type. An OSM way merged from several source ways carries a list of identifiers; a GeoPackage column holds a scalar. Dropping the attribute loses provenance and writing the Python representation of a list produces a string nothing can parse reliably. Joining on a separator, consistently, is the workable answer.
Nulls and mixed types break the write. A column that is an integer on most rows and a string on a few has no GeoPackage type, and the write either fails or silently coerces.
Runnable solution Jump to heading
from __future__ import annotations
import logging
from pathlib import Path
import geopandas as gpd
import networkx as nx
import osmnx as ox
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.graph.export")
LIST_SEPARATOR = "|" # not a comma: OSM values contain commas
def flatten_lists(frame: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
"""Join list-valued cells into strings. A GeoPackage column holds scalars."""
out = frame.copy()
for column in out.columns:
if column == "geometry":
continue
has_lists = out[column].map(lambda v: isinstance(v, (list, tuple))).any()
if not has_lists:
continue
out[column] = out[column].map(
lambda v: LIST_SEPARATOR.join(str(x) for x in v)
if isinstance(v, (list, tuple)) else v)
logger.info("flattened list values in column %r", column)
return out
def coerce_types(frame: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
"""Give every column one type. Mixed columns have no GeoPackage type."""
out = frame.copy()
for column in out.columns:
if column == "geometry":
continue
series = out[column]
if series.dtype == object:
kinds = {type(v).__name__ for v in series.dropna()}
if len(kinds) > 1:
# Mixed types: stringify the whole column rather than let the
# driver coerce unpredictably or drop rows.
logger.warning("column %r has mixed types %s; storing as text",
column, sorted(kinds))
out[column] = series.map(lambda v: None if v is None else str(v))
return out
def export(graph: nx.MultiDiGraph, path: Path) -> Path:
nodes, edges = ox.graph_to_gdfs(graph, nodes=True, edges=True,
fill_edge_geometry=True)
# Endpoints and the parallel-edge key live in the index; a file has no
# index, so they must become real columns or the topology is unrecoverable.
edges = edges.reset_index()
for required in ("u", "v", "key"):
if required not in edges.columns:
raise ValueError(f"edges are missing {required!r}; the graph cannot "
f"be rebuilt from this export")
nodes = nodes.reset_index().rename(columns={"osmid": "node_id"})
edges = coerce_types(flatten_lists(edges))
nodes = coerce_types(flatten_lists(nodes))
if nodes.crs is None or edges.crs is None:
raise ValueError("graph has no CRS; set one before exporting")
path.unlink(missing_ok=True)
nodes.to_file(path, layer="nodes", driver="GPKG")
edges.to_file(path, layer="edges", driver="GPKG")
logger.info("wrote %d node(s) and %d edge(s) to %s",
len(nodes), len(edges), path.name)
return path
def rebuild(path: Path) -> nx.MultiDiGraph:
"""Read the file back into a graph. This is the only real verification."""
nodes = gpd.read_file(path, layer="nodes").set_index("node_id")
edges = gpd.read_file(path, layer="edges").set_index(["u", "v", "key"])
graph = ox.graph_from_gdfs(nodes, edges)
logger.info("rebuilt graph with %d node(s) and %d edge(s)",
graph.number_of_nodes(), graph.number_of_edges())
return graph
def compare(original: nx.MultiDiGraph, restored: nx.MultiDiGraph) -> bool:
ok = True
for name, a, b in (("node", original.number_of_nodes(),
restored.number_of_nodes()),
("edge", original.number_of_edges(),
restored.number_of_edges())):
if a != b:
logger.error("%s count differs: %d before, %d after", name, a, b)
ok = False
before = sum(d.get("length", 0.0) for *_, d in original.edges(data=True))
after = sum(d.get("length", 0.0) for *_, d in restored.edges(data=True))
if abs(before - after) > max(1.0, before * 1e-9):
logger.error("total length differs: %.1f before, %.1f after", before, after)
ok = False
return ok
if __name__ == "__main__":
logger.info("export, rebuild, compare — an export that cannot be rebuilt "
"is a picture of a graph rather than a graph")
Step-by-step walkthrough Jump to heading
- Promote the index to columns. The endpoint identifiers and the parallel-edge key live in the frame’s index, and a file has no index. Failing to reset it produces a file that draws correctly and cannot be rebuilt.
- Assert the three key columns exist. Checking before writing turns a silent loss of topology into a clear error at the right moment.
- Choose a separator that does not appear in values. A comma is the obvious choice and the wrong one, because OSM names and references contain commas. A pipe is safe in practice.
- Flatten lists rather than dropping them. The merged-way identifier list is provenance, and losing it means a feature in the file cannot be traced to the OSM ways it came from.
- Coerce mixed-type columns to text. A column that is numeric on most rows and a string on a few has no file type, and letting the driver decide produces either a failure or a silent coercion.
- Require a CRS. GeoPackage stores it, so a wrong or missing value travels with the file and misplaces everything downstream.
- Rebuild and compare. Node count, edge count and total length together catch collapsed parallel edges, lost rows and mangled geometry. An export that has not been rebuilt has not been verified.
Verification Jump to heading
- The graph rebuilds. Reading the file back must produce a graph, not an error about missing columns.
- Counts match exactly. Node and edge counts before and after must be identical; a lower edge count means parallel edges collapsed.
- Total length is preserved. Summing edge lengths should agree to floating-point tolerance.
- List attributes survive. Find an edge merged from several ways and confirm its identifier list is present and splittable.
- The CRS is correct. Open the file in a GIS and confirm it lands where expected.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Graph cannot be rebuilt | Endpoint identifiers left in the index | Reset the index so they become columns |
| Edge count falls on reload | Parallel-edge key not exported | Include the key column in the edges layer |
| List attributes become unparseable | Python list representation written | Join on a separator absent from the values |
| Write fails on a column | Mixed types in one column | Coerce the whole column to text |
| Features land in the wrong place | CRS unset or wrong | Require a CRS before writing |
| Separator splits real values | A comma used as the separator | Use a character that does not occur in OSM values |
| Edges have no geometry | Geometry not filled for straight edges | Fill edge geometry during conversion |
Specification reference Jump to heading
GeoPackage stores vector features in SQLite tables with a declared geometry column and coordinate reference system, one table per layer, with fixed column types. It has no representation for list-valued attributes or for graph connectivity, so both must be encoded explicitly by the writer. See the GeoPackage specification for the table and geometry model.
Frequently Asked Questions Jump to heading
Why must the endpoint identifiers become columns?
Because a GIS file has no notion of an index or of connectivity. In memory the edge frame is indexed by source, target and key, and that index is what encodes the topology; writing the file discards it. Without those three as real columns, the file contains a set of lines that draw correctly and cannot be reassembled into a graph, which is usually discovered by whoever tries to route on it.
Why not use a comma to join list values?
Because OSM values contain commas routinely — names, references, opening hours and address fields all do. Splitting on a comma then breaks a single value into fragments, and the damage is silent because the fragments look like plausible list members. A character that does not occur in the data, such as a pipe, avoids the problem entirely and costs nothing.
What should happen to a column with mixed types?
Coerce the whole column to text and say so. A GeoPackage column has one type, so a column that is numeric on most rows and a string on a few must be resolved somehow; letting the driver choose produces either a failed write or a silent coercion that loses the non-conforming values. Stringifying preserves everything at the cost of the consumer parsing it back.
Is opening the file in a GIS a sufficient check?
No. A viewer shows that the geometry is present and correctly located, which is the part least likely to be wrong. It cannot show that parallel edges collapsed, that a list attribute became unparseable, or that the endpoint columns are missing. Rebuilding the graph from the file and comparing counts and total length is the only check that exercises what the export was for.
Related Jump to heading
- OSMnx Graph Conversion Techniques — the parent topic and building the graph.
- Simplifying an OSMnx Graph Without Losing Geometry — the step that should precede export.
- Exporting OSM to GeoParquet & PostGIS — sinks with richer type systems.
- Splitting Semicolon-Separated OSM Tag Values — the same separator problem in the source data.
- Routing Graph Topology QA — checks the rebuilt graph should pass.
Up one level: OSMnx Graph Conversion Techniques.