Managing Spatial Reference Systems in SQLite
To register a coordinate reference system (CRS) in SQLite you must insert an EPSG definition into spatial_ref_sys (SpatiaLite) or gpkg_spatial_ref_sys (GeoPackage), then bind the SRID to each geometry column — SQLite never infers projections automatically.
Why This Matters
Unlike enterprise spatial databases that store projection metadata inside every geometry BLOB, SQLite delegates CRS ownership entirely to its SpatiaLite metadata tables. The spatial_ref_sys and gpkg_spatial_ref_sys tables act as a lookup registry: an integer SRID points to an authoritative WKT definition, and every geometry column in the database must reference a registered SRID.
This design has a sharp edge in offline-first and embedded deployments. Desktop installations of QGIS or SpatiaLite GUI pre-populate the registry with several thousand EPSG entries. Mobile builds, Docker images, and Python virtual environments usually ship with a stripped or empty registry. The moment a client tries to render a layer whose SRID has no matching row, spatial index creation fails silently, distance calculations return wrong units, and desktop GIS applications display geometries in the wrong place. Automating registry validation and injection before any schema migration or bulk import is the lowest-cost way to prevent these failures.
Prerequisites
- Python 3.9+ with the built-in
sqlite3module pyproj3.4+ (pip install pyproj)- SpatiaLite shared library (
mod_spatialite.soon Linux,mod_spatialite.dylibon macOS,mod_spatialite.dllon Windows) — only required for.sqlite/.dbfiles, not GeoPackage - Read/write access to the target database file
- Basic familiarity with EPSG codes and WKT projection strings
How Spatial SQLite Stores CRS Definitions
The two formats share the same concept but differ in schema. Both enforce strict referential integrity: inserting geometries with an unregistered SRID causes index creation to fail and triggers client-side projection errors.
SpatiaLite stores definitions in spatial_ref_sys with five mandatory columns:
| Column | Type | Description |
|---|---|---|
srid | INTEGER PRIMARY KEY | The integer Spatial Reference System ID |
auth_name | TEXT | Authority name, e.g. EPSG |
auth_srid | INTEGER | Authority-assigned code, e.g. 4326 |
srtext | TEXT | Well-Known Text (WKT) projection string |
proj4text | TEXT | Legacy PROJ.4 string (may be empty in newer setups) |
GeoPackage uses gpkg_spatial_ref_sys per the OGC GeoPackage specification, replacing srid with srs_id and adding srs_name, organization, organization_coordsys_id, and definition. The definition column accepts WKT2:2019 strings. Two rows with srs_id values of 0 (undefined Cartesian) and -1 (undefined geographic) are mandatory and must never be removed.
The relationship between these registries and the geometry column tables is covered in full in SpatiaLite Metadata Tables Explained. The broader format contract between SpatiaLite and GeoPackage is described in the GeoPackage Specification Deep Dive.
Primary Method: Validate, Inject, and Bind in One Transaction
The function below is the recommended production approach. It resolves an EPSG code through pyproj, checks whether the SRID is already registered, inserts the definition if missing, and binds it to the target geometry column — all inside a single transaction so a partial failure cannot leave the schema inconsistent.
# -- SpatiaLite (.sqlite/.db) and GeoPackage (.gpkg) -- Python 3.9+
import sqlite3
import os
from pyproj import CRS
from pyproj.exceptions import CRSError
def manage_srs_in_sqlite(
db_path: str,
target_srid: int,
table_name: str,
geom_col: str,
geom_type: str = "GEOMETRY",
) -> None:
"""Validate EPSG, inject CRS if missing, and bind to a geometry column.
Args:
db_path: Absolute path to the .sqlite, .db, or .gpkg file.
target_srid: EPSG integer code (e.g. 4326, 32632).
table_name: Target feature table name (created if absent).
geom_col: Name of the geometry column to register.
geom_type: OGC geometry type string, e.g. "POINT", "MULTIPOLYGON".
"""
is_geopackage = db_path.lower().endswith(".gpkg")
if not os.path.exists(db_path):
raise FileNotFoundError(f"Database not found: {db_path}")
# Step 1: resolve the EPSG code through pyproj before touching the database.
try:
crs = CRS.from_epsg(target_srid)
# WKT2:2019 is preferred by GeoPackage 1.3+.
wkt = crs.to_wkt(version="WKT2_2019")
proj4 = crs.to_proj4() # lossy for some CRS; kept for SpatiaLite legacy compat
authority = crs.to_authority()
if authority is None:
raise ValueError(f"No EPSG authority match for SRID {target_srid}")
auth_name, auth_srid_str = authority
auth_srid = int(auth_srid_str)
except CRSError as exc:
raise ValueError(f"Invalid EPSG code {target_srid}: {exc}") from exc
conn = sqlite3.connect(db_path)
conn.enable_load_extension(True)
try:
if not is_geopackage:
# mod_spatialite must be loaded before any spatial function call.
conn.load_extension("mod_spatialite")
conn.execute("SELECT spatialite_version();")
with conn: # single transaction — either all steps commit or all roll back
registry = "gpkg_spatial_ref_sys" if is_geopackage else "spatial_ref_sys"
id_col = "srs_id" if is_geopackage else "srid"
# Step 2: check registry — insert only when the SRID is absent.
exists = conn.execute(
f"SELECT COUNT(*) FROM {registry} WHERE {id_col} = ?",
(target_srid,),
).fetchone()[0]
if not exists:
if is_geopackage:
conn.execute(
"""
INSERT INTO gpkg_spatial_ref_sys
(srs_name, srs_id, organization, organization_coordsys_id,
definition, description)
VALUES (?, ?, ?, ?, ?, ?)
""",
(crs.name, target_srid, auth_name, auth_srid, wkt, crs.name),
)
else:
conn.execute(
"""
INSERT INTO spatial_ref_sys
(srid, auth_name, auth_srid, srtext, proj4text)
VALUES (?, ?, ?, ?, ?)
""",
(target_srid, auth_name, auth_srid, wkt, proj4),
)
# Step 3: bind the SRID to the geometry column.
if is_geopackage:
conn.execute(
f"CREATE TABLE IF NOT EXISTS {table_name} "
f"(id INTEGER PRIMARY KEY AUTOINCREMENT, {geom_col} {geom_type})"
)
conn.execute(
"""
INSERT OR IGNORE INTO gpkg_geometry_columns
(table_name, column_name, geometry_type_name, srs_id, z, m)
VALUES (?, ?, ?, ?, 0, 0)
""",
(table_name, geom_col, geom_type.upper(), target_srid),
)
else:
# AddGeometryColumn registers the column in geometry_columns
# and creates the associated triggers automatically.
conn.execute(
f"CREATE TABLE IF NOT EXISTS {table_name} "
f"(id INTEGER PRIMARY KEY AUTOINCREMENT)"
)
conn.execute(
"SELECT AddGeometryColumn(?, ?, ?, ?, 2, 0)",
(table_name, geom_col, target_srid, geom_type),
)
print(f"Bound SRID {target_srid} ({crs.name}) to {table_name}.{geom_col}")
finally:
conn.close()
Step-by-Step Walkthrough
1. Resolve the EPSG code before touching the database
Call CRS.from_epsg(target_srid) first. If pyproj cannot resolve the code it raises CRSError immediately, before you open a connection. This keeps error handling clean and prevents half-written registry rows.
2. Choose WKT version based on format
Use crs.to_wkt(version="WKT2_2019") for GeoPackage 1.3+ targets. For SpatiaLite databases consumed by older QGIS releases (pre-3.16) or ArcGIS Desktop, switch to crs.to_wkt(version="WKT1_GDAL"). WKT1 is lossier but universally supported.
3. Check before inserting
The SELECT COUNT(*) guard avoids duplicate-key errors on srid (SpatiaLite) and srs_id (GeoPackage), which are both primary keys. Never use INSERT OR REPLACE on gpkg_spatial_ref_sys — it cascades deletes to gpkg_geometry_columns through the foreign-key constraint.
4. Wrap everything in one transaction
The with conn: context manager commits on exit and rolls back on any exception. If AddGeometryColumn fails after the spatial_ref_sys insert has already executed, the rollback removes the orphaned registry row — keeping the database consistent.
5. Register the spatial index after binding
Registering an SRID and binding the geometry column do not automatically create an R-tree index. For SpatiaLite, run SELECT CreateSpatialIndex('table_name', 'geom_column') in a follow-up call. GeoPackage manages spatial index registration through gpkg_extensions entries; use GDAL or a conformant OGR driver to create these rather than writing them by hand.
CRS Registry Flow
The diagram below shows the path from an EPSG code to a bound geometry column and spatial index in both formats.
Verification
After running manage_srs_in_sqlite, confirm the SRID is present and bound with these queries. Run them in a fresh connection without loading mod_spatialite to verify the registry rows are persisted rather than cached in memory.
# -- SpatiaLite verification
import sqlite3
def verify_srid_registered(db_path: str, srid: int, table_name: str) -> None:
conn = sqlite3.connect(db_path)
try:
is_gpkg = db_path.lower().endswith(".gpkg")
registry = "gpkg_spatial_ref_sys" if is_gpkg else "spatial_ref_sys"
id_col = "srs_id" if is_gpkg else "srid"
row = conn.execute(
f"SELECT {id_col}, srs_name FROM {registry} WHERE {id_col} = ?"
if is_gpkg else
f"SELECT srid, srtext FROM {registry} WHERE srid = ?",
(srid,),
).fetchone()
assert row is not None, f"SRID {srid} not found in {registry}"
print(f"Registry OK: {row}")
geom_table = "gpkg_geometry_columns" if is_gpkg else "geometry_columns"
id_ref = "srs_id" if is_gpkg else "srid"
bound = conn.execute(
f"SELECT table_name, column_name FROM {geom_table} "
f"WHERE table_name = ? AND {id_ref} = ?",
(table_name, srid),
).fetchone()
assert bound is not None, f"SRID {srid} not bound to {table_name}"
print(f"Geometry column bound: {bound}")
finally:
conn.close()
You can also run a quick check from the command line using ogrinfo:
# Replace 'layers.gpkg' and 'features' with your file and layer name
ogrinfo -al -so layers.gpkg features | grep -E "SRS|Geometry"
A valid output shows the layer’s geometry type and its authority-registered projection string. If ogrinfo reports SRS: unknown, the SRID row is missing or the definition column contains a string the GDAL WKT parser rejects.
Alternative Approaches
Using GDAL/OGR instead of raw SQL
For one-off CRS registration during file creation, the GDAL Python bindings handle everything automatically:
# -- GeoPackage via GDAL/OGR 3.4+ -- Python 3.9+
from osgeo import ogr, osr
driver = ogr.GetDriverByName("GPKG")
ds = driver.CreateDataSource("output.gpkg")
srs = osr.SpatialReference()
srs.ImportFromEPSG(4326)
srs.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)
layer = ds.CreateLayer("features", srs=srs, geom_type=ogr.wkbPoint)
ds = None # flush and close
GDAL inserts the required gpkg_spatial_ref_sys rows and the gpkg_geometry_columns entry automatically. Use this path when creating new files; use the sqlite3 path above when patching existing databases or deploying to environments without GDAL.
Bulk-populating the registry from a CSV seed file
For embedded deployments that must ship with a pre-populated registry, generate a CSV of EPSG definitions at build time and load it at first-run:
# -- SpatiaLite bulk seed -- Python 3.9+
import csv
import sqlite3
def seed_registry(db_path: str, csv_path: str) -> int:
"""Insert EPSG definitions from a CSV seed into spatial_ref_sys.
CSV columns: srid,auth_name,auth_srid,srtext,proj4text
Returns the number of rows inserted.
"""
conn = sqlite3.connect(db_path)
conn.enable_load_extension(True)
conn.load_extension("mod_spatialite")
inserted = 0
with conn, open(csv_path, newline="") as fh:
for row in csv.DictReader(fh):
exists = conn.execute(
"SELECT COUNT(*) FROM spatial_ref_sys WHERE srid = ?",
(int(row["srid"]),),
).fetchone()[0]
if not exists:
conn.execute(
"INSERT INTO spatial_ref_sys "
"(srid, auth_name, auth_srid, srtext, proj4text) VALUES (?,?,?,?,?)",
(int(row["srid"]), row["auth_name"], int(row["auth_srid"]),
row["srtext"], row["proj4text"]),
)
inserted += 1
conn.close()
return inserted
This pattern is useful in offline sync scenarios where every field device needs an identical registry snapshot. Version-control the CSV alongside your application schema and validate it in CI before deployment.
Troubleshooting
OperationalError: no such table: spatial_ref_sys
Cause: The mod_spatialite extension was not loaded, or the database was not initialised with InitSpatialMetaData().
Fix: Add these two lines immediately after connecting:
conn.enable_load_extension(True)
conn.load_extension("mod_spatialite")
# Only needed for brand-new, empty databases:
conn.execute("SELECT InitSpatialMetaData(1);")
The 1 argument to InitSpatialMetaData suppresses the error if metadata tables already exist.
IntegrityError: UNIQUE constraint failed: gpkg_spatial_ref_sys.srs_id
Cause: A row with the same srs_id already exists in the GeoPackage registry. This usually happens when a script is run twice or when a GeoPackage created by GDAL already contains the common EPSG entries.
Fix: Use the SELECT COUNT(*) guard before inserting (as shown in the primary method). Never use INSERT OR REPLACE — it triggers a cascading delete on gpkg_geometry_columns.
CRSError: Invalid projection: ...
Cause: The EPSG code is not in the local pyproj data directory (PROJ_DATA env var). This is common in minimal Docker images.
Fix:
pip install pyproj --upgrade
python -c "import pyproj; print(pyproj.datadir.get_data_dir())"
# Verify the directory is non-empty and contains .db files
Alternatively, set PROJ_DATA to point to a pre-downloaded PROJ data directory bundled with your application.
Related
- SpatiaLite Metadata Tables Explained — parent page covering all metadata tables, their schemas, and how they interact with spatial indexes
- Reading Spatial Metadata with Python — detect format, query geometry_columns and spatial_ref_sys, normalise across both standards
- GeoPackage Specification Deep Dive — mandatory gpkg_* table schemas, OGC compliance triggers, and format contracts
- Extension Compatibility in Spatial SQLite — platform-specific mod_spatialite loading paths and version pinning
- Transaction Scoping & Rollback Strategies — safe transaction patterns for schema migrations that touch multiple metadata tables