Transaction Scoping & Rollback Strategies for SpatiaLite & GeoPackage

Without explicit transaction boundaries, Python scripts that write to SpatiaLite or GeoPackage databases silently auto-commit every DML statement. The…

Without explicit transaction boundaries, Python scripts that write to SpatiaLite or GeoPackage databases silently auto-commit every DML statement. The result is partial geometry writes, broken spatial indexes, and corrupted offline caches that are difficult to detect and expensive to repair in the field. This guide covers everything needed to implement deterministic, atomic spatial operations in Python — from locking strategies through savepoints to pre-commit geometry validation.

This topic sits within the broader Python Integration & Database Workflows section, which covers the full Python-to-SQLite spatial stack. For the underlying extension that exposes spatial functions inside a transaction, see Native sqlite3 Spatial Extensions.


Prerequisites

Before implementing transaction boundaries, confirm your environment meets the following requirements. Check each one off before proceeding.


Concept & Specification Reference

How Python’s sqlite3 module handles transactions

Python’s sqlite3 wrapper controls transaction state through the isolation_level connection parameter. The default value ("" — deferred) causes the driver to issue an implicit BEGIN before the first DML statement and an automatic COMMIT after each batch of statements. This means a loop over 10,000 geometry inserts may commit after every iteration, making partial rollback impossible.

Setting isolation_level=None disables all implicit transaction management and hands full control to the application. Every BEGIN, COMMIT, and ROLLBACK must then be explicit.

isolation_level valueBehaviorRecommended for spatial work?
"" (default deferred)Implicit BEGIN + auto-commitNo — silent partial commits
"DEFERRED" / "IMMEDIATE" / "EXCLUSIVE"Implicit BEGIN at first DML, explicit COMMIT requiredPartial — BEGIN type is fixed
NoneFully manual — no implicit managementYes — full control

SQLite locking and BEGIN variants

SQLite uses a file-level locking protocol with five states: UNLOCKED, SHARED, RESERVED, PENDING, and EXCLUSIVE. Choosing the wrong BEGIN variant when writing spatial data causes SQLITE_BUSY errors when background sync processes or QGIS attempt concurrent access.

BEGIN variantLock acquired immediatelyUse case
BEGIN (DEFERRED)None until first read/writeShort read-heavy transactions
BEGIN IMMEDIATERESERVED at startSpatial batch writes — prevents lock race
BEGIN EXCLUSIVEEXCLUSIVE at startFull R-tree rebuilds, schema migrations

For field deployments where multiple processes share one GeoPackage file, BEGIN IMMEDIATE is the right default for all write transactions. It fails fast if another writer holds the lock instead of entering a silent wait that corrupts the WAL file.

Savepoints as nested rollback boundaries

The SQL SAVEPOINT name / ROLLBACK TO SAVEPOINT name / RELEASE SAVEPOINT name triplet creates a nested scope inside an open transaction. Critically, rolling back to a savepoint does not close the outer transaction — the connection stays in the BEGIN IMMEDIATE state and further writes are still possible.

This is essential for batch imports where a single malformed geometry should discard only that batch, not hours of previously validated work.

GeoPackage Binary (GPB) encoding

When writing geometries into a .gpkg column using SpatiaLite functions such as GeomFromText, the geometry is stored in SpatiaLite’s internal BLOB format by default, not GeoPackage Binary. Other OGC-compliant tools (GDAL, QGIS, mobile SDKs) cannot read SpatiaLite BLOBs. Enable amphibious mode before any write:

sql
-- GeoPackage context: required before writing geometries with SpatiaLite functions
SELECT EnableGpkgAmphibiousMode();

This is a per-connection call and must appear after mod_spatialite is loaded. For more on the binary format distinction and its effect on interoperability, see the GeoPackage Specification Deep Dive.


Transaction Lifecycle: Architecture Diagram

The diagram below shows the complete lifecycle of a spatial write transaction, including the savepoint branch for partial rollback.

