Extension Compatibility in Spatial SQLite

Without verified extension compatibility, a spatial pipeline that works on your development laptop will silently fail on an ARM64 edge device, a sandboxed…

Without verified extension compatibility, a spatial pipeline that works on your development laptop will silently fail on an ARM64 edge device, a sandboxed CI runner, or a Windows field laptop — producing either a cryptic OperationalError at startup or, worse, executing spatial queries against unregistered geometry columns and returning wrong results. This page, part of Core Architecture & Format Standards for Spatial SQLite, gives you a systematic, production-tested workflow for ensuring mod_spatialite loads correctly across every platform you ship to.

Spatial SQLite is not a monolithic database engine — it is a modular runtime where core storage is extended through dynamically loaded shared libraries. Unlike PostgreSQL/PostGIS, SQLite delegates spatial functions, coordinate reference system (CRS) transformations, and topology validation to external modules. That architectural flexibility delivers exceptional portability but introduces strict compatibility boundaries: the extension binary must match the host architecture, the host SQLite build must permit dynamic loading, and the runtime spatial metadata must be initialized atomically before any query executes.

Extension Compatibility Resolution FlowThree validation stages: (1) build detection checks whether the Python sqlite3 module permits enable_load_extension; (2) architecture matching selects the correct .so/.dylib/.dll binary for the host OS and CPU; (3) metadata initialization calls InitSpatialMetaData and verifies geometry_columns and spatial_ref_sys. A failure at any stage produces a named error with a specific fix.Stage 1Build DetectionStage 2Architecture MatchStage 3Metadata InitNotSupportedError→ use pysqlite3-binarywrong architecture /undefined symbolmissing geometry_columns→ wrap in transactionSpatial context readyfor production queries

Prerequisites

Before running the compatibility workflow, confirm your environment meets these baselines:

  • Python 3.9+ with the standard sqlite3 module, or pysqlite3 for custom SQLite builds
  • SQLite 3.35+ — required for stable load_extension behavior and modern SQL constructs used in spatial metadata queries
  • Target-architecture binaries for mod_spatialite: Linux x86_64/aarch64, macOS arm64/x86_64, Windows x64
  • Write permissions on the connection’s working directory during initialization (needed for WAL sidecar files)
  • Familiarity with SpatiaLite Metadata Tables Explained — extension state interacts directly with geometry_columns and spatial_ref_sys
  • Isolated .gpkg and .sqlite test files separate from production data

Concept & Specification Reference

How SQLite Dynamic Extension Loading Works

SQLite’s extension API maps a shared library into the process via the OS dynamic linker (dlopen on POSIX, LoadLibrary on Windows). The linker resolves an entry-point symbol — by convention sqlite3_extension_init — and calls it with the active database connection. From that point the extension registers its SQL functions, virtual tables, and collations directly in the connection’s function registry.

Python’s sqlite3 module wraps this mechanism through Connection.enable_load_extension(True) and Connection.load_extension(path). Two conditions must both be true before this works:

  1. The Python interpreter was compiled with SQLITE_ENABLE_LOAD_EXTENSION=1. CPython’s own builds shipped with pip typically disable this for security reasons. Many Linux distribution packages and Conda builds re-enable it.
  2. The shared library matches the SQLite version ABI that Python links against. A mod_spatialite built against SQLite 3.39 will fail to load under Python’s bundled SQLite 3.31 with an undefined symbol error.

Architecture Compatibility Matrix

Host OSArchitectureExtension filenameNotes
Linuxx86_64mod_spatialite.soAvailable via libspatialite-dev on Debian/Ubuntu
Linuxaarch64mod_spatialite.soRequires cross-compiled or native ARM build
macOSarm64 (M-series)mod_spatialite.dylibHomebrew builds for arm64 only; do not mix with Rosetta Python
macOSx86_64mod_spatialite.dylibAvailable via Homebrew on Intel Macs
Windowsx64mod_spatialite.dllDownload from OSGeo4W or bundle with your installer

Spatial Metadata Tables Required After Initialization

Calling InitSpatialMetaData(1) populates three foundational tables. Any missing entry breaks spatial query execution entirely:

TablePurposeInitialized by
spatial_ref_sysEPSG CRS definitions and WKT projection parametersInitSpatialMetaData(1) — pre-populates ~4000 EPSG entries
geometry_columnsRegistry mapping table/column names to SRID and geometry typeAddGeometryColumn() per layer
views_geometry_columnsSame registry for SQL views exposing geometryPopulated manually or by OGR

Step-by-Step Implementation

Step 1: Detect SQLite Build Type and Security Boundaries

Python’s bundled sqlite3 module frequently ships with a statically compiled SQLite core that disables dynamic extension loading by default. This restriction prevents arbitrary code execution in shared hosting but also blocks legitimate spatial extension use. Detect the situation before any other step:

