How to Serialize MultiPolygon Geometries to WKB in Python

Call shapely.geometry.shape(geojsondict).wkb to produce ISO WKB bytes, or use shapely.wkb.dumps(geom, includesrid=True) for EWKB with an embedded SRID —…

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+
  • shapely 2.0+ (pip install shapely)
  • fiona 1.9+ for reading source features (optional — any GeoJSON dict works)
  • A target SQLite database with a geometry column of type BLOB or a GeoPackage with a registered geometry column in gpkg_geometry_columns

Primary Method

python
# 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:

python
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:

OffsetBytesValue
010x01 (little-endian) or 0x00 (big-endian)
14WKB type: 0x00000006 = MultiPolygon
54Number of Polygon components
9+variesEach 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.

python
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:

python
# 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:

python
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.

python
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:

python
# 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:

python
# 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:

python
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.