Explicit spatial transaction lifecycle with savepointA flowchart showing BEGIN IMMEDIATE at the top, followed by SAVEPOINT, then spatial INSERT/UPDATE. A diamond decision node runs ST_IsValid. The valid path goes down to RELEASE SAVEPOINT then COMMIT. The invalid path exits left to ROLLBACK TO SAVEPOINT, with a dashed retry arrow back up to SAVEPOINT. A second invalid path exits left from COMMIT to ROLLBACK for a full transaction abort.validinvalidfix & retryBEGIN IMMEDIATEreserve write lock up frontSAVEPOINT bulk_importnested rollback boundaryRun spatial INSERT / UPDATEGeomFromText, geometry BLOBsST_IsValid?pre-commit geometry checkROLLBACK TO SAVEPOINTdiscard batch, keep txn openRELEASE SAVEPOINTCOMMITdurable, atomic write to disk
Rolling back to the savepoint discards only the invalid batch while keeping the outer transaction open — invalid geometries never reach COMMIT.

Step-by-Step Implementation

Step 1 — Disable auto-commit and acquire a write lock

Setting isolation_level=None disables Python’s implicit transaction management. BEGIN IMMEDIATE then acquires a RESERVED lock immediately, preventing SQLITE_BUSY races with concurrent readers or background sync agents.

python
# SpatiaLite / GeoPackage context: Python 3.9+, sqlite3 stdlib
import sqlite3

def open_spatial_connection(db_path: str) -> sqlite3.Connection:
    conn = sqlite3.connect(db_path, isolation_level=None)
    conn.enable_load_extension(True)
    conn.load_extension("mod_spatialite")
    return conn

conn = open_spatial_connection("field_survey.gpkg")
cur = conn.cursor()

# For GeoPackage files: encode geometries in GPB, not SpatiaLite BLOB
cur.execute("SELECT EnableGpkgAmphibiousMode();")

# Acquire the write lock before any DML
cur.execute("BEGIN IMMEDIATE;")

Configure PRAGMA busy_timeout before starting the transaction so SQLite retries locked operations automatically instead of raising OperationalError immediately:

python
# SpatiaLite / GeoPackage context: set before BEGIN
cur.execute("PRAGMA busy_timeout = 5000;")  # retry for up to 5 s
cur.execute("BEGIN IMMEDIATE;")

Step 2 — Set a savepoint around risky batch operations

Place a savepoint before any bulk insert or coordinate transformation batch. This creates a rollback boundary that can be discarded without aborting the outer transaction.

python
# SpatiaLite context: savepoint for bulk geometry import
cur.execute("SAVEPOINT bulk_import;")

try:
    for record in incoming_features:
        cur.execute(
            """
            INSERT INTO survey_points (feature_id, geom, status)
            VALUES (?, GeomFromText(?, 4326), ?)
            """,
            (record["id"], record["wkt"], "pending"),
        )

    # Validate the entire batch before releasing
    cur.execute(
        """
        SELECT COUNT(*) FROM survey_points
        WHERE status = 'pending'
          AND (NOT ST_IsValid(geom) OR ST_SRID(geom) != 4326)
        """
    )
    invalid_count = cur.fetchone()[0]

    if invalid_count > 0:
        raise ValueError(f"{invalid_count} invalid geometries in batch")

    cur.execute("RELEASE SAVEPOINT bulk_import;")

except Exception as exc:
    # Discard the batch; outer transaction (BEGIN IMMEDIATE) stays open
    cur.execute("ROLLBACK TO SAVEPOINT bulk_import;")
    print(f"Batch discarded, outer transaction still active: {exc}")

Step 3 — Run a full pre-commit validation sweep

Before issuing COMMIT, run a broader validation pass covering SRID consistency, NULL geometries, and any domain-specific constraints. Catching violations here is far cheaper than repairing a committed but corrupt file.

python
# SpatiaLite / GeoPackage context: pre-commit validation
VALIDATION_SQL = """
    SELECT
        feature_id,
        CASE WHEN geom IS NULL        THEN 'null_geometry'
             WHEN NOT ST_IsValid(geom) THEN 'invalid_geometry'
             WHEN ST_SRID(geom) != 4326 THEN 'wrong_srid'
             ELSE 'ok'
        END AS issue
    FROM survey_points
    WHERE geom IS NULL
       OR NOT ST_IsValid(geom)
       OR ST_SRID(geom) != 4326;
"""

