SpatiaLite CLI & Shell Automation for SpatiaLite & GeoPackage

Every SpatiaLite and GeoPackage file is a plain SQLite database on disk, which means the fastest path to automating it is often not a Python import but a…

Every SpatiaLite and GeoPackage file is a plain SQLite database on disk, which means the fastest path to automating it is often not a Python import but a four-tool command-line stack — sqlite3, spatialite, ogr2ogr, and ogrinfo — wired together in a shell script that fails loudly and exits with a meaningful code. This guide, part of the CLI Automation & Offline Sync Pipelines section, shows how to script those tools reliably: heredocs and .read for batched SQL, dot-commands for import and export, set -euo pipefail for exit-code discipline, single-transaction batching for speed, and pipes that stream one tool’s output straight into the next.

The techniques here run identically on a developer laptop, a CI runner, and a headless field gateway, because none of them depend on an interactive terminal. Where the Python Integration & Database Workflows section drives the same engine through in-process library calls, this guide stays at the shell layer, where a cron job or a systemd timer can invoke it without a Python interpreter loaded at all.

Command-line tool stack for SpatiaLite and GeoPackage automationA shell script at the top drives four command-line tools. sqlite3 and spatialite run SQL against the database file; ogr2ogr converts formats into it; ogrinfo reads it back for validation. All four operate on a single .gpkg or .sqlite file on disk, and each returns an exit code the script checks.Shell Scriptset -euo pipefailsqlite3SQL · PRAGMA · .importspatialiteST_* preloadedogr2ogrformat conversionogrinfoinspect · validateSpatial File.gpkg / .sqlite
The four tools share one file on disk. sqlite3 and spatialite mutate it with SQL, ogr2ogr feeds converted data in, and ogrinfo reads it back as a validation gate. Every arrow is a scriptable step whose exit code the shell must check.

Prerequisites

Confirm each of these before scripting the tools. A missing binary or an unset extension path is the most common reason a script that works interactively fails under cron.

Verify the whole toolchain in one pass before wiring it into a pipeline:

bash
# Confirm all four command-line tools resolve and report their versions
set -euo pipefail
for tool in sqlite3 spatialite ogr2ogr ogrinfo; do
    command -v "$tool" >/dev/null 2>&1 \
        || { echo "missing required tool: $tool" >&2; exit 1; }
done
sqlite3 --version
ogr2ogr --version

If spatialite is absent but sqlite3 is present, you can still run spatial SQL by loading mod_spatialite into the plain shell — covered below. If ogr2ogr is missing the GPKG driver, the GDAL build lacks the GeoPackage plugin and must be reinstalled from a distribution that includes it.

Concept & Specification Reference

The four tools divide cleanly into two families: the SQL shells (sqlite3, spatialite) that execute statements against an open database, and the OGR utilities (ogr2ogr, ogrinfo) that treat the database as one format among many.

The Tool Stack

ToolShips withScripting roleReads SQL/args from
sqlite3SQLiteExecute SQL, PRAGMA checks, .import/.dump, integrity verificationargument, heredoc on stdin, or .read file.sql
spatialiteSpatiaLiteSame as sqlite3 with ST_* and metadata functions preloadedargument, heredoc, or .read
ogr2ogrGDALConvert, reproject, clip, and filter between formatscommand-line flags only
ogrinfoGDALInspect layers, counts, geometry types, and CRS for validationcommand-line flags only

Dot-Commands That Matter for Automation

Dot-commands are shell-level directives interpreted by sqlite3/spatialite, not SQL. They configure how the shell reads input and formats output, which is exactly what a non-interactive script needs to control.

Dot-commandEffectTypical automation use
.mode csv / .mode list / .mode columnSets output formattingcsv for machine-readable exports piped to other tools
.headers onEmits a header rowCSV exports that downstream tools parse by column name
.output file.csvRedirects results to a fileCapture a query result without shell redirection
.import data.csv tableBulk-loads a CSV into a tableFast row ingest without per-row INSERT
.read script.sqlExecutes a SQL fileRun a versioned migration or query batch
.dumpEmits SQL to recreate the databasePortable text backup for diffing or transport
.schema tablePrints the CREATE statementsAssert schema in a verification step
.load mod_spatialiteLoads the SpatiaLite extension into sqlite3Spatial functions in the plain shell

