Datum Transformations & Projection Accuracy in Spatial SQLite

Reprojection looks like arithmetic and behaves like a lookup. Convert a national-grid coordinate to WGS 84 on a workstation and again inside a field…

Reprojection looks like arithmetic and behaves like a lookup. Convert a national-grid coordinate to WGS 84 on a workstation and again inside a field application, and the two answers can differ by two metres — not because either is wrong, but because the two machines had different transformation data available and each silently picked the best option it could find. Nothing warns you. The coordinates are plausible, the layer renders, and the discrepancy only surfaces when someone stands where the map says the boundary is.

This guide is part of the Core Architecture & Format Standards for Spatial SQLite section. It explains where that variability comes from, how to make a transformation deterministic, and how to verify a reprojected layer against something other than hope.

Prerequisites

Concept & Specification Reference

Three distinct things get called “the projection”, and separating them is most of the battle.

TermWhat it isWhat changes if it is wrong
DatumThe model of the Earth’s shape and its orientationCoordinates move by metres to hundreds of metres
ProjectionThe map from the curved surface to a planeCoordinates change entirely, and obviously so
TransformationThe specific route between two datumsCoordinates move by centimetres to metres, quietly

A projection error is loud: the numbers change magnitude, the layer lands in the ocean, someone notices within a minute. A datum error is quiet, because the wrong answer is only a few metres from the right one — comfortably inside the error people attribute to GPS.

The critical point is that a transformation is not implied by the two reference systems. Between a national datum and WGS 84 there are usually several published routes: a three-parameter shift that is simple and approximate, a seven-parameter one that is better, and a grid-based one that is best and requires a data file most installations do not have. PROJ picks the most accurate route whose data it can actually find, which means the route depends on the machine.

Several transformation routes between the same two reference systemsOne source reference system and one target, with three candidate routes between them. A three-parameter shift is always available and accurate to a few metres. A seven-parameter transformation is available from the PROJ database and accurate to about a metre. A grid-based transformation is the most accurate at a few centimetres but requires a grid file that must be installed separately. PROJ selects the best route whose data is present, so a machine missing the grid silently uses a less accurate one.source CRSa national grid3-parameter shiftalways available · ±3 m7-parameter transformin proj.db · ±1 mgrid-basedneeds a grid file · ±0.05 mtarget CRSWGS 84
The source and target are stated in the data. Which of the three routes runs is decided by what happens to be installed.

Step-by-Step Implementation

1. Ask which route PROJ is going to take

Before transforming anything, find out what the library intends to do. pyproj exposes the candidate operations and their published accuracy, which turns an invisible decision into a visible one.

python
# List the transformation routes PROJ knows between two reference systems
from pyproj.transformer import TransformerGroup

tg = TransformerGroup("EPSG:27700", "EPSG:4326")

for t in tg.transformers:
    print(f"{t.description}\n    accuracy: {t.accuracy} m")

# Routes PROJ knows about but cannot run, because a grid is missing
for missing in tg.unavailable_operations:
    print("UNAVAILABLE:", missing.name)
    for grid in missing.grids:
        print("    needs grid:", grid.short_name, "->", grid.url)

unavailable_operations is the field that matters. A machine that lists a grid-based route there is a machine that will silently fall back to something less accurate, and the URL it prints is exactly what needs installing.

2. Pin the transformation rather than the endpoints

Once you know which route you want, name it. pyproj accepts an explicit pipeline, which removes the selection step entirely and makes the result reproducible regardless of what else the machine has installed.

python
# Deterministic transformation: state the route, not just the endpoints
from pyproj import Transformer

# Selection by authority code — the operation is now fixed, not chosen
transformer = Transformer.from_pipeline(
    "+proj=pipeline "
    "+step +inv +proj=tmerc +lat_0=49 +lon_0=-2 +k=0.9996012717 "
    "+x_0=400000 +y_0=-100000 +ellps=airy "
    "+step +proj=hgridshift +grids=uk_os_OSTN15_NTv2_OSGBtoETRS.tif "
    "+step +proj=unitconvert +xy_in=rad +xy_out=deg"
)