cur.execute(VALIDATION_SQL)
violations = cur.fetchall()

if violations:
    cur.execute("ROLLBACK;")
    for fid, issue in violations:
        print(f"  {fid}: {issue}")
    raise RuntimeError("Transaction aborted — geometry violations found (see above).")

cur.execute("COMMIT;")

Step 4 — Refresh spatial indexes after commit

The R-tree virtual tables that back spatial queries (described in the GeoPackage Specification Deep Dive) do not automatically reflect bulk inserts when SpatiaLite writes go through the raw geometry column. Rebuild them after every significant batch commit.

python
# SpatiaLite context: rebuild statistics + recover index
cur.execute("SELECT UpdateLayerStatistics('survey_points', 'geom');")
cur.execute("SELECT RecoverSpatialIndex('survey_points', 'geom');")

# GeoPackage context (mod_spatialite with GeoPackage support):
# cur.execute("SELECT gpkgAddSpatialIndex('survey_points', 'geom');")

For the performance implications of index rebuild frequency, see Connection Pooling & Lifecycle Management.

Step 5 — Wrap everything in a context manager

Manual BEGIN/COMMIT/ROLLBACK calls scattered across a long script are error-prone. A context manager guarantees the connection is cleaned up and the transaction is either committed or rolled back, even when exceptions interrupt execution mid-write.

python
# SpatiaLite / GeoPackage context: reusable transaction context manager
from contextlib import contextmanager
import sqlite3

@contextmanager
def spatial_transaction(db_path: str, gpkg: bool = False):
    """
    Yields a cursor inside a BEGIN IMMEDIATE transaction.
    Rolls back and closes on any exception; commits and closes on clean exit.
    Set gpkg=True for GeoPackage files to enable amphibious GPB encoding.
    """
    conn = sqlite3.connect(db_path, isolation_level=None)
    conn.enable_load_extension(True)
    conn.load_extension("mod_spatialite")
    cur = conn.cursor()
    try:
        cur.execute("PRAGMA busy_timeout = 5000;")
        if gpkg:
            cur.execute("SELECT EnableGpkgAmphibiousMode();")
        cur.execute("BEGIN IMMEDIATE;")
        yield cur
        cur.execute("COMMIT;")
    except Exception as exc:
        # Guard: only ROLLBACK if a transaction is actually open.
        # If BEGIN IMMEDIATE itself failed (e.g. SQLITE_BUSY), an unconditional
        # ROLLBACK raises "no transaction is active" and hides the real error.
        if conn.in_transaction:
            cur.execute("ROLLBACK;")
        raise RuntimeError(f"Transaction rolled back: {exc}") from exc
    finally:
        conn.close()

# Usage — GeoPackage write
with spatial_transaction("offline_cache.gpkg", gpkg=True) as cur:
    cur.execute(
        "INSERT INTO field_logs (ts, note) VALUES (datetime('now'), ?);",
        ("Checkpoint A",),
    )

Validation & Verification

After a transaction commits, confirm correctness with these copy-pasteable commands.

Verify geometry validity and SRID uniformity:

python
# SpatiaLite / GeoPackage context: post-commit health check
cur.execute("""
    SELECT
        COUNT(*)                                     AS total,
        SUM(CASE WHEN NOT ST_IsValid(geom) THEN 1 ELSE 0 END) AS invalid,
        COUNT(DISTINCT ST_SRID(geom))                AS srid_count
    FROM survey_points;
""")
total, invalid, srid_count = cur.fetchone()
assert invalid == 0,     f"{invalid} invalid geometries remain after commit"
assert srid_count == 1,  f"Mixed SRIDs detected ({srid_count} distinct values)"
print(f"OK: {total} features, all valid, single SRID")

Verify the spatial index is populated (SpatiaLite):

sql
-- SpatiaLite context: idx_ virtual table should have same row count as base table
SELECT COUNT(*) FROM idx_survey_points_geom;

Inspect gpkg_contents metadata after a GeoPackage write:

sql
-- GeoPackage context: confirm bounding box and last_change were updated
SELECT table_name, data_type, min_x, min_y, max_x, max_y, last_change
FROM gpkg_contents
WHERE table_name = 'survey_points';