python
# SpatiaLite -- extension loading capability check
import sqlite3
import sys

def check_extension_support() -> bool:
    """Return True if this interpreter allows dynamic extension loading."""
    try:
        conn = sqlite3.connect(":memory:")
        conn.enable_load_extension(True)
        conn.close()
        return True
    except AttributeError:
        # Method not compiled in at all
        print("enable_load_extension missing — Python compiled without SQLITE_ENABLE_LOAD_EXTENSION")
        return False
    except sqlite3.NotSupportedError as exc:
        print(f"Dynamic loading disabled at runtime: {exc}")
        print("Fix: pip install pysqlite3-binary, then import pysqlite3 as sqlite3")
        return False

if not check_extension_support():
    sys.exit(1)

If enable_load_extension is absent, the recommended fix is pip install pysqlite3-binary and then aliasing it: import pysqlite3 as sqlite3. This wheel bundles a full SQLite build with extension loading compiled in, avoiding the need to recompile Python. Consult the SQLite extension loading documentation for the underlying C-API behavior this wraps.

Step 2: Resolve Extension Path and Match Architecture

Spatial extensions must exactly match the host OS, CPU architecture, and SQLite ABI. A 64-bit Python interpreter cannot load a 32-bit .so, and a macOS arm64 binary will be silently rejected under Rosetta-emulated x86_64 Python even if macOS itself is on Apple Silicon.

python
# SpatiaLite -- architecture-aware extension path resolution
import platform
import sys
import os
from pathlib import Path

def resolve_spatialite_path(base_dir: str | Path) -> Path:
    """
    Return the correct mod_spatialite binary for the current OS + architecture.
    base_dir should contain an 'extensions/' sub-directory with versioned binaries.
    """
    system = platform.system().lower()
    arch = platform.machine().lower()  # 'x86_64', 'aarch64', 'arm64', 'amd64'
    # Normalise Windows 'amd64' to 'x86_64' for consistent filenames
    if arch == "amd64":
        arch = "x86_64"

    ext_suffix = {"linux": ".so", "darwin": ".dylib", "windows": ".dll"}
    suffix = ext_suffix.get(system)
    if suffix is None:
        raise RuntimeError(f"Unsupported platform: {system!r}")

    lib_name = f"mod_spatialite_{arch}{suffix}"
    lib_path = Path(base_dir) / "extensions" / lib_name

    if not lib_path.exists():
        raise FileNotFoundError(
            f"Extension binary not found: {lib_path}\n"
            f"Expected architecture: {arch}, OS: {system}\n"
            "Check that the correct pre-built binary is bundled with the application."
        )
    return lib_path

Store extension binaries in a versioned, read-only directory alongside your application bundle — never rely on system PATH resolution in production offline deployments where package managers may not be available.

Step 3: Enable Dynamic Loading and Initialize Spatial Context

Once the binary is confirmed, load it and initialize the spatial metadata tables atomically. Wrapping initialization in a transaction ensures that a partial failure — for example, a disk-full condition mid-way through the 4000 EPSG row inserts — leaves the database in a clean, rollback-ready state rather than a partially initialized one.

python
# SpatiaLite -- atomic spatial context initialization
import sqlite3
from pathlib import Path

def initialize_spatial_context(db_path: str | Path, ext_path: str | Path) -> sqlite3.Connection:
    """
    Open db_path, load mod_spatialite, and initialize spatial metadata if absent.
    Returns an open connection with spatial functions available.
    """
    conn = sqlite3.connect(str(db_path))
    conn.enable_load_extension(True)
    conn.load_extension(str(ext_path))
    # Disable auto-commit so initialization is atomic
    conn.isolation_level = None
    conn.execute("BEGIN")
    try:
        table_exists = conn.execute(
            "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='spatial_ref_sys'"
        ).fetchone()[0]
        if table_exists == 0:
            # The '1' argument pre-populates EPSG CRS definitions
            conn.execute("SELECT InitSpatialMetaData(1)")
            conn.execute("COMMIT")
            print("Spatial metadata initialized (EPSG CRS populated).")
        else:
            conn.execute("ROLLBACK")
            print("Spatial metadata already present — skipping init.")
    except Exception:
        conn.execute("ROLLBACK")
        conn.close()
        raise
    # Restore default isolation level for subsequent queries
    conn.isolation_level = ""
    return conn

The transaction boundary is critical: if InitSpatialMetaData succeeds but the connection closes before COMMIT, SQLite’s rollback journal will restore the clean state, preventing a partially initialized spatial_ref_sys that causes CRS lookup failures later.

Step 4: Validate Spatial Metadata and Schema Alignment