lon, lat = transformer.transform(651409.903, 313177.270)
print(f"{lon:.9f}, {lat:.9f}")

The pipeline is verbose, and that is the point: every step is stated, so two machines running it either produce identical output or fail loudly because a grid is absent. A Transformer.from_crs call produces neither guarantee.

3. Record the transformation alongside the data

A container that has been reprojected should say how. The registry records the reference system, not the route taken to reach it, so the route belongs in your own metadata — a small table in the container itself is the version least likely to be separated from the data.

sql
-- A provenance table travels with the container
CREATE TABLE IF NOT EXISTS _crs_provenance (
    layer_name   TEXT NOT NULL,
    source_srs   INTEGER NOT NULL,
    target_srs   INTEGER NOT NULL,
    operation    TEXT NOT NULL,      -- the authority code or pipeline string
    accuracy_m   REAL,
    proj_version TEXT NOT NULL,
    applied_at   TEXT NOT NULL,
    PRIMARY KEY (layer_name, applied_at)
);

Recording the PROJ version matters as much as the operation. Published transformations are revised, and a pipeline that produced one answer under PROJ 8 can produce a slightly different one under PROJ 9 — a difference that is impossible to explain later without knowing which version ran.

4. Bundle the grids you depend on

A grid-based transformation is only reproducible if the grid ships with the application. PROJ resolves grid files through its data directory, so bundling means placing the files where the library looks and confirming that it found them.

python
# Confirm the grid the pipeline needs is actually resolvable
import pyproj
from pathlib import Path

print("PROJ data dirs:", pyproj.datadir.get_data_dir())

grid = Path(pyproj.datadir.get_data_dir()) / "uk_os_OSTN15_NTv2_OSGBtoETRS.tif"
if not grid.exists():
    raise RuntimeError(
        f"grid missing at {grid} — transformations will fall back silently"
    )

Raising here is deliberate. The failure this check exists to prevent is not an exception — it is a successful run producing coordinates two metres out, and the only way to convert that into something noticeable is to refuse to start.

What happens on a machine missing the transformation gridTwo machines run the same reprojection. The build machine has the grid installed, so PROJ uses the grid-based route and produces coordinates accurate to a few centimetres. The field device does not have the grid, so PROJ falls back to a seven-parameter transformation, produces coordinates about a metre and a half away, and reports no warning. The two containers now disagree, and nothing in either records why.build machinegrid file installedgrid-based route selectedaccurate to ~5 cmthe answer everyone assumesfield devicegrid file absentfalls back to 7 parametersabout 1.5 m awayno warning, no errorboth containers claim EPSG:4326, and the claim is true of boththe reference system is not what differs — the route to it is
This is why a container should record the operation and not only the target reference system.

5. Reproject the layer, then re-register it

Reprojecting geometry and updating the registry are two operations, and doing only the first produces a layer whose coordinates and declared reference system disagree — the same class of error as calling set_crs where to_crs was meant.

python
# Reproject a layer in place and update its registered SRS together
import sqlite3
from shapely import from_wkb, to_wkb
from shapely.ops import transform

conn = sqlite3.connect("survey.gpkg", isolation_level=None)
conn.enable_load_extension(True)
conn.load_extension("mod_spatialite")

rows = conn.execute("SELECT fid, geom FROM parcels").fetchall()
reprojected = [
    (to_wkb(transform(transformer.transform, from_wkb(blob))), fid)
    for fid, blob in rows if blob is not None
]