Two Ways to Get Spatial Functions

The spatialite shell preloads the entire spatial function catalogue, so ST_Area, CreateSpatialIndex, and InitSpatialMetaData are available immediately. The plain sqlite3 shell does not — it needs an explicit extension load. Loading mod_spatialite with the .load dot-command turns the standard shell into a spatially aware one:

bash
# Load mod_spatialite into the plain sqlite3 shell, then call a spatial function
sqlite3 field.gpkg <<'SQL'
.load mod_spatialite
SELECT ST_AsText(ST_GeomFromText('POINT(11.2 46.1)', 4326));
SQL

Prefer the spatialite shell when it is installed and prefer .load mod_spatialite when you only have the base SQLite tools; the SQL you run afterward is identical. On some platforms the extension is named mod_spatialite.so, mod_spatialite.dylib, or mod_spatialite.dll, and the bare .load mod_spatialite resolves the right one if it sits on the library search path.

Step-by-Step Implementation

1. Run Scripted SQL with a Heredoc

A heredoc feeds a block of SQL to the shell on standard input, so the whole script lives inside your .sh file with no temporary artifacts. Quoting the delimiter as 'SQL' prevents the shell from expanding $ and backticks inside the block — essential when the SQL contains parameters or JSON.

bash
# Initialise spatial metadata and index a table in one scripted session
set -euo pipefail
DB=parcels.sqlite

spatialite "$DB" <<'SQL'
SELECT InitSpatialMetaData(1);
SELECT CreateSpatialIndex('parcels', 'geometry');
SELECT COUNT(*) AS indexed_rows FROM parcels;
SQL

The spatialite shell exits non-zero if any statement raises an error, so with set -e the script aborts on the first failed statement rather than plowing ahead.

2. Keep Reusable SQL in a File and Run It with .read

For SQL you version-control and reuse across runs, keep it in a .sql file and invoke it with .read. This separates logic from orchestration and lets the same migration run from a script, a CI job, or an interactive session.

sql
-- SpatiaLite context — migrate_parcels.sql, run non-interactively via .read
SELECT InitSpatialMetaData(1);
UPDATE parcels SET geometry = ST_MakeValid(geometry)
 WHERE ST_IsValid(geometry) = 0;
SELECT DisableSpatialIndex('parcels', 'geometry');
SELECT CreateSpatialIndex('parcels', 'geometry');
bash
# Execute the versioned SQL file against the database
spatialite parcels.sqlite ".read migrate_parcels.sql"

3. Control Output Format with Dot-Commands

To produce a machine-readable extract, set the mode and headers, redirect with .output, then run the query. Everything printed between .output file and the next .output stdout lands in the file.

bash
# Export a filtered query to CSV using dot-commands
sqlite3 field.gpkg <<'SQL'
.headers on
.mode csv
.output parcels_large.csv
SELECT fid, area_m2 FROM parcels WHERE area_m2 > 10000 ORDER BY area_m2 DESC;
.output stdout
SQL

4. Bulk-Load Rows with .import

.import reads a delimited file directly into a table, bypassing per-row INSERT parsing. It is the fastest way to ingest a large attribute CSV in the shell. Set .mode csv first so the delimiter and quoting are interpreted correctly, and use --skip 1 to drop a header row.

bash
# Bulk-load an attribute CSV into an existing table without per-row INSERTs
sqlite3 field.gpkg <<'SQL'
.mode csv
.import --skip 1 new_attributes.csv attr_staging
SELECT COUNT(*) AS staged FROM attr_staging;
SQL

5. Batch Many Statements into One Transaction

By default each statement sqlite3 executes commits on its own, and the per-commit fsync dominates runtime when you run thousands of them. Wrapping the batch in BEGIN; and COMMIT; collapses it into a single durable transaction, which is the single biggest lever on scripted write throughput.

bash
# Wrap a large batch of updates in one transaction for a large speedup
sqlite3 field.gpkg <<'SQL'
PRAGMA journal_mode=WAL;
BEGIN;
UPDATE parcels SET status = 'reviewed' WHERE surveyor = 'A. Kaur';
UPDATE parcels SET status = 'reviewed' WHERE surveyor = 'M. Diaz';
DELETE FROM parcels WHERE geometry IS NULL;
COMMIT;
SQL