For reading and validating spatial metadata programmatically, see Reading Spatial Metadata with Python.


Common Failure Modes & Fixes

1. sqlite3.OperationalError: no such function: ST_IsValid

Cause: mod_spatialite was not loaded before executing spatial functions, or the extension call failed silently.

Diagnosis:

python
cur.execute("SELECT spatialite_version();")
print(cur.fetchone())  # None means the extension is not loaded

Fix: Always call conn.enable_load_extension(True) before conn.load_extension("mod_spatialite"). Check the .so / .dylib path with find /usr -name "mod_spatialite*" 2>/dev/null.


2. SQLITE_BUSY: database is locked on BEGIN IMMEDIATE

Cause: Another process (QGIS, a background sync agent, or a previous crashed script) holds a lock on the file.

Diagnosis:

bash
# Check for open file handles on Linux
fuser field_survey.gpkg

Fix: Set PRAGMA busy_timeout before the BEGIN. If the file is locked by a crashed process, the lock clears when that process exits. For resilient retry logic in offline deployments, see Implementing Connection Retries for Offline Apps.


3. Geometries committed but unreadable by QGIS or GDAL

Cause: Geometries were written as SpatiaLite internal BLOBs into a .gpkg file without enabling EnableGpkgAmphibiousMode(). Other OGC tools expect GeoPackage Binary encoding.

Diagnosis:

sql
-- GeoPackage context: inspect first 4 bytes of geometry BLOB
SELECT HEX(SUBSTR(geom, 1, 4)) FROM survey_points LIMIT 1;
-- GPB starts with 4750 0003 (GP magic bytes); SpatiaLite BLOB starts with 0000 0000 or 0001

Fix: Re-enable amphibious mode and re-insert the affected rows within a new transaction, or use ST_AsBinary + GeomFromWKB to re-encode in place.


4. R-tree spatial index returns stale results after bulk insert

Cause: The idx_<table>_<geom> virtual table was not refreshed after COMMIT. This causes bounding-box queries to miss newly inserted features or return deleted ones.

Diagnosis:

sql
-- SpatiaLite context: compare base table count vs index count
SELECT
    (SELECT COUNT(*) FROM survey_points)          AS base_count,
    (SELECT COUNT(*) FROM idx_survey_points_geom) AS index_count;

Fix:

python
cur.execute("SELECT UpdateLayerStatistics('survey_points', 'geom');")
cur.execute("SELECT RecoverSpatialIndex('survey_points', 'geom');")

5. WAL file grows unbounded after long-running transactions

Cause: WAL mode accumulates changes in the -wal companion file between checkpoints. Long transactions prevent the checkpoint from advancing, and the file grows without bound.

Diagnosis:

python
cur.execute("PRAGMA wal_checkpoint(PASSIVE);")
print(cur.fetchone())  # (frames_in_wal, frames_checkpointed, ...)

Fix: Issue a PRAGMA wal_checkpoint(TRUNCATE); after major batch commits. For WAL mode tuning specific to offline mobile deployments, review Managing Large Spatial Datasets in Memory.


Performance Notes

Transaction granularity: A single BEGIN IMMEDIATE wrapping 50,000 geometry inserts is orders of magnitude faster than 50,000 auto-committed single-row inserts. The file-level lock is acquired once; WAL sync writes are batched to a single COMMIT.

Page cache and geometry size: Large polygon geometries (high vertex counts) can exceed SQLite’s default 4 KB page size. Increase it before creating a new database to reduce internal page splits:

sql
-- Must be set before any table is created; cannot be changed after
PRAGMA page_size = 16384;
PRAGMA cache_size = -32000;  -- 32 MB in-memory page cache

PRAGMA synchronous trade-off: The default FULL mode fsyncs at every commit, guaranteeing durability at the cost of throughput. For bulk import pipelines where a crash requires a full re-import anyway, NORMAL reduces sync overhead while WAL mode still protects against corruption:

python
cur.execute("PRAGMA journal_mode=WAL;")
cur.execute("PRAGMA synchronous=NORMAL;")

Do not drop synchronous below NORMAL (OFF) on production databases — a power failure mid-COMMIT will corrupt the file.


Child Pages