conn.execute("BEGIN IMMEDIATE")
conn.executemany(
    "UPDATE parcels SET geom = GeomFromWKB(?, 4326) WHERE fid = ?", reprojected
)
conn.execute("UPDATE gpkg_contents SET srs_id = 4326 WHERE table_name = 'parcels'")
conn.execute(
    "UPDATE gpkg_geometry_columns SET srs_id = 4326 WHERE table_name = 'parcels'"
)
conn.execute("COMMIT")
Reprojecting geometry and re-registering the layer are two operationsReprojection rewrites every geometry into the new reference system. Re-registration updates the srs_id recorded in gpkg_contents and gpkg_geometry_columns. Doing only the first leaves a layer whose coordinates and declared reference system disagree; doing only the second relabels the data without moving it. Both must happen inside the same transaction.one transactionrewrite the geometryevery vertex transformedalone: coordinates and label disagreeupdate both registry rowsgpkg_contents · gpkg_geometry_columnsalone: relabels without movinga failure between the two leaves a state no validity check can detect
Both halves in one transaction, or build a new container and swap it in — the partial state is undetectable afterwards.

Validation & Verification

Control points are the only real verification. Take coordinates you know in both systems — a survey monument, a benchmark, a corner whose position is published — transform them, and measure the residual.

python
# Verify the pipeline against known control points
CONTROLS = [
    # (easting, northing, expected_lon, expected_lat)
    (651409.903, 313177.270, 1.716073972, 52.658007833),
    (438700.000, 114800.000, -0.939621000, 50.868331000),
]

for e, n, elon, elat in CONTROLS:
    lon, lat = transformer.transform(e, n)
    # ~111 km per degree of latitude; adequate for a residual sanity check
    dy = (lat - elat) * 111_320
    dx = (lon - elon) * 111_320 * abs(lat) ** 0
    print(f"residual: {dx:+.3f} m east, {dy:+.3f} m north")
    assert abs(dx) < 0.2 and abs(dy) < 0.2, "transformation route is not the expected one"

A residual of a few centimetres means the grid-based route ran. A residual of one to two metres means it did not, and the assertion is what turns that from a fact nobody noticed into a failed build.

Cross-check the registry too, because a correct transformation written into a mis-registered layer is still wrong:

sql
-- GeoPackage: the two registry rows must agree with each other
SELECT c.table_name, c.srs_id AS contents_srs, g.srs_id AS geometry_srs
FROM gpkg_contents c
JOIN gpkg_geometry_columns g ON g.table_name = c.table_name
WHERE c.data_type = 'features' AND c.srs_id <> g.srs_id;

Common Failure Modes & Fixes

Coordinates land in the Gulf of Guinea

Diagnosis: Longitude and latitude were swapped, or a projected coordinate was interpreted as degrees. Both produce values near zero, and zero-zero is off the coast of West Africa. Fix: Check axis order. EPSG:4326 is formally latitude-first, and different libraries disagree about whether to honour that; pyproj respects it unless the transformer is created with always_xy=True, which is almost always what code assuming longitude-first expects.

The same file reprojects differently on two machines

Diagnosis: Different transformation routes, because one machine has a grid the other does not. Fix: Pin the pipeline explicitly and assert the grid is present at startup. Comparing TransformerGroup(...).unavailable_operations on both machines identifies the difference in one command.

ST_Transform returns NULL for every row

Diagnosis: The target SRID has no definition in the container’s reference-system registry, or PROJ cannot open proj.db. SpatiaLite returns NULL rather than raising in both cases. Fix: Insert the missing definition as described in Managing Spatial Reference Systems in SQLite, and assert proj_version() returns a value during startup.

Everything shifts by a consistent amount after a library upgrade

Diagnosis: The published transformation was revised, or the new PROJ version prefers a different route. Fix: This is not a bug to work around — it is usually a correction. Compare the two routes with projinfo, decide which one your published data should use, and pin it. Recording the PROJ version in the provenance table is what makes this diagnosable at all.

Areas computed after reprojection are wrong

Diagnosis: Area was computed in a geographic reference system, where the units are degrees and the result is meaningless. Fix: Compute area in a projected system appropriate to the extent, or use a geodesic area function. Reprojecting to EPSG:4326 for storage and interchange is normal; computing measurements there is not.

Performance Notes