The interaction between transaction scope and durability is covered in depth in Transaction Scoping & Rollback Strategies; the short rule for scripts is one logical unit of work, one transaction.

6. Pipe Query Output Between Tools

Because the SQL shells write to standard output, you can stream a query result straight into another command. Here sqlite3 emits a list of feature ids as CSV, and xargs feeds them to a second command — no temporary file required.

bash
# Stream sqlite3 CSV output directly into a downstream command
set -euo pipefail
sqlite3 -csv -noheader field.gpkg \
    "SELECT fid FROM parcels WHERE status = 'flagged';" \
    | while IFS= read -r fid; do
        echo "reprocessing feature $fid"
    done

The -csv and -noheader flags are the command-line equivalents of the .mode csv and .headers off dot-commands, convenient when you only run a single query and do not need a heredoc.

7. Enforce Exit-Code Discipline

set -euo pipefail makes the shell abort on any command that returns non-zero (-e), on any unset variable (-u), and on a failure anywhere in a pipeline (pipefail). Combine it with an explicit $? check where you need to branch on the specific outcome rather than abort.

bash
#!/usr/bin/env bash
# Abort the whole run if any spatial step fails
set -euo pipefail

DB=field.gpkg
if ! sqlite3 "$DB" "PRAGMA integrity_check;" | grep -q '^ok$'; then
    echo "integrity check failed for $DB" >&2
    exit 1
fi

ogr2ogr -f GPKG -update -append central.gpkg "$DB" parcels
echo "append exit code: $?"

Validation & Verification

After any scripted mutation, prove the database is still well-formed and the spatial structures are intact before shipping it downstream. Two independent tools give you cross-checked confidence: ogrinfo reads the file through the OGR driver, and sqlite3 PRAGMAs read the raw b-tree.

bash
# ogrinfo summary confirms the driver, layer, geometry type, and feature count
ogrinfo -al -so field.gpkg parcels

# Expected excerpt:
# Layer name: parcels
# Geometry: Polygon
# Feature Count: 5231
# Layer SRS WKT: GEOGCS["WGS 84", ...]
bash
# PRAGMA checks confirm structural integrity and foreign-key soundness
sqlite3 field.gpkg <<'SQL'
PRAGMA integrity_check;
PRAGMA foreign_key_check;
SELECT COUNT(*) AS rtree_rows FROM rtree_parcels_geom;
SQL

An integrity_check that prints anything other than ok, or an rtree_ row count that has drifted away from the layer’s feature count, means the file must not be distributed. For the full OGC conformance assertions beyond these smoke checks, see How to Validate GeoPackage OGC Compliance.

Common Failure Modes & Fixes

Extension Load Fails in the Plain sqlite3 Shell

Symptom: Error: unknown command or invalid arguments: "load" or Error: not authorized after .load mod_spatialite.

Cause: The sqlite3 binary was compiled with extension loading disabled (-DSQLITE_OMIT_LOAD_EXTENSION), or mod_spatialite is not on the library search path.

Fix: Use the spatialite shell, which has spatial functions built in and needs no .load. If you must use sqlite3, point the loader at the library directory and confirm the file exists:

bash
# Make the extension discoverable, then load it by bare name
export LD_LIBRARY_PATH="/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
sqlite3 field.gpkg ".load mod_spatialite" "SELECT spatialite_version();"

Heredoc Variables Expand Unexpectedly

Symptom: SQL that references $ or backticks produces empty values or shell errors like bad substitution.

Cause: An unquoted heredoc delimiter (<<SQL) lets the shell expand $var and command substitutions inside the SQL block before sqlite3 ever sees it.

Fix: Quote the delimiter (<<'SQL') to pass the block verbatim. Only leave it unquoted when you deliberately want the shell to interpolate a value, and then interpolate it safely rather than inside a SQL string.

Batch Runs Slowly Despite Few Rows

Symptom: A script issuing thousands of sqlite3 "$DB" "UPDATE ..." invocations takes minutes for a small table.

Cause: Each invocation starts a new process, opens the file, commits its own transaction, and fsyncs. Process startup and per-statement commits dominate the runtime.

Fix: Feed the whole batch to a single sqlite3 process wrapped in one BEGIN;/COMMIT;. The pattern in step 5 typically turns a multi-minute loop into a sub-second run. The batching approach is expanded in Scripting Batch Spatial Queries with the sqlite3 CLI.

