Vectorizing Raster Stream Networks to GeoPackage
A traced network is only useful if it survives the trip to disk with its topology intact, and the format and schema choices decide whether it does. This guide covers the export step — schema, indexing, metadata and the pitfalls that silently destroy connectivity — as part of the stream network vectorization and ordering topic within flow routing and stream network extraction.
Prerequisites
- A traced network in memory as a
GeoDataFramewithfrom_node,to_nodeand geometry. geopandaswith thepyogrioengine, which is substantially faster for GeoPackage writes than the older Fiona path.- A projected CRS. Writing a network in degrees produces a file whose length column is not a length.
Core Technique: A Schema That Preserves Topology
GeoPackage is the right container here for three reasons: it is a single file, it holds multiple layers, and it is a SQLite database, so the topology can be indexed and queried without loading the geometry at all.
The schema matters more than the format. A network layer needs three groups of columns.
Annotated Code Example
import hashlib
import json
import logging
import sqlite3
from datetime import datetime, timezone
import geopandas as gpd
log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
def write_stream_geopackage(
links: gpd.GeoDataFrame,
out_path: str,
dem_path: str,
threshold_cells: int,
router: str = "D8",
conditioning: str = "priority-flood fill, breached road crossings",
layer: str = "streams",
) -> dict:
"""
Write a traced network to GeoPackage with topology, indexes and provenance.
Parameters
----------
links : Traced segments carrying from_node, to_node and geometry.
out_path : Destination .gpkg path.
dem_path : Source DEM, hashed into the provenance record.
threshold_cells : The accumulation threshold used for extraction.
router : Routing algorithm name, recorded verbatim.
conditioning : Free-text description of the conditioning applied.
layer : Layer name inside the GeoPackage.
"""
required = {"from_node", "to_node"}
missing = required - set(links.columns)
if missing:
raise ValueError(f"network is missing topology column(s): {sorted(missing)}")
if links.crs is None or links.crs.is_geographic:
raise ValueError("network must be in a projected CRS — lengths in degrees "
"are not lengths")
out = links.copy()
# --- Node ids must be integers. Float ids survive a round trip through
# some drivers as 1.0000000001 and stop matching, which fragments the
# topology in a way that is very hard to see. ---
for col in ("from_node", "to_node"):
out[col] = out[col].astype("int64")
if "link_id" not in out.columns:
out.insert(0, "link_id", range(len(out)))
out["link_id"] = out["link_id"].astype("int64")
if "length_m" not in out.columns:
out["length_m"] = out.geometry.length
# --- Write geometry unsimplified. Any generalisation moves endpoints,
# and a moved endpoint no longer coincides with its neighbour's. ---
out.to_file(out_path, layer=layer, driver="GPKG", engine="pyogrio")
log.info("Wrote %d links to %s (layer '%s')", len(out), out_path, layer)
# --- Index the topology columns so upstream/downstream queries do not
# scan the table, and record provenance in a sibling table. ---
dem_hash = _sha256(dem_path)
meta = {
"threshold_cells": threshold_cells,
"router": router,
"conditioning": conditioning,
"dem_path": dem_path,
"dem_sha256": dem_hash,
"crs": out.crs.to_string(),
"link_count": int(len(out)),
"total_length_km": round(float(out["length_m"].sum()) / 1000.0, 3),
"extracted_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
with sqlite3.connect(out_path) as con:
con.execute(f'CREATE INDEX IF NOT EXISTS idx_{layer}_from '
f'ON "{layer}" (from_node)')
con.execute(f'CREATE INDEX IF NOT EXISTS idx_{layer}_to '
f'ON "{layer}" (to_node)')
con.execute("CREATE TABLE IF NOT EXISTS extraction_metadata "
"(key TEXT PRIMARY KEY, value TEXT)")
con.executemany("INSERT OR REPLACE INTO extraction_metadata VALUES (?, ?)",
[(k, json.dumps(v)) for k, v in meta.items()])
log.info("Indexed topology columns and stored %d provenance keys", len(meta))
log.info("Provenance: threshold=%d cells, router=%s, DEM sha256=%s…",
threshold_cells, router, dem_hash[:12])
return meta
def _sha256(path: str, chunk: int = 1 << 20) -> str:
h = hashlib.sha256()
with open(path, "rb") as fh:
for block in iter(lambda: fh.read(chunk), b""):
h.update(block)
return h.hexdigest()
# --- Example usage ---
# write_stream_geopackage(
# links=net, out_path="basin_streams.gpkg",
# dem_path="basin_conditioned.tif", threshold_cells=500,
# )
Parameter Reference
| Choice | Recommended | Why |
|---|---|---|
| Driver | GPKG | Single file, multi-layer, SQLite-queryable topology |
| Engine | pyogrio |
Several times faster than Fiona for large writes |
| Node dtype | int64 |
Float node ids break equality matching after a round trip |
| Simplification | None, in the topological layer | Moving a vertex breaks the shared-endpoint invariant |
| Indexes | from_node, to_node |
Turns an upstream query from a table scan into a lookup |
| Metadata | Separate table in the same file | Travels with the data instead of in a README nobody reads |
Worked Example: Verifying the Round Trip
The point of the schema is that the file can be reloaded and traversed without repair. The check is short and worth running on every export.
| Check | Expected | A failure means |
|---|---|---|
| Link count after reload | Unchanged | The driver dropped rows — usually a mixed geometry type |
| Node id dtype after reload | Integer | Ids were written as real and will not match |
Distinct to_node values with no matching from_node |
One per basin outlet | Extra values are dangling links |
| Weakly connected components | One per basin | More means the topology fragmented |
Sum of length_m |
Matches the pre-write total | Geometry was altered on write |
The indexes are what make the file useful as a database rather than as a picture. An upstream query on an unindexed topology column scans the whole table.
Storing the raster-side identifiers too
A segment’s usefulness often depends on being able to get back to the cells it came from. Two small columns make that possible without storing the whole cell path: the row and column of the segment’s head cell, and of its tail cell. With those, a consumer can re-enter the raster at either end of any segment — to sample slope along it, to re-derive its upstream area from the accumulation grid, or to seed a delineation at its outlet — without re-running the tracing step.
The alternative, matching a segment’s geometry back to cells by coordinate, is both slower and lossy: a line vertex sits at a cell centre only if nothing has moved it, and something usually has.
Layer conventions that make the file self-describing
A GeoPackage holds several layers, and using that deliberately is worth the small extra
effort. A streams layer carrying the segments, a nodes layer carrying the junctions
as points, and the extraction_metadata table described above together make a file that
a consumer can understand without documentation. The nodes layer in particular is cheap
— it is the unique endpoints already computed during tracing — and it lets a map client
symbolise confluences and outlets without deriving them.
Where the same file will hold several extractions at different thresholds, suffix the layer names with the threshold rather than writing several files. The metadata table then carries one row set per layer, and a comparison between two extractions becomes a join inside one file rather than a join across two.
Gotchas and Edge Cases
rasterio.features.shapesfor vectorization. Produces bank outlines, not centrelines, and fragments at every junction. It is the fastest route to a network that looks right and cannot be traversed.- Shapefile output. Field names truncate to ten characters, the format caps at 2 GB, and the CRS travels in a sidecar file that gets separated. Use GeoPackage.
- Float node ids. Survive most round trips and then fail an equality join on a handful of links, fragmenting the graph in a way that only shows up in a component count.
- Simplifying before writing. Moves endpoints, breaks shared-node identity, and the damage is invisible until a traversal fails.
- Mixed geometry types in one layer. A traced network that accidentally includes a zero-length link produces a Point among the LineStrings, and some drivers drop the whole row rather than the geometry.
- Metadata in the filename.
streams_500cells_d8.gpkgis better than nothing and worse than a metadata table, because the filename does not survive being copied into a project directory with a different convention.
Related Topics
- Stream Network Vectorization and Ordering — the parent topic: thresholding, thinning and tracing that produce these segments
- Computing Strahler Stream Order in Python — an attribute worth computing before the export, not after
- Building a NetworkX Graph from a Stream Network — reading this file back as a traversable graph
- Validating Catchment Boundary Topology with GeoPandas and Shapely — the equivalent discipline for the catchment polygons this network cuts