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.

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.

Nested record structure of MultiPolygon Well-Known BinaryThe outer MultiPolygon record begins with a byte-order byte, a four-byte type code of six, and a four-byte count of polygon components. Each component is then a complete nested Polygon record with its own byte-order byte, its own type code of three, its own ring count, and one coordinate array per ring. The first polygon shown has two rings, an exterior and one hole; the second has a single exterior ring.MultiPolygon record01 · order06 00 00 00 · type02 00 00 00 · numPolysPolygon 1 — a complete record01 · own order03 00 00 00 · typenumRings = 2exterior ring + one holePolygon 2 — a complete record01 · own order03 00 00 00numRings = 1exterior ring only
Each component carries its own byte-order byte — the outer one does not govern the nested records.

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

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.

The serialization sequence that survives untrusted geometryA left-to-right flow. A GeoJSON dict is turned into a geometry by shape, then normalize enforces ring orientation, then is_valid is tested. Valid geometries go straight to wkb.dumps. Invalid ones are diverted down to make_valid and then rejoin the flow just before dumps, so the repair happens once and only when it is needed.valid — no repair neededshape( )GeoJSON dict innormalize( )ring order onlyis_valid?topology testwkb.dumps( )ISO WKB bytesinvalidmake_valid( )type may change
Repair sits off the main path: it runs only for geometry that failed the test, and its output rejoins before encoding.

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),
)

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:

Three routes from ISO WKB bytes into a spatial columnOne buffer of ISO WKB bytes branches three ways. Routed through the SpatiaLite GeomFromWKB function, the SQL layer adds the SpatiaLite envelope and the bytes land in a SpatiaLite BLOB column. Prefixed by hand with the eight-byte GP header, they land in a GeoPackage BLOB column but the registry and R-tree remain your responsibility. Written through Fiona or OGR, the driver owns the encoding and maintains the registry and index for either container.ISO WKB byteswkb.dumps(geom)GeomFromWKB(?, 4326)the SQL layer wraps itSpatiaLite column0x00 … 0xFEGP header + WKByou prepend 8 bytesGeoPackage columnregistry is yours to fixfiona / OGR writedriver owns the encodingeither containerregistry + R-tree kept
The two hand-rolled routes are faster; only the driver route maintains the registry tables and spatial index for you.

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.

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.

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.