How to Serialize MultiPolygon Geometries to WKB in Python
Call shapely.geometry.shape(geojson_dict).wkb to produce ISO WKB bytes, or use shapely.wkb.dumps(geom, include_srid=True) for EWKB with an embedded SRID — then write the bytes directly into a BLOB column with a parameterised SQLite INSERT.
This page is part of the Fiona & OGR Driver Configuration guide and covers the serialization step that bridges Fiona-read geometries to raw SQLite storage.
Why This Matters
GeoPackage stores geometries as a GeoPackage Binary blob — an 8-byte header followed by ISO WKB. SpatiaLite uses a different binary envelope (SpatiaLite Geometry), but both formats have ISO WKB as their inner payload. Understanding the exact byte layout matters when you bypass the OGR layer and write geometry blobs directly: a mismatched byte order mark or an off-by-one in the ring count produces rows that import without error but silently fail geometry function calls later.
MultiPolygon is the most error-prone geometry type in batch pipelines: exterior rings must be counter-clockwise, interior rings (holes) must be clockwise, and each Polygon component within the MultiPolygon is a separate winding-ordered ring group. Shapely enforces these rules; raw struct-packing does not.
Prerequisites
- Python 3.9+
shapely2.0+ (pip install shapely)fiona1.9+ for reading source features (optional — any GeoJSON dict works)- A target SQLite database with a geometry column of type
BLOBor a GeoPackage with a registered geometry column ingpkg_geometry_columns
Primary Method
# Shapely 2.0+ / Python 3.9+
import sqlite3
from shapely.geometry import shape, MultiPolygon
import shapely.wkb
def serialize_multipolygon_to_wkb(geojson_geometry: dict) -> bytes:
"""
Convert a GeoJSON geometry dict to ISO WKB bytes.
Raises ValueError if the input is not a MultiPolygon or Polygon,
or if the geometry is invalid after normalization.
"""
geom = shape(geojson_geometry)
# Promote Polygon to MultiPolygon for a uniform type contract
if geom.geom_type == "Polygon":
geom = MultiPolygon([geom])
elif geom.geom_type != "MultiPolygon":
raise ValueError(
f"Expected Polygon or MultiPolygon, got {geom.geom_type}"
)
# normalize() enforces CCW exterior / CW interior ring orientation
geom = geom.normalize()
if not geom.is_valid:
from shapely.validation import make_valid
geom = make_valid(geom)
# dumps() produces ISO WKB; include_srid=False (default) for bare WKB
return shapely.wkb.dumps(geom, little_endian=True)
Pass the returned bytes to a parameterised INSERT:
def insert_multipolygon(
conn: sqlite3.Connection,
table: str,
feature_id: int,
geojson_geometry: dict,
) -> None:
wkb_bytes = serialize_multipolygon_to_wkb(geojson_geometry)
conn.execute(
f"INSERT INTO {table} (id, geometry) VALUES (?, ?);",
(feature_id, wkb_bytes),
)
Step-by-Step Walkthrough
1. Understand the WKB byte layout
ISO WKB for a MultiPolygon with SRID 4326 starts with:
| Offset | Bytes | Value |
|---|---|---|
| 0 | 1 | 0x01 (little-endian) or 0x00 (big-endian) |
| 1 | 4 | WKB type: 0x00000006 = MultiPolygon |
| 5 | 4 | Number of Polygon components |
| 9+ | varies | Each Polygon as a nested WKB structure |
Each nested Polygon starts with its own byte-order byte and WKB type (0x00000003), followed by a ring count and then the ring coordinate arrays. Shapely’s wkb.dumps() handles this layout automatically; the common mistake is calling Python’s struct.pack manually and misordering the ring-count and coordinate fields.
2. Enforce ring orientation before serialization
Shapely 2.x does not guarantee ring orientation on construction. normalize() reorders rings to the OGC convention (exterior CCW, interior CW). Skip it and the GeoPackage will accept the insert, but ST_IsValid will return 0 and spatial predicates like ST_Intersects may return wrong results.
from shapely.geometry import shape
from shapely.validation import make_valid
geom = shape(geojson_geom)
geom = geom.normalize() # enforce ring orientation
if not geom.is_valid:
geom = make_valid(geom) # repair self-intersections
3. Write WKB to a SpatiaLite geometry column
SpatiaLite wraps ISO WKB in a proprietary envelope. Use GeomFromWKB(?, 4326) in the SQL to let SpatiaLite add the envelope:
# SpatiaLite — wrap WKB in the SpatiaLite binary envelope
conn.execute(
"INSERT INTO survey_areas (id, geometry) VALUES (?, GeomFromWKB(?, 4326));",
(feature_id, wkb_bytes),
)
For a bare GeoPackage geometry column that stores ISO WKB directly, insert the bytes without the GeomFromWKB wrapper — but you must add the 8-byte GeoPackage Binary header manually if the column is validated by gpkg_geometry_columns:
import struct
def gpkg_header(srid: int = 4326) -> bytes:
"""Minimal 8-byte GeoPackage Binary header — envelope type 0 (no envelope)."""
magic = b"GP" # 2 bytes: 0x47 0x50
version = b"\x00" # 1 byte: version 0
flags = b"\x01" # 1 byte: little-endian WKB, no envelope
srid_bytes = struct.pack("<i", srid) # 4 bytes: SRID as little-endian int32
return magic + version + flags + srid_bytes
gpkg_blob = gpkg_header(4326) + wkb_bytes
conn.execute(
"INSERT INTO survey_areas (id, geometry) VALUES (?, ?);",
(feature_id, gpkg_blob),
)
4. Batch-insert with a single transaction
Wrapping multiple inserts in one transaction is the most impactful performance change for bulk geometry loading. Each COMMIT flushes the WAL; thousands of individual transactions are thousands of fsync calls.
import fiona
def bulk_insert_from_fiona(
conn: sqlite3.Connection,
gpkg_src: str,
layer: str,
table: str,
) -> int:
rows = []
with fiona.open(gpkg_src, layer=layer) as src:
for feature in src:
wkb = serialize_multipolygon_to_wkb(feature["geometry"])
rows.append((feature["id"], wkb))
conn.execute("BEGIN;")
conn.executemany(
f"INSERT INTO {table} (id, geometry) VALUES (?, GeomFromWKB(?, 4326));",
rows,
)
conn.execute("COMMIT;")
return len(rows)
5. Rebuild the spatial index after bulk insert
R-tree indexes on GeoPackage and SpatiaLite geometry columns are not automatically updated during direct INSERT statements that bypass the OGR layer. Rebuild them explicitly:
# SpatiaLite — rebuild R-tree after bulk WKB insert
conn.execute("SELECT UpdateLayerStatistics();")
conn.execute("SELECT RebuildGeometryTriggers('survey_areas', 'geometry');")
For GeoPackage, update the R-tree trigger table directly:
# GeoPackage — re-populate the R-tree index
conn.execute("""
INSERT OR REPLACE INTO rtree_survey_areas_geometry
SELECT id,
ST_MinX(geometry), ST_MaxX(geometry),
ST_MinY(geometry), ST_MaxY(geometry)
FROM survey_areas;
""")
Validation
After inserting, verify geometry validity and spatial index consistency:
conn.enable_load_extension(True)
conn.load_extension("mod_spatialite")
rows = conn.execute("""
SELECT id,
ST_IsValid(geometry) AS valid,
ST_GeometryType(geometry) AS gtype,
ST_SRID(geometry) AS srid
FROM survey_areas
LIMIT 10;
""").fetchall()
for row in rows:
assert row[1] == 1, f"Invalid geometry at id={row[0]}"
assert row[2] in ("MULTIPOLYGON", "POLYGON"), f"Unexpected type: {row[2]}"
assert row[3] == 4326, f"Wrong SRID: {row[3]}"
print("All sampled geometries are valid")
Common Failure Modes
ST_IsValid returns 0 after insert
Ring orientation was not enforced before serialization. Call geom.normalize() and, if the geometry is still invalid, make_valid(geom) from shapely.validation.
OperationalError: no such function: GeomFromWKB
mod_spatialite is not loaded. Call conn.enable_load_extension(True) then conn.load_extension("mod_spatialite") before issuing any spatial SQL.
struct.error: unpack requires a buffer of N bytes
The WKB blob is truncated — usually caused by reading only part of a binary column. Use BLOB column type (not TEXT) and pass bytes objects, not strings, to parameterised queries.
R-tree out of sync after bulk insert
Direct INSERT statements bypass the GeoPackage R-tree triggers. Always call the R-tree rebuild query after any bulk geometry load that uses raw WKB inserts.
Related
- Fiona & OGR Driver Configuration — parent guide: driver binding, schema enforcement, and environment pinning for Fiona pipelines
- How to Batch Convert Shapefiles to GeoPackage with Fiona — convert entire Shapefile directories to GeoPackage in one automated pass
- Spatial Data Serialization Patterns — WKB, GeoJSON, and batch serialization strategies for SpatiaLite and GeoPackage
- Transaction Scoping & Rollback Strategies — coordinate write transactions to protect bulk geometry inserts
- Python Integration & Database Workflows — top-level guide to the full Python spatial SQLite stack