Safe Temp-File Handling for SpatiaLite on Android
Put the SpatiaLite database and every sidecar it spawns — -wal, -shm, -journal, and sort spill files — inside the app-private directory (cacheDir or filesDir), point SQLITE_TMPDIR and PRAGMA temp_store there too, and never let any of them land on shared external storage.
This page narrows the Security Boundaries & Access Controls guide down to one platform-specific hazard: on Android, SQLite writes several temporary files next to (or elsewhere from) your main database, and if any of them leak onto shared storage the sandbox that protects your spatial data is defeated. It complements the runtime hardening in securing GeoPackage files for field use by focusing on the transient files rather than the container itself.
Why This Matters
SpatiaLite is SQLite with the spatial extension loaded, and SQLite is chatty on disk. In WAL mode it maintains a -wal write-ahead log and a -shm shared-memory index alongside the database; in rollback mode it writes a -journal; and any large ORDER BY or R-tree rebuild can spill a temporary sort file to whatever directory SQLite considers “temp”. Each of those files contains real geometry and attribute bytes.
Android isolates each app in a private sandbox under internal storage, where files are readable only by that app’s UID. Shared external storage — the legacy /sdcard world — has no such guarantee: other apps and the user can read it, and on removable media the bytes survive the app’s uninstall. If a sort spill or a -wal file is written there, sensitive survey data is exposed even when the main .gpkg is carefully encrypted. Scoped storage, enforced since Android 10, tightens this further: apps can no longer freely write arbitrary paths on shared volumes, so a mis-targeted temp directory does not just leak, it can fail outright with a permissions error.
Prerequisites
- An Android app embedding Python via Chaquopy or BeeWare, or a native layer using
android.database.sqlite - SpatiaLite /
mod_spatialitebundled for the target ABIs (arm64-v8a, x86_64) - Access to the app context so you can resolve
context.getCacheDir()andcontext.getFilesDir() - Target SDK 29+ so scoped storage rules apply
- Familiarity with the SpatiaLite metadata tables and how the R-tree spatial index spills to temp during rebuilds
Primary Method
The reliable pattern is to derive every path from the app context, keep the database in filesDir, and force all transient files into a temp subdirectory of cacheDir. The example runs under Chaquopy, where the app context is passed into Python; the PRAGMAs and environment variable are identical for any embedding.
# SpatiaLite context: keep every sidecar and temp file inside the Android sandbox.
import os
import sqlite3
def open_sandboxed_spatialite(files_dir: str, cache_dir: str, db_name: str):
"""Open a SpatiaLite database with all temp/sidecar files kept app-private.
Args:
files_dir: context.getFilesDir().getAbsolutePath() — persistent, private.
cache_dir: context.getCacheDir().getAbsolutePath() — transient, private.
db_name: File name of the database, e.g. "field.gpkg".
"""
db_path = os.path.join(files_dir, db_name)
# Force SQLite's temp directory into a private cache subfolder.
tmp_dir = os.path.join(cache_dir, "sqlite_tmp")
os.makedirs(tmp_dir, exist_ok=True)
os.environ["SQLITE_TMPDIR"] = tmp_dir # read at connection time
conn = sqlite3.connect(db_path)
conn.enable_load_extension(True)
conn.load_extension("mod_spatialite")
# temp_store = 2 (MEMORY) avoids temp files entirely for small spills;
# for large spills that must hit disk, temp_store_directory (deprecated but
# still honored) and SQLITE_TMPDIR both point inside the sandbox.
conn.execute("PRAGMA temp_store = 2")
conn.execute(f"PRAGMA temp_store_directory = '{tmp_dir}'")
# WAL sidecars (-wal, -shm) live next to db_path, i.e. inside filesDir.
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA synchronous = NORMAL")
return conn
Because -wal and -shm are always created in the same directory as the database file, placing the database in filesDir automatically keeps those two sidecars private. SQLITE_TMPDIR and PRAGMA temp_store_directory handle the separate case of sort/index spill files, which SQLite otherwise tries to write to a system temp path that may be unwritable under scoped storage.
Step-by-Step Walkthrough
1. Never derive paths from external storage
The single most important rule: resolve paths from getFilesDir() / getCacheDir(), not from Environment.getExternalStorageDirectory(). Passing an external path into Python is how spatial data leaks.
# Correct: sandbox paths handed down from the Android context.
db = open_sandboxed_spatialite(files_dir, cache_dir, "field.gpkg")
# Wrong: anything under /sdcard, /storage/emulated/0, or getExternalFilesDir
# for temp — those bytes may be world-readable or blocked by scoped storage.
2. Send in-memory spills to RAM, on-disk spills to cache
PRAGMA temp_store = 2 keeps small temporary b-trees in memory. Large operations — rebuilding a spatial index over hundreds of thousands of rows — can still exceed memory and spill to disk, which is exactly why SQLITE_TMPDIR must already point inside cacheDir.
# SpatiaLite context: a spatial index rebuild may spill a temp file to disk.
db.execute("SELECT DisableSpatialIndex('parcels', 'geom')")
db.execute("SELECT CreateSpatialIndex('parcels', 'geom')")
3. Checkpoint and truncate the WAL before backgrounding
When the app goes to the background, checkpoint the WAL so its contents fold back into the main database and the -wal file shrinks. This limits how much live data sits in the sidecar.
# SpatiaLite context: fold the WAL back into the database on pause.
db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
4. Clean up sidecars on close
After a clean close SQLite removes -wal and -shm itself, but a killed process can orphan them. Sweep the sandbox on startup and delete stragglers whose parent database is closed.
import os
def sweep_sidecars(files_dir: str, db_name: str) -> None:
"""Remove orphaned WAL/journal sidecars for a closed database."""
for suffix in ("-wal", "-shm", "-journal"):
stray = os.path.join(files_dir, db_name + suffix)
if os.path.exists(stray):
os.remove(stray)
Verification
Confirm that no SpatiaLite artifact was written outside the sandbox. List the app-private directories and assert the system temp path stayed empty.
# Python context: prove every SpatiaLite artifact lives inside the sandbox.
import os
def assert_sandboxed(files_dir: str, cache_dir: str) -> None:
private_roots = (files_dir, cache_dir)
for root in private_roots:
for name in os.listdir(root):
print("sandbox artifact:", os.path.join(root, name))
# The default system temp must not hold any SQLite spill files.
system_tmp = os.environ.get("TMPDIR", "/data/local/tmp")
strays = [f for f in os.listdir(system_tmp) if "sqlite" in f.lower()]
assert not strays, f"SQLite temp leaked outside sandbox: {strays}"
On a connected device you can cross-check from the host with adb:
# Shell context: only app-private storage should contain SpatiaLite sidecars.
adb shell run-as com.example.fieldapp ls -l files cache/sqlite_tmp
Alternative Approaches or Edge Cases
Sharing an exported file with another app. When a technician needs to hand a .gpkg to another app, do not copy it to /sdcard. Expose it through a FileProvider content URI so the OS grants a scoped, revocable read grant while the bytes stay in your sandbox. The diagram contrasts the safe and unsafe placements.
BeeWare / native SQLite parity. Under BeeWare’s Rubicon bridge or a pure Kotlin layer, the same rules apply: open the database with an absolute path under filesDir, and set the process environment SQLITE_TMPDIR before the first connection. The PRAGMA names are identical because they belong to SQLite, not to any binding.
Encrypted databases still spill plaintext. Even a SQLCipher-encrypted database decrypts pages into temp files during sorts. Keeping temp_store = 2 (memory) is therefore a confidentiality control, not just a performance one — pair it with the encryption steps in the SQLCipher walkthrough.
Troubleshooting
OperationalError: database or disk is full during a spatial index rebuild Cause: SQLITE_TMPDIR points at a path with little free space, or scoped storage blocked the write and SQLite fell back to a tiny partition. Fix: set SQLITE_TMPDIR to cacheDir/sqlite_tmp, and prefer PRAGMA temp_store = 2 so small spills stay in RAM.
disk I/O error opening the database after an upgrade to Android 10+ Cause: the app still resolves the database or temp path from legacy external storage, which scoped storage now denies. Fix: move the database into filesDir and re-point every temp path to cacheDir; do not request MANAGE_EXTERNAL_STORAGE to work around it.
Orphaned -wal file keeps growing across sessions Cause: the process is killed before a checkpoint, so the WAL never folds back. Fix: run PRAGMA wal_checkpoint(TRUNCATE) on pause and call sweep_sidecars() on startup to clear orphans left by a crash.
Related
- Security Boundaries & Access Controls — parent guide to the spatial SQLite security model
- Read-Only WAL Mode for Distributed Field Devices — why WAL sidecars need special care when a device may only read
- How to Encrypt a GeoPackage with SQLCipher in Python — encrypting the container the sidecars belong to
- Reading Spatial Metadata with Python — inspecting the metadata that index rebuilds touch