Scripting Batch Spatial Queries with the sqlite3 CLI
Feed a whole block of spatial SQL to one sqlite3 (or spatialite) process through a quoted heredoc — loading mod_spatialite, wrapping the statements in BEGIN;/COMMIT;, and redirecting results with .output — so a batch that would take minutes as separate invocations runs in a single transaction in seconds.
This page is part of the SpatiaLite CLI & Shell Automation guide. Its scope is query and SQL batching — running many spatial statements non-interactively and capturing their output — as distinct from format conversion, which the ogr2ogr guide covers.
Why This Matters
The slowest way to run a thousand spatial updates is a shell loop that invokes sqlite3 a thousand times. Each invocation starts a process, opens the file, commits its own transaction, and flushes to disk — so the fixed per-call overhead, not the SQL, dominates the runtime. Batching flips that: one process, one transaction, one flush at the end. On field gateways and CI runners where the same maintenance query runs nightly over a growing GeoPackage, that difference is the gap between a job that finishes before the next collection shift and one that is still running when the surveyors arrive.
Non-interactive batching is also what makes spatial SQL reproducible. A heredoc or a .read file is a versioned artifact you can diff, review, and replay, whereas commands typed into an interactive shell vanish with the terminal. Every technique here reads its SQL from standard input or a file and returns an exit code the calling script can act on.
Prerequisites
- SQLite 3.37+ with the
sqlite3shell onPATH, or thespatialiteshell from the SpatiaLite distribution mod_spatialitereachable on the library search path if you use the plainsqlite3shell for spatial functions- A GeoPackage or SpatiaLite file with at least one spatial layer to query
- A POSIX shell for heredocs and
set -euo pipefail - Familiarity with SpatiaLite metadata tables so batched schema changes register geometry columns correctly
Primary Method
The core pattern is a quoted heredoc piped into a single shell process. Loading the extension, enabling WAL mode, opening a transaction, running every statement, and committing all happen in one session against one open file handle.
# One process, one transaction: a full batch of spatial SQL via a quoted heredoc
set -euo pipefail
DB=field.gpkg
sqlite3 "$DB" <<'SQL'
.load mod_spatialite
PRAGMA journal_mode=WAL;
BEGIN;
UPDATE parcels
SET area_m2 = ST_Area(geom)
WHERE area_m2 IS NULL;
UPDATE parcels
SET geom = ST_MakeValid(geom)
WHERE ST_IsValid(geom) = 0;
DELETE FROM parcels WHERE geom IS NULL;
COMMIT;
SELECT COUNT(*) AS remaining FROM parcels;
SQL
The quoted delimiter <<'SQL' passes the block to sqlite3 verbatim, so $ and backticks inside the SQL are not touched by the shell. BEGIN; and COMMIT; bracket every mutation into one durable transaction, and with set -e the script aborts if any statement raises an error before the commit.
Step-by-Step Walkthrough
1. Load spatial functions
The spatialite shell has spatial functions built in, so it needs no load step. The plain sqlite3 shell needs .load mod_spatialite as the first line before any ST_* call. Choose one and stay consistent within a script.
# spatialite shell — functions preloaded, no .load needed
spatialite field.gpkg "SELECT ST_Area(geom) FROM parcels LIMIT 1;"
# sqlite3 shell — must load the extension first
sqlite3 field.gpkg ".load mod_spatialite" "SELECT ST_Area(geom) FROM parcels LIMIT 1;"
Loading mod_spatialite is the same extension mechanism documented in Extension Compatibility in Spatial SQLite; on most platforms the bare name resolves the correct .so, .dylib, or .dll.
2. Wrap the batch in a single transaction
Placing BEGIN; before the first statement and COMMIT; after the last collapses the batch into one durable write. Without it, each statement commits independently and pays a separate disk flush.
# Every UPDATE inside one transaction commits once, not once per statement
sqlite3 field.gpkg <<'SQL'
BEGIN;
UPDATE parcels SET status = 'reviewed' WHERE district = 'north';
UPDATE parcels SET status = 'reviewed' WHERE district = 'south';
UPDATE parcels SET reviewed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE status = 'reviewed';
COMMIT;
SQL
3. Keep reusable SQL in a file and run it with .read
For SQL you version-control, keep it in a .sql file and run it with .read. The file can itself contain .load, BEGIN/COMMIT, and dot-commands, so the whole batch is self-describing.
-- GeoPackage context — nightly_maintenance.sql, run via .read
.load mod_spatialite
BEGIN;
UPDATE parcels SET geom = ST_MakeValid(geom) WHERE ST_IsValid(geom) = 0;
DELETE FROM _sync_log WHERE changed_at < date('now', '-30 days');
COMMIT;
SELECT COUNT(*) AS parcels FROM parcels;
# Execute the versioned SQL file non-interactively
sqlite3 field.gpkg ".read nightly_maintenance.sql"
4. Capture results into a file with .mode and .output
To write query results to a file, set the output format with .mode, turn on .headers, redirect with .output, run the query, then restore .output stdout. Everything printed between the two .output directives lands in the file.
# Export a spatial query result to CSV from inside a heredoc
sqlite3 field.gpkg <<'SQL'
.load mod_spatialite
.headers on
.mode csv
.output large_parcels.csv
SELECT fid, ST_Area(geom) AS area_m2
FROM parcels
WHERE ST_Area(geom) > 10000
ORDER BY area_m2 DESC;
.output stdout
SQL
For a single ad-hoc query, the command-line flags -csv -header do the same thing without a heredoc: sqlite3 -csv -header field.gpkg "SELECT ..." > out.csv.
5. Bulk-load rows with .import
When a batch needs to ingest many rows from a delimited file, .import loads them directly into a table far faster than generating per-row INSERT statements. Set .mode csv so commas and quoting are parsed correctly, and --skip 1 to drop a header line.
# Bulk-load attributes into a staging table, then merge in one transaction
sqlite3 field.gpkg <<'SQL'
.mode csv
.import --skip 1 incoming_attrs.csv attr_staging
BEGIN;
UPDATE parcels
SET status = (SELECT s.status FROM attr_staging s WHERE s.fid = parcels.fid)
WHERE fid IN (SELECT fid FROM attr_staging);
COMMIT;
DROP TABLE attr_staging;
SQL
6. Check the exit code
sqlite3 returns non-zero when a statement fails, so set -euo pipefail makes the script abort automatically. When you need to branch on the outcome rather than abort, capture the result and test it.
#!/usr/bin/env bash
# Fail the run if the batch did not leave the database in a valid state
set -euo pipefail
DB=field.gpkg
sqlite3 "$DB" ".read nightly_maintenance.sql"
if ! sqlite3 "$DB" "PRAGMA integrity_check;" | grep -q '^ok$'; then
echo "post-batch integrity check failed" >&2
exit 1
fi
echo "batch complete, database valid"
Verification
After a batch runs, confirm the database is intact and the intended rows changed. PRAGMA integrity_check reads the raw b-tree, and a targeted count confirms the batch touched what you expected.
# Structural integrity plus a targeted result check
sqlite3 field.gpkg <<'SQL'
PRAGMA integrity_check;
SELECT COUNT(*) AS reviewed FROM parcels WHERE status = 'reviewed';
SELECT COUNT(*) AS invalid_geom FROM parcels WHERE ST_IsValid(geom) = 0;
SQL
An integrity_check that prints ok, the expected reviewed count, and zero invalid_geom rows together confirm the batch committed cleanly and repaired the geometries it was meant to. If invalid_geom is non-zero, the ST_MakeValid step either did not run or the extension was not loaded when it ran.
Alternative Approaches or Edge Cases
Splitting an oversized batch. One transaction is fastest, but a batch that modifies millions of rows holds a write lock for its entire duration and grows the -wal sidecar until it commits. When that lock duration is a problem on a live field database, chunk the work into several transactions of a few thousand rows each, checkpointing between them:
# Chunked commits keep the WAL bounded and release the lock periodically
set -euo pipefail
for offset in 0 5000 10000 15000; do
sqlite3 field.gpkg <<SQL
BEGIN;
UPDATE parcels SET area_m2 = ST_Area(geom)
WHERE fid IN (SELECT fid FROM parcels ORDER BY fid LIMIT 5000 OFFSET $offset);
COMMIT;
PRAGMA wal_checkpoint(TRUNCATE);
SQL
done
Note this heredoc is unquoted (<<SQL) precisely because it needs the shell to substitute $offset; keep the interpolated value numeric to avoid injecting SQL.
Driving the same SQL from Python. When a batch needs branching logic, error handling per statement, or values computed in Python, run the identical SQL through the sqlite3 module instead of the shell — the approach in Native sqlite3 Spatial Extensions. The SQL is the same; only the harness differs.
Troubleshooting
Error: no such function: ST_Area
Cause: The spatial extension was not loaded in the session. In the plain sqlite3 shell, ST_* functions do not exist until mod_spatialite is loaded, and a .load line in one invocation does not carry over to a separate invocation.
Fix: Put .load mod_spatialite as the first line of the same heredoc or .sql file that calls the spatial functions, or switch to the spatialite shell, which preloads them. Confirm the load worked with SELECT spatialite_version(); at the top of the batch.
Error: database is locked
Cause: Another process — often an overlapping scheduled run of the same script — holds the write lock, or a long transaction in this batch has not committed.
Fix: Guard scheduled batches with an flock lock file so runs cannot overlap, and keep transactions short enough to release the lock promptly. Setting PRAGMA busy_timeout=5000; at the top of the batch makes sqlite3 wait up to five seconds for the lock instead of failing immediately. See Transaction Scoping & Rollback Strategies for retry patterns.
Error: near line 1: unrecognized token after variable expansion
Cause: An unquoted heredoc (<<SQL) let the shell expand a $ inside the SQL that was meant to be literal, producing malformed SQL by the time sqlite3 parsed it.
Fix: Quote the delimiter (<<'SQL') so the block is passed verbatim. Use the unquoted form only when you deliberately interpolate a shell value, and keep interpolated values simple and validated to avoid injection.
Frequently Asked Questions
Is a heredoc or a .read file better for batching SQL?
Use a .read file when the SQL is stable, reusable, and worth version-controlling — a nightly maintenance batch or a migration you review and replay. Use a heredoc when the SQL is generated or embedded in a one-off orchestration script and does not warrant its own file. Both run the same statements in the same session, so the choice is about maintainability, not performance. A common hybrid keeps stable logic in .read files and calls them from a heredoc that adds run-specific parameters.
How much faster is one transaction than separate statements?
The gain scales with how many separate commits you avoid, because each independent commit forces a disk flush that a single wrapping transaction pays only once. On a batch of thousands of small updates against local storage, consolidating into one BEGIN;/COMMIT; commonly turns a multi-minute run into a sub-second one. The improvement is even larger on network or removable storage, where each flush is slower, which is exactly the storage field devices tend to use.
Related
- SpatiaLite CLI & Shell Automation — parent guide: the full command-line toolchain, dot-commands, and exit-code handling
- How to Convert Shapefiles with ogr2ogr on the Command Line — the format-conversion counterpart to this query-batching guide
- Native sqlite3 Spatial Extensions — run the same spatial SQL from Python when a batch needs branching logic
- Transaction Scoping & Rollback Strategies — transaction boundaries, WAL mode, and busy-timeout retries for safe batched writes