database is locked Under Cron

Symptom: Error: database is locked when a scheduled script runs, but never when you run it by hand.

Cause: Two invocations overlap — a long run is still writing when the next scheduled run starts — and both contend for the write lock or the -wal sidecar.

Fix: Guard the critical section with an advisory lock so a second run exits immediately:

bash
# Prevent overlapping scheduled runs with an flock guard
exec 9>/var/lock/parcels-sync.lock
flock -n 9 || { echo "sync already running, skipping"; exit 0; }

.import Puts Everything in One Column

Symptom: After .import data.csv t, every row’s data lands in the first column and the rest are empty.

Cause: The shell was in the default list mode with a pipe separator, so comma-delimited input was never split on commas.

Fix: Set .mode csv before .import so the shell parses commas and quoting per RFC 4180. Add --skip 1 when the file carries a header row you do not want inserted.

Performance Notes

One Process, One Transaction

The dominant cost in shell automation is not SQL execution but process startup and per-statement commits. Consolidating a batch into a single sqlite3 process wrapped in one transaction removes both. On a 50,000-row update, moving from per-row invocations to one transactional heredoc routinely cuts wall-clock time by one to two orders of magnitude.

WAL Mode and Checkpoint Cadence

Set PRAGMA journal_mode=WAL; once at the top of a write session so background readers — an ogrinfo validation, a status dashboard — are not blocked while the script writes. The -wal sidecar grows until checkpointed, so end a heavy sync with an explicit checkpoint rather than leaving it to accumulate:

bash
# Bound the WAL sidecar after a heavy write session
sqlite3 field.gpkg "PRAGMA wal_checkpoint(TRUNCATE);"

Deferring Index Maintenance

Maintaining the R-tree spatial index on every insert is wasteful during a bulk load. When a script ingests a large batch, drop or disable the spatial index first, load inside one transaction, then rebuild it in a single pass with CreateSpatialIndex. Building the R-tree once over the finished table is markedly faster than maintaining it incrementally row by row.

Child Pages

Frequently Asked Questions

When should I use the spatialite shell instead of sqlite3?

Use the spatialite shell whenever it is installed and you need spatial functions, because it preloads the entire catalogue — ST_Area, CreateSpatialIndex, InitSpatialMetaData — with no extension load step. Reach for the plain sqlite3 shell when you only have the base SQLite tools or when the work is non-spatial, such as PRAGMA checks, .import loads, or attribute updates. If you need spatial functions in sqlite3, add a .load mod_spatialite line at the top of the script; the SQL you run afterward is identical either way.

Why does wrapping statements in BEGIN and COMMIT make my script so much faster?

Without an explicit transaction, SQLite commits and flushes to disk after every statement, and each flush waits on the storage device. A thousand statements become a thousand durable commits. Wrapping the batch in one BEGIN;/COMMIT; collapses that into a single flush at the end, so the fixed per-commit cost is paid once instead of a thousand times. The effect grows with batch size and is most dramatic on spinning disks and network storage.

How do I capture a query result into a file from a script?

Inside a heredoc, set .headers on and .mode csv, then .output result.csv before the query and .output stdout after it — everything the query prints in between is written to the file. For a single ad-hoc query you can skip the dot-commands and use the command-line flags instead: sqlite3 -csv -header field.gpkg "SELECT ..." > result.csv. Both produce identical CSV; the dot-command form is cleaner when a script runs several queries with different output targets.

Can I run ogr2ogr and sqlite3 against the same file in one script?

Yes, and it is the normal pattern: ogr2ogr handles format conversion and reprojection while sqlite3 or spatialite handles SQL-level edits and validation on the same file. Run them sequentially, not concurrently, and check each exit code before the next step so a failed conversion never feeds a half-written file into a query. Enabling WAL mode lets a read-only ogrinfo inspect the file while a write is in progress, but two writers to one file at the same time will contend for the lock.

How do I stop a scheduled script from corrupting the database when runs overlap?

Guard the write section with an advisory lock using flock on a dedicated lock file, so a second invocation that fires while the first is still running exits immediately instead of writing concurrently. Also ensure every process opens the database in WAL mode; mixing WAL and rollback-journal opens across overlapping processes is a second, subtler source of corruption. Together, a lock file plus consistent WAL mode make scheduled runs safe to fire on a tight cadence.