Transformation cost is dominated by two things, and neither is the arithmetic. The first is transformer construction: resolving a route involves database lookups and, for grid-based routes, opening and indexing a grid file. Construct the transformer once and reuse it — a loop that calls Transformer.from_crs per feature can spend the overwhelming majority of its time on setup.

The second is vertex count. pyproj transforms arrays far faster than it transforms scalars, so passing whole coordinate arrays rather than iterating point by point makes a large difference on polygon layers. shapely.ops.transform is convenient and works per geometry; where a layer is large enough for the difference to matter, extracting coordinates into arrays, transforming in bulk, and rebuilding geometry is measurably faster.

Finally, reprojection is a full rewrite of every geometry, which means it invalidates every bounding box in the spatial index. Budget the index rebuild as part of the reprojection rather than as an afterthought — on a large layer it commonly costs more than the transformation did.

Child Pages

Pages in this section go deeper on individual reference-system tasks:

Frequently Asked Questions

Is EPSG:4326 a datum or a projection?

Neither, strictly — it is a geographic coordinate reference system, which pairs the WGS 84 datum with unprojected angular coordinates. That distinction matters because “unprojected” is not the same as “no projection applied”: the coordinates are angles on an ellipsoid, and treating them as a flat plane is itself an implicit and rather bad projection. This is why area and distance computed directly in 4326 are wrong, and why it is nevertheless the right choice for storage and interchange.

How accurate does my transformation actually need to be?

Match it to how the data is collected and used. A handheld GPS receiver in reasonable conditions gives a few metres, so a three-parameter shift adds error comparable to the measurement error and is defensible. Survey-grade collection gives centimetres, and pairing it with a three-parameter shift throws away the entire value of the equipment. The failure to avoid is not choosing a less accurate route — it is not knowing which route ran.

Why does `always_xy=True` appear in so much example code?

Because the formal axis order of many geographic reference systems is latitude-first, while almost all software and file formats are longitude-first. pyproj follows the authority definition by default, so transform(50.8, -0.9) and transform(-0.9, 50.8) both run without error and only one is right. Passing always_xy=True forces longitude-first ordering throughout, which matches what surrounding code nearly always assumes. Set it deliberately rather than copying it, and be consistent within a codebase.

Can I store a layer in one reference system and query it in another?

You can, and it costs more than it looks. Every predicate has to transform one side, per row, and none of that work can use the spatial index — because the index holds bounding boxes in the stored reference system and the query geometry is in a different one. Transform the query geometry into the stored system once, before the query, and the index works again. That single change is often the difference between a query that takes milliseconds and one that scans the layer.

Do reprojected geometries stay valid?

Usually, but not necessarily. A transformation moves every vertex independently, and a polygon whose vertices were only just non-degenerate can end up self-intersecting afterwards — most often near the edge of a projection’s usable area, where distortion is largest. Run a validity check after reprojecting a layer, using the pass described in Geometry Validity & Topology Repair, rather than assuming validity survives.

What should a container record about how it was reprojected?

At minimum the source and target authority codes, the operation actually used, and the PROJ version that ran it. Those three make a result reproducible; the reference system alone does not, because it says nothing about which of several routes was taken. Recording the published accuracy of the operation as well turns a later question — “can we trust these coordinates to a metre?” — into a lookup rather than an investigation.

Is it safe to reproject a container in place?

Only under a transaction that also updates the registry, and only with a backup. Reprojection rewrites every geometry and both registry rows, and a failure partway through leaves a layer where some rows are in the new system and some are in the old — a state no check will detect, because every row is individually valid and the registry claims one system for all of them. Building a new container and swapping it in atomically avoids the partial state entirely, and is the pattern to prefer wherever the disk space allows.

Why do two libraries disagree about the same EPSG code?

Usually because they are reading different versions of the EPSG dataset. The registry is revised regularly — parameters are corrected, transformations are deprecated and replaced — so a code that resolves one way under a 2021 dataset can resolve slightly differently under a 2024 one. This is another argument for pinning the image rather than the wheel: the dataset ships with PROJ, not with your code.