Read-Only WAL Mode for Distributed Field Devices
Before distributing a reference GeoPackage that devices may only read, checkpoint and strip its -wal file, ship it in a rollback-journal state, and open it with PRAGMA query_only = ON plus the immutable=1 or mode=ro URI parameters so no device can ever mutate the shared copy.
This page isolates one operational corner of the Security Boundaries & Access Controls guide: the awkward interaction between write-ahead logging and read-only distribution. It is deliberately narrower than the full field-deployment pipeline in securing GeoPackage files for field use — here the concern is a base map or reference layer that many devices consume identically and none should change.
Why This Matters
When you push one canonical GeoPackage — a road network, a parcel base map, a survey control grid — to a fleet of tablets, “read-only” has to mean read-only at every layer, or the copies silently diverge. A single accidental UPDATE on one device makes that device’s answers disagree with the rest of the fleet, and there is no server to reconcile them because these devices are offline by design.
The subtlety is that WAL mode — normally the best choice for concurrency — assumes the database directory is writable, because opening a WAL database creates the -wal and -shm sidecars even for a read. If you ship a WAL-mode GeoPackage to a read-only mount or a device that mounts the media read-only, the very first connection fails because SQLite cannot create those sidecars. So the goal is twofold: neutralize WAL for the distributed copy, and stack runtime and filesystem controls so a read stays a read.
Prerequisites
- Python 3.9+ with the standard-library
sqlite3module (GDAL/OGR optional for validation) - A source
.gpkgstaged on a writable host where you can run the checkpoint - Ability to set filesystem permissions on the destination (
chmod, read-only mount, or Android app-private storage) - Understanding of SQLite transaction scoping and journal modes
- The file header knowledge to confirm the shipped file is in the journal mode you expect
Primary Method
Prepare the file on the staging host, then open it read-only on the device. Preparation checkpoints the WAL back into the main database and switches the persisted journal mode to DELETE, so the distributed file needs no writable sidecar. The device then opens it with an immutable URI and query_only as belt-and-braces.
# GeoPackage context: prepare a read-only reference file, then open it safely.
import os
import sqlite3
def prepare_readonly_reference(src_path: str) -> None:
"""Fold the WAL back in and switch the file to DELETE journal mode.
Run on the writable staging host BEFORE distribution. Afterwards the file
carries no -wal sidecar, so it can live on a read-only mount.
"""
if not os.path.exists(src_path):
raise FileNotFoundError(src_path)
conn = sqlite3.connect(src_path)
# Fold every committed page from the WAL into the main database file.
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
# Persist a journal mode that needs no sidecar on open.
conn.execute("PRAGMA journal_mode = DELETE")
conn.commit()
conn.close()
def open_readonly_reference(path: str) -> sqlite3.Connection:
"""Open a distributed reference GeoPackage with no possibility of writes."""
# immutable=1 promises SQLite the file cannot change, so it skips creating
# -wal/-shm and skips locking entirely — ideal for a read-only mount.
uri = f"file:{path}?immutable=1&mode=ro"
conn = sqlite3.connect(uri, uri=True)
# query_only rejects any INSERT/UPDATE/DELETE at the SQL layer as well.
conn.execute("PRAGMA query_only = ON")
return conn
mode=ro opens the file read-only through SQLite’s VFS; immutable=1 goes further and tells SQLite the bytes will never change under it, letting it skip lock and sidecar handling that a read-only directory cannot satisfy. PRAGMA query_only = ON is the third, independent guard at the SQL layer.
Step-by-Step Walkthrough
1. Decide how the WAL leaves the building
You have two shippable end states. Either checkpoint and switch to DELETE journal mode (shown above), or ship the file with WAL removed but leave the device to open it immutable. The decision below drives everything else.
2. Checkpoint the write-ahead log
PRAGMA wal_checkpoint(TRUNCATE) copies every committed frame from the -wal file into the database and then shrinks the log to zero bytes, so no committed data is stranded in a sidecar you are about to delete.
# GeoPackage context: empty the WAL so nothing is stranded in the sidecar.
conn = sqlite3.connect("basemap.gpkg")
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.close()
3. Persist a sidecar-free journal mode
Switching to DELETE mode is stored in the database header, so consuming devices do not attempt to create a -wal. This is what makes the file openable from a directory the device cannot write.
conn = sqlite3.connect("basemap.gpkg")
mode = conn.execute("PRAGMA journal_mode = DELETE").fetchone()[0]
assert mode == "delete", f"journal mode is {mode}, expected delete"
conn.close()
4. Enforce read-only at runtime with query_only
On the device, PRAGMA query_only = ON makes the connection reject any write statement with a clear error instead of silently succeeding against a local copy. It is a per-connection guard, so set it right after opening.
conn = open_readonly_reference("/data/app/basemap.gpkg")
conn.execute("PRAGMA query_only = ON")
# Reads work; a write now raises OperationalError.
conn.execute("SELECT count(*) FROM gpkg_contents").fetchone()
5. Harden the file permissions
Strip every write bit so even a process that ignores query_only cannot alter the bytes. On POSIX devices this is chmod 444; on Android, place the file in app-private storage where the OS enforces isolation.
import os
import stat
def make_readonly(path: str) -> None:
"""Remove all write bits so the reference file cannot be modified."""
mode = os.stat(path).st_mode
os.chmod(path, mode & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH))
Verification
Prove that reads succeed and writes are rejected on the prepared file.
# Python context: a distributed reference file must read but never write.
import sqlite3
def verify_readonly(path: str) -> bool:
conn = sqlite3.connect(f"file:{path}?immutable=1&mode=ro", uri=True)
conn.execute("PRAGMA query_only = ON")
# A read must succeed.
conn.execute("SELECT count(*) FROM gpkg_contents").fetchone()
# A write must be rejected.
try:
conn.execute("CREATE TABLE _probe(x)")
conn.close()
return False # Write succeeded — file is NOT read-only.
except sqlite3.OperationalError:
conn.close()
return True
assert verify_readonly("basemap.gpkg"), "reference file is writable"
Confirm the shipped journal mode from the command line so no -wal sidecar is expected:
# Shell context: the distributed file should report 'delete', not 'wal'.
sqlite3 basemap.gpkg "PRAGMA journal_mode;"
Alternative Approaches or Edge Cases
Keeping WAL for on-device read concurrency. If a device runs several reader threads and you genuinely want WAL’s concurrent reads, you cannot ship a read-only directory — WAL needs to create -shm. Put the file in a writable app-private directory instead, and rely on PRAGMA query_only = ON plus chmod 444 on the .gpkg itself (leaving the directory writable so the sidecars can appear). This trades a strictly read-only mount for in-process read scaling.
Immutable on truly read-only media. When the file lives on a read-only mount, a CD image, or an OS-enforced read-only partition, immutable=1 is mandatory: without it SQLite tries to take a lock the filesystem cannot grant and fails with unable to open database file. Never set immutable=1 on a file that some other process might still change — SQLite will return stale or corrupt reads because it assumes the bytes are frozen.
Combining with encryption. A read-only reference layer and an encrypted container are orthogonal controls and stack cleanly — open the file with the SQLCipher key first, then apply the same query_only and immutable flags, as covered in the SQLCipher encryption walkthrough.
Troubleshooting
OperationalError: unable to open database file on a read-only mount Cause: the file is still in WAL mode, so SQLite tries to create -wal/-shm in a directory it cannot write. Fix: run PRAGMA wal_checkpoint(TRUNCATE) then PRAGMA journal_mode = DELETE on the staging host, or open with immutable=1.
OperationalError: attempt to write a readonly database Cause: a write statement reached a connection opened with mode=ro or guarded by query_only. This is the control working as intended — fix the calling code to route writes to a separate, writable working copy rather than the shared reference file.
Stale rows after the reference file was updated in place Cause: a device opened the file with immutable=1 while the staging host overwrote it, so SQLite kept serving cached, frozen bytes. Fix: distribute updates as a new file path or bump a version in the filename, and only mark a file immutable=1 once it will never change under an open connection.
Related
- Security Boundaries & Access Controls — parent guide to the spatial SQLite security model
- Transaction Scoping & Rollback Strategies — journal modes, WAL internals, and commit boundaries
- Safe Temp-File Handling for SpatiaLite on Android — where WAL and temp sidecars are allowed to live on a device
- Securing GeoPackage Files for Field Use — the full checksum, permission, and encryption deployment pipeline