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.
What makes MultiPolygon distinctive is that the structure is genuinely recursive: the outer record carries a count, and each counted element is itself a complete, self-describing WKB record with its own byte-order marker. A parser that assumes one byte-order byte at the front of the buffer and reads every subsequent integer with that endianness will decode a mixed-endian blob into garbage without ever raising.
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
Treat those four lines as a fixed sequence rather than three independent options. normalize() fixes orientation but not topology; make_valid() fixes topology but may return a different geometry type — a self-intersecting polygon can come back as a GeometryCollection — so the type guard has to run after the repair, not before it. The order that survives real input is: build, orient, test, repair only if the test failed, then serialize.
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),
)
The same bytes therefore reach three different destinations by three different routes, and choosing the wrong one is the single most common cause of a file that loads in Python and refuses to open in QGIS:
The middle route is the one that needs discipline. Prepending the header produces a byte-correct geometry, but nothing has told the container that the table is a feature layer: gpkg_contents and gpkg_geometry_columns still have no row for it, and the R-tree is not populated. A reader that trusts the registry — which is every standards-compliant reader — reports the table as absent. Use the direct route for throughput, then close the loop by writing the registry rows and rebuilding the index in the same transaction, as the next two steps do.
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.
Frequently Asked Questions
Should I use ISO WKB or EWKB for GeoPackage columns?
ISO WKB. The GeoPackage specification defines the geometry payload as standard WKB with the spatial reference carried in the container’s own header, not inside the geometry bytes. EWKB — the PostGIS dialect that embeds an SRID in the type code by setting a high bit — is not part of the GeoPackage contract, and a reader that parses the type word strictly will reject it or misread the geometry type. Call shapely.wkb.dumps(geom) with the default include_srid=False and let the 4-byte srs_id field of the GeoPackage Binary header carry the reference system.
Why does my geometry round-trip correctly in Python but fail in QGIS?
Almost always because the bytes are fine and the registry is not. Writing a geometry BLOB with raw sqlite3 stores valid data but leaves no row in gpkg_contents or gpkg_geometry_columns, and the R-tree is unpopulated. Python code that queries the table directly never notices; QGIS enumerates layers from the registry and therefore does not see the table at all. Add the registry rows in the same transaction as the bulk insert, then rebuild the index.
Is `normalize()` enough, or do I always need `make_valid()`?
They fix different problems and neither substitutes for the other. normalize() only reorders rings and vertices into a canonical form — it cannot repair a ring that crosses itself, a hole that escapes its exterior, or a duplicated vertex that collapses a segment to zero length. make_valid() repairs those topological faults but may return a different geometry type, so a MultiPolygon with a bow-tie can come back as a GeometryCollection. Run normalize() unconditionally, test is_valid, and call make_valid() only on the failures — then re-check the type before writing.
How much does the byte-order flag actually matter in practice?
For the WKB payload, very little: every mainstream writer emits little-endian, and from_wkb reads the flag correctly either way. It matters for the header integers around the payload, which follow the GeoPackage flags byte rather than the WKB byte-order byte. Code that hard-codes <i when unpacking srs_id works everywhere until it meets a big-endian header, at which point the geometry still decodes and the SRS id comes back as a nonsense number — a bug that looks like a projection problem rather than a parsing one.
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