After initialization, confirm that the expected tables exist and carry meaningful data before executing any spatial queries. Mismatched metadata versions can cause silent failures during CRS transformations — for example, ST_Transform will return NULL rather than raising an error if the source or target SRID is absent from spatial_ref_sys. As described in SpatiaLite Metadata Tables Explained, the geometry_columns table is the authoritative registry for layer-level spatial constraints.

python
# SpatiaLite -- post-init metadata validation
import sqlite3

def validate_spatial_metadata(conn: sqlite3.Connection) -> bool:
    """
    Confirm that spatial metadata is present and geometry columns are registered.
    Returns True on success; prints actionable diagnostics on failure.
    """
    ok = True

    # 1. Confirm extension is live
    version = conn.execute("SELECT spatialite_version()").fetchone()
    if version is None:
        print("ERROR: spatialite_version() returned NULL — extension not active.")
        return False
    print(f"SpatiaLite version: {version[0]}")

    # 2. Check CRS population
    srs_count = conn.execute("SELECT count(*) FROM spatial_ref_sys").fetchone()[0]
    if srs_count == 0:
        print("WARNING: spatial_ref_sys is empty — CRS transformations will fail.")
        ok = False
    else:
        print(f"CRS definitions loaded: {srs_count}")

    # 3. Check registered geometry layers
    geom_cols = conn.execute(
        "SELECT f_table_name, f_geometry_column, srid, geometry_type "
        "FROM geometry_columns"
    ).fetchall()
    if not geom_cols:
        print("INFO: No geometry columns registered yet (expected for a fresh database).")
    else:
        for table, col, srid, gtype in geom_cols:
            print(f"  {table}.{col}  srid={srid}  type={gtype}")

    return ok

Step 5: Cross-Platform Testing and Deployment Hardening

Production deployments require validation on every target architecture before shipping. The recommended pattern is a lightweight compatibility probe that runs at application startup — before any spatial query — and fails fast with an actionable message rather than silently degrading:

python
# SpatiaLite -- startup compatibility probe
import sqlite3
import sys
from pathlib import Path

def probe_spatial_runtime(ext_base: str | Path) -> None:
    """
    Run a complete compatibility check at application startup.
    Raises RuntimeError with a clear message on any failure.
    """
    if not check_extension_support():
        raise RuntimeError(
            "Python sqlite3 module does not support dynamic extension loading.\n"
            "Install pysqlite3-binary: pip install pysqlite3-binary"
        )
    ext_path = resolve_spatialite_path(ext_base)
    conn = initialize_spatial_context(":memory:", ext_path)
    if not validate_spatial_metadata(conn):
        raise RuntimeError("Spatial metadata validation failed — check extension build.")
    conn.close()
    print("Spatial runtime: OK")

# Call during application boot, before any worker threads start
probe_spatial_runtime("/opt/myapp")

For GeoPackage deployments distributed to field devices, pre-warm the extension during application startup, not lazily during the first query. Network-disconnected field environments cannot pull missing binaries on demand, so a failed lazy load at query time produces a silent data-loss scenario rather than a visible startup error.

Validation and Verification

Run these checks after deployment to confirm the full stack is operational:

python
# SpatiaLite -- verification suite (copy-paste into a test script)
import sqlite3

def run_verification(db_path: str, ext_path: str) -> None:
    conn = sqlite3.connect(db_path)
    conn.enable_load_extension(True)
    conn.load_extension(ext_path)

    # Extension version
    ver = conn.execute("SELECT spatialite_version()").fetchone()[0]
    assert ver, "spatialite_version() returned empty"
    print(f"spatialite_version(): {ver}")

    # GEOS (geometry engine)
    geos = conn.execute("SELECT geos_version()").fetchone()[0]
    print(f"geos_version(): {geos}")

    # PROJ (CRS transform library)
    proj = conn.execute("SELECT proj4_version()").fetchone()[0]
    print(f"proj4_version(): {proj}")

    # Geometry round-trip
    wkt = conn.execute(
        "SELECT AsText(ST_Buffer(GeomFromText('POINT(0 0)', 4326), 0.001))"
    ).fetchone()[0]
    assert wkt.startswith("POLYGON"), f"Unexpected buffer result: {wkt}"
    print("ST_Buffer round-trip: PASS")

    # CRS transform
    transformed = conn.execute(
        "SELECT AsText(ST_Transform(GeomFromText('POINT(0 51.5)', 4326), 27700))"
    ).fetchone()[0]
    assert transformed.startswith("POINT"), f"ST_Transform failed: {transformed}"
    print("ST_Transform (EPSG:4326 → EPSG:27700): PASS")

    conn.close()
    print("All verification checks passed.")

You can also verify extension availability from the command line without Python:

bash
# SpatiaLite -- CLI verification (Linux/macOS)
sqlite3 :memory: "SELECT load_extension('/usr/lib/x86_64-linux-gnu/mod_spatialite.so'); SELECT spatialite_version();"

Common Failure Modes and Fixes

“enable_load_extension not available” — Restricted Python Build

Symptom: AttributeError: 'sqlite3.Connection' object has no attribute 'enable_load_extension'

Diagnosis: The Python interpreter was compiled without SQLITE_ENABLE_LOAD_EXTENSION. Verify with:

python
import sqlite3; help(sqlite3.Connection)
# If enable_load_extension is absent from the method list, the build is restricted.

Fix: Install pysqlite3-binary (pip install pysqlite3-binary) and replace the import: import pysqlite3 as sqlite3. For Conda environments, use conda install -c conda-forge sqlite to get a build with extension loading enabled. If you control the Python build, add -DSQLITE_ENABLE_LOAD_EXTENSION=1 to your compile flags.

“undefined symbol: sqlite3_extension_init” — ABI Mismatch

Symptom: sqlite3.OperationalError: /path/to/mod_spatialite.so: undefined symbol: sqlite3_extension_init

Diagnosis: The mod_spatialite binary was built against a different SQLite ABI than the one Python links against. Check versions:

python
import sqlite3; print(sqlite3.sqlite_version)
# Compare with: ldd /path/to/mod_spatialite.so | grep libsqlite

Fix: Rebuild mod_spatialite from source against the exact SQLite version Python uses, or switch to pysqlite3-binary which bundles a self-consistent SQLite + extension loader pair.

“wrong architecture” — CPU Mismatch

Symptom: sqlite3.OperationalError: dlopen() failed: mach-o file, but is an incompatible architecture

Diagnosis: The binary is for a different CPU (e.g., x86_64 .dylib loaded by arm64 Python on Apple Silicon).

bash
file /path/to/mod_spatialite.dylib      # shows architecture
python3 -c "import platform; print(platform.machine())"  # shows Python's arch

Fix: Install the architecture-matching binary. On macOS with Homebrew: arch -arm64 brew install libspatialite. On Linux, verify the package architecture with dpkg --print-architecture before installing.

“not authorized” — macOS SIP or Sandbox Restriction

Symptom: sqlite3.OperationalError: not authorized

Diagnosis: macOS System Integrity Protection or an app sandbox is blocking dlopen for unsigned libraries.

Fix: For development, run outside the sandbox and ensure the binary is signed: codesign --verify /path/to/mod_spatialite.dylib. For distribution, add the com.apple.security.cs.disable-library-validation entitlement and sign your application bundle with codesign --entitlements.

Missing geometry_columns After InitSpatialMetaData

Symptom: sqlite3.OperationalError: no such table: geometry_columns after calling InitSpatialMetaData(1).

Diagnosis: The call succeeded but was not committed, or the extension was not active when it was called. Check:

python
# SpatiaLite -- diagnose missing geometry_columns
conn.execute("SELECT spatialite_version()")   # should not raise
conn.execute("SELECT count(*) FROM sqlite_master WHERE name='geometry_columns'").fetchone()

Fix: Ensure conn.enable_load_extension(True) and conn.load_extension(path) succeed before calling InitSpatialMetaData. Wrap the call in an explicit BEGIN/COMMIT block (see Step 3 above). Confirm write permissions on the database file.

Performance Notes

Extension loading itself is a one-time cost per connection — typically under 50 ms on modern hardware — but the connection initialization pattern has significant downstream impact:

  • Avoid loading extensions per-query: Load mod_spatialite once at connection open time, not inside query loops. Re-loading resets the function registry and is not idempotent under all builds.

  • InitSpatialMetaData(1) is expensive on first run: Inserting ~4000 EPSG rows takes 200–800 ms depending on disk speed. Run it once during database creation, not on every connection open. Gate it on a SELECT count(*) FROM spatial_ref_sys check as shown in Step 3.

  • WAL mode for concurrent readers: If multiple Python processes share a spatial SQLite file, enable WAL mode immediately after opening, before the extension is loaded. WAL mode allows concurrent readers without blocking the writer, which is critical for connection pooling patterns in spatial API servers.

    python
    # SpatiaLite -- enable WAL before loading extension
    conn = sqlite3.connect(db_path)
    conn.execute("PRAGMA journal_mode=WAL")
    conn.enable_load_extension(True)
    conn.load_extension(ext_path)
    
  • PRAGMA cache_size: For databases with large spatial_ref_sys tables or heavily indexed geometry columns, increase the page cache: conn.execute("PRAGMA cache_size=-32000") allocates ~32 MB. This reduces I/O during CRS lookups across thousands of features.

Child Pages