How to Serialize Multipolygon Geometries to WKB in Python
Use shapely.wkb.dumps(geom, little_endian=True) to produce ISO Well-Known Binary from a Shapely MultiPolygon, then insert the bytes into a BLOB column via a parameterised SQLite query — always calling geom.normalize() first to enforce OGC ring-orientation rules for exterior and interior rings.
This page is part of the Spatial Data Serialization Patterns guide, which covers WKB, GeoJSON, and batch-serialization strategies for both SpatiaLite and GeoPackage.
Why This Matters
MultiPolygon geometries appear routinely in administrative boundary datasets, land-parcel exports, and multi-island coastal surveys. They are the geometry type most likely to fail silently: SQLite accepts a badly oriented ring without error, but ST_IsValid returns 0, ST_Intersects returns wrong results, and spatial joins produce phantom matches. The failure typically surfaces only in production — not in unit tests — because test datasets rarely include geometries with holes or disconnected components.
Ring orientation is the core invariant: exterior rings must wind counter-clockwise (CCW) and interior rings (holes) must wind clockwise (CW) according to the OGC Simple Features specification. Shapely 2.x does not guarantee this on construction, so you must call normalize() before serializing.
Prerequisites
- Python 3.9+
shapely2.0+ (pip install shapely)sqlite3(stdlib) or a GeoPackage connection viafionaorgeopandas- For SpatiaLite targets:
libspatialiteinstalled and accessible asmod_spatialite
Primary Method
# Shapely 2.0+ — serialize MultiPolygon to ISO WKB for direct SQLite insertion
import sqlite3
from shapely.geometry import shape, MultiPolygon
from shapely.validation import make_valid
import shapely.wkb
def multipolygon_to_wkb(geojson_geometry: dict) -> bytes:
"""
Convert a GeoJSON geometry dict (Polygon or MultiPolygon) to ISO WKB bytes.
Ring orientation is enforced; invalid geometries are repaired with make_valid().
"""
geom = shape(geojson_geometry)
# Normalize to MultiPolygon for a uniform type contract
if geom.geom_type == "Polygon":
geom = MultiPolygon([geom])
elif geom.geom_type != "MultiPolygon":
raise TypeError(f"Expected Polygon or MultiPolygon, got {geom.geom_type}")
# Enforce OGC ring orientation: exterior CCW, interior CW
geom = geom.normalize()
# Repair topology errors (self-intersections, duplicate vertices)
if not geom.is_valid:
geom = make_valid(geom)
return shapely.wkb.dumps(geom, little_endian=True)
Insert into SpatiaLite using GeomFromWKB to add the SpatiaLite binary envelope:
def insert_multipolygon_spatialite(
conn: sqlite3.Connection,
table: str,
feature_id: int,
geojson_geometry: dict,
srid: int = 4326,
) -> None:
wkb = multipolygon_to_wkb(geojson_geometry)
conn.execute(
f"INSERT INTO {table} (id, geometry) VALUES (?, GeomFromWKB(?, ?));",
(feature_id, wkb, srid),
)
Step-by-Step Walkthrough
1. Understand ring ordering for MultiPolygon components
A MultiPolygon is a list of Polygon components. Each Polygon has one exterior ring and zero or more interior rings (holes). The OGC rule:
- Exterior ring: vertices listed counter-clockwise (CCW) when viewed with Y-axis up
- Interior ring (hole): vertices listed clockwise (CW)
Shapely’s normalize() method reorders rings to satisfy this convention. If you skip it, GeoPackage and SpatiaLite store the geometry, but any function that relies on ring topology — ST_Within, ST_Difference, ST_Buffer — may return incorrect results.
2. Detect and promote mixed geometry types
Fiona and GeoJSON sources sometimes return Polygon for single-component features and MultiPolygon for multi-component ones, even within the same layer. Normalize to MultiPolygon before writing to avoid schema type mismatches:
from shapely.geometry import shape, MultiPolygon
def to_multipolygon(geom):
if geom.geom_type == "Polygon":
return MultiPolygon([geom])
elif geom.geom_type == "MultiPolygon":
return geom
else:
raise TypeError(f"Cannot convert {geom.geom_type} to MultiPolygon")
3. Handle geometries with holes
Interior rings define areas excluded from the polygon. A land parcel with a lake, a country with an enclave — these are modelled as polygons with holes. Shapely preserves holes through the WKB round-trip:
from shapely.geometry import Polygon, MultiPolygon
# Polygon with one hole: outer square minus inner square
exterior = [(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)]
hole = [(2, 2), (2, 8), (8, 8), (8, 2), (2, 2)]
poly_with_hole = Polygon(exterior, [hole])
multi = MultiPolygon([poly_with_hole]).normalize()
wkb_bytes = shapely.wkb.dumps(multi, little_endian=True)
# Verify the hole survives the round-trip
restored = shapely.wkb.loads(wkb_bytes)
assert len(list(restored.geoms)) == 1, "Component count changed"
assert len(list(restored.geoms[0].interiors)) == 1, "Hole lost in round-trip"
4. Batch serialize for bulk insert
For bulk loads, serialize all geometries before opening the transaction to separate I/O-bound Shapely work from the SQLite write:
import fiona
def bulk_serialize(fiona_collection) -> list[tuple]:
"""Return (id, wkb_bytes) pairs for every feature in the collection."""
rows = []
for feature in fiona_collection:
try:
wkb = multipolygon_to_wkb(feature["geometry"])
rows.append((feature["id"], wkb))
except (TypeError, ValueError) as exc:
# Log and skip invalid features rather than aborting the batch
print(f"Skipping feature {feature['id']}: {exc}")
return rows
Then insert in a single transaction:
conn.execute("BEGIN;")
conn.executemany(
"INSERT INTO parcels (id, geometry) VALUES (?, GeomFromWKB(?, 4326));",
[(row[0], row[1]) for row in rows],
)
conn.execute("COMMIT;")
5. Build the GeoPackage binary header for header-aware consumers
If writing directly to a GeoPackage column validated by gpkg_geometry_columns, prepend the 8-byte GeoPackage Binary header to the ISO WKB:
import struct
def gpkg_wkb(wkb_bytes: bytes, srid: int = 4326) -> bytes:
"""Prepend the GeoPackage Binary header to ISO WKB."""
# magic (2) + version (1) + flags (1) + srid (4) = 8 bytes
header = b"GP" + b"\x00" + b"\x01" + struct.pack("<i", srid)
return header + wkb_bytes
Use this when writing to a raw GeoPackage geometry column without the OGR layer or Fiona as the intermediary.
6. Validate geometry after insert
Run a sample validation query immediately after the bulk insert:
conn.enable_load_extension(True)
conn.load_extension("mod_spatialite")
invalid = conn.execute("""
SELECT id FROM parcels WHERE ST_IsValid(geometry) = 0 LIMIT 10;
""").fetchall()
if invalid:
print(f"Invalid geometries found: {[r[0] for r in invalid]}")
else:
print("All geometries are valid")
Common Failure Modes
ST_IsValid returns 0
Ring orientation was not enforced. Call geom.normalize() before wkb.dumps(). If the geometry is still invalid after normalization, use make_valid(geom) from shapely.validation.
Holes disappear after insert
The interior rings were not included in the WKB. Confirm you are using a Polygon with the holes argument, not a Polygon constructed from only the exterior ring. Verify the hole count before serializing: assert len(list(geom.interiors)) == expected_holes.
OperationalError: no such function: GeomFromWKB
mod_spatialite is not loaded on the connection. Call conn.enable_load_extension(True) and conn.load_extension("mod_spatialite") before executing spatial SQL.
struct.error on WKB read-back
The bytes column stored a string (Python str) instead of bytes. Always pass bytes objects to parameterised queries for BLOB columns. Passing a hex string produces a text value that cannot be decoded as WKB.
Related
- Spatial Data Serialization Patterns — parent guide: WKB, GeoJSON, and batch-serialization patterns for SpatiaLite and GeoPackage
- Batch Convert Shapefiles to GeoPackage with Fiona — complete pipeline for ingesting
.shpfiles and writing OGC-compliant GeoPackage layers - Fiona & OGR Driver Configuration — explicit driver binding and schema enforcement for GeoPackage and SpatiaLite
- Transaction Scoping & Rollback Strategies — wrap bulk geometry inserts in correct transaction boundaries
- Python Integration & Database Workflows — top-level guide to the full Python spatial SQLite stack