How to Add a Custom CRS to gpkg_spatial_ref_sys

Insert one row into gpkgspatialrefsys carrying the srsid, an authority name and code, a WKT definition, and a description — then bind the layer to that…

Insert one row into gpkg_spatial_ref_sys carrying the srs_id, an authority name and code, a WKT definition, and a description — then bind the layer to that srs_id in both gpkg_contents and gpkg_geometry_columns, all inside a single transaction. A GeoPackage ships with three mandatory rows and nothing else, so adding the reference system your data actually uses is a normal build step rather than a repair.

This page belongs to the Datum Transformations & Projection Accuracy guide.

Why This Matters

Unlike SpatiaLite, which typically initialises with a large EPSG catalogue, the GeoPackage specification requires only three rows: WGS 84, an undefined Cartesian system, and an undefined geographic system. Everything else is the producer’s responsibility. A container written by a tool that did not add the definition still records an srs_id on the layer — it simply points at nothing, and every consumer that tries to resolve it gets a dangling reference.

The symptom is unhelpfully indirect. ST_Transform returns NULL rather than raising, a desktop GIS offers to “choose a CRS” as though the file did not state one, and a reprojection pipeline produces a layer full of empty geometry. All three are the same missing row.

Prerequisites

  • Python 3.9+ with sqlite3, and pyproj 3.4+ to produce the WKT
  • Write access to the container
  • The authority and code for the reference system, or a complete WKT definition
  • Familiarity with the registry model from GeoPackage Specification Deep Dive

Primary Method

python
# Insert a CRS definition and bind a layer to it, atomically
import sqlite3
from pyproj import CRS


def register_crs(gpkg_path: str, epsg: int, layer: str | None = None) -> int:
    """
    Ensure gpkg_spatial_ref_sys holds a definition for `epsg`, and optionally
    bind `layer` to it. Returns the srs_id. Safe to call repeatedly.
    """
    crs = CRS.from_epsg(epsg)
    conn = sqlite3.connect(gpkg_path, isolation_level=None)
    try:
        conn.execute("BEGIN IMMEDIATE")

        existing = conn.execute(
            "SELECT definition FROM gpkg_spatial_ref_sys WHERE srs_id = ?", (epsg,)
        ).fetchone()

        if existing is None:
            conn.execute(
                """
                INSERT INTO gpkg_spatial_ref_sys
                    (srs_name, srs_id, organization, organization_coordsys_id,
                     definition, description)
                VALUES (?, ?, 'EPSG', ?, ?, ?)
                """,
                (crs.name, epsg, epsg, crs.to_wkt(version="WKT1_GDAL"), crs.name),
            )

        if layer is not None:
            conn.execute(
                "UPDATE gpkg_contents SET srs_id = ? WHERE table_name = ?", (epsg, layer)
            )
            conn.execute(
                "UPDATE gpkg_geometry_columns SET srs_id = ? WHERE table_name = ?",
                (epsg, layer),
            )

        conn.execute("COMMIT")
        return epsg
    except Exception:
        conn.execute("ROLLBACK")
        raise
    finally:
        conn.close()

The insert is conditional rather than an upsert, and that is deliberate: overwriting an existing definition changes the meaning of every geometry already bound to it. If the stored definition differs from the one you expect, that is something to investigate, not something to silently replace.

What a GeoPackage ships with, and what you must addThe specification requires three rows in gpkg_spatial_ref_sys: WGS 84 with srs_id 4326, an undefined Cartesian system with srs_id minus one, and an undefined geographic system with srs_id zero. Any other reference system — a national grid, a local projection, a custom definition — must be inserted by the producer before a layer can bind to it.gpkg_spatial_ref_sys, as shippedsrs_id 4326WGS 84always presentsrs_id −1undefined Cartesianalways presentsrs_id 0undefined geographicalways presenteverything else is yours to inserta national grid, a local projection, a custom definitiona layer bound to a missing srs_id is a dangling reference, and nothing raises
SpatiaLite habits mislead here: its initialisation usually loads a full catalogue, and GeoPackage's does not.

Step-by-Step Walkthrough

1. Check what the container already holds

sql
-- GeoPackage: the reference systems this container can resolve
SELECT srs_id, organization, organization_coordsys_id, srs_name
FROM gpkg_spatial_ref_sys
ORDER BY srs_id;

2. Find layers pointing at a definition that is missing

This is the query worth running against any container you did not build.

sql
-- Layers whose srs_id has no matching definition
SELECT c.table_name, c.srs_id
FROM gpkg_contents c
WHERE c.data_type = 'features'
  AND c.srs_id NOT IN (SELECT srs_id FROM gpkg_spatial_ref_sys);

3. Produce the WKT

pyproj generates the definition, and the WKT flavour matters more than it looks. GeoPackage 1.2 and earlier expect WKT 1; 1.3 added an optional definition_12_063 column carrying WKT 2. Writing WKT 2 into the definition column of a container that consumers read as WKT 1 produces a definition that parses in some tools and not others.

python
from pyproj import CRS

crs = CRS.from_epsg(27700)
print(crs.to_wkt(version="WKT1_GDAL")[:120], "…")   # for `definition`
print(crs.to_wkt(version="WKT2_2019")[:120], "…")   # for `definition_12_063`, if present

4. Insert with the identifier you intend to keep

For an EPSG system, use the EPSG code as srs_id. It is what every other tool will look for, and using something else makes the container correct but surprising. For a genuinely custom system with no authority code, the specification allows any unused identifier — pick from a private range well above the EPSG space, such as 100000 upward, and record organization as something other than EPSG so nobody mistakes it for one.

sql
-- A custom definition with no authority code
INSERT INTO gpkg_spatial_ref_sys
    (srs_name, srs_id, organization, organization_coordsys_id, definition, description)
VALUES
    ('Site grid — north quarry', 100001, 'NONE', 100001,
     'PROJCS["Site grid",GEOGCS[...]]',
     'Local engineering grid, origin at the survey monument');
Choosing an srs_idTwo cases. A reference system with an EPSG code should use that code as its srs_id and record EPSG as the organization, so other tools find it where they expect. A genuinely local system with no authority code should use an identifier from a private range well above the EPSG space, with the organization recorded as something other than EPSG so it is not mistaken for a registered code.has an EPSG codesrs_id = the EPSG codeorganization = 'EPSG'other tools find it where they lookanything else is correct and surprisingno authority codesrs_id from a private range100000 upward, well clear of EPSGorganization ≠ 'EPSG'so nobody mistakes it for registeredreusing an EPSG number for a local grid is the one choice with no recoveryevery consumer will resolve it against the real registry and be wrong
The failure to avoid is borrowing an EPSG number for something that is not that system — nothing detects it, and every consumer resolves it wrongly.

5. Bind the layer in both registry tables

gpkg_contents and gpkg_geometry_columns each carry an srs_id, and different consumers read different ones. Updating only one produces a container that behaves differently in two tools.

sql
-- Both rows, one transaction
UPDATE gpkg_contents SET srs_id = 27700 WHERE table_name = 'parcels';
UPDATE gpkg_geometry_columns SET srs_id = 27700 WHERE table_name = 'parcels';

Note that this binds the declaration only. It does not transform the coordinates — if the geometry is in a different system, this relabels it, which is the set_crs-versus-to_crs mistake in another form. Transform first, then rebind, as described in the parent guide.

Verification

sql
-- No layer may point at a definition that does not exist
SELECT c.table_name, c.srs_id
FROM gpkg_contents c
WHERE c.data_type = 'features'
  AND c.srs_id NOT IN (SELECT srs_id FROM gpkg_spatial_ref_sys);

-- The two registry rows must agree for every layer
SELECT c.table_name, c.srs_id AS contents, g.srs_id AS geometry_columns
FROM gpkg_contents c
JOIN gpkg_geometry_columns g ON g.table_name = c.table_name
WHERE c.srs_id <> g.srs_id;

Both queries should return nothing. Then confirm the definition actually parses, because a syntactically broken WKT satisfies the referential check and fails at use:

python
from pyproj import CRS

definition = conn.execute(
    "SELECT definition FROM gpkg_spatial_ref_sys WHERE srs_id = ?", (27700,)
).fetchone()[0]

crs = CRS.from_wkt(definition)          # raises if the WKT is malformed
assert crs.to_epsg() == 27700, f"definition resolves to {crs.to_epsg()}"
print("definition parses and resolves correctly")
Three checks a registered reference system must passFirst, no layer may reference an srs_id with no row in gpkg_spatial_ref_sys, or the reference dangles. Second, the srs_id recorded in gpkg_contents must match the one in gpkg_geometry_columns, or two consumers disagree. Third, the stored WKT must actually parse and resolve to the system it claims, since a malformed definition satisfies both earlier checks and fails only at use.1 · no dangling srs_idevery layer's srs_id has a row in gpkg_spatial_ref_sys2 · the two registry rows agreegpkg_contents.srs_id equals gpkg_geometry_columns.srs_id3 · the definition parses and resolvesa malformed WKT passes the first two and fails at use
The third check is the one a purely SQL validator cannot make, which is why it belongs in the Python side of the gate.

Alternative Approaches or Edge Cases

Letting GDAL do it. Writing a layer through OGR with -t_srs EPSG:27700 inserts the definition and binds both registry rows automatically, correctly, and with the right WKT flavour for the container version. Where you control the write path, this is strictly better than hand-inserting; the manual route matters when repairing a container someone else produced.

Containers at GeoPackage 1.3. These may carry a definition_12_063 column holding WKT 2 alongside the WKT 1 definition. Populate both when the column exists — some consumers prefer the WKT 2 form, and leaving it as the placeholder undefined string means those consumers fall back or fail.

Vertical and compound systems. A container recording elevation may need a compound CRS. The insert is identical, but the WKT is larger and fewer consumers handle it; where interchange matters more than completeness, storing the horizontal system and carrying elevation as an attribute is the more portable choice.

Troubleshooting

IntegrityError: UNIQUE constraint failed: gpkg_spatial_ref_sys.srs_id

Cause: A definition for that identifier already exists. Fix: This is the guard working. Read the existing definition and compare it with the one you intended — if they differ, that is a real conflict to resolve deliberately, not something to overwrite.

ST_Transform returns NULL after the insert

Cause: SpatiaLite’s transform functions read spatial_ref_sys, not gpkg_spatial_ref_sys, and the two are separate tables even in a container that has both. Fix: For SpatiaLite-side operations, register the definition in its registry too — see Managing Spatial Reference Systems in SQLite.

A desktop GIS still asks which CRS the layer uses

Cause: The layer’s srs_id is 0 or -1, the undefined placeholders, rather than the one you inserted. Fix: Update both registry rows for the layer; inserting the definition alone does not bind anything to it.

Frequently Asked Questions

Can I insert every EPSG code so the container never lacks one?

You can, and it makes the container several megabytes larger for data nobody reads. The full registry is large, and a field deployment pays that size on every device. Insert the systems the container’s layers actually use, plus any the consumer is known to reproject into — typically two or three rows.

What happens if two containers use the same private srs_id for different systems?

Nothing, until they are merged — at which point the merged container has one identifier with two meanings and no way to tell which layer meant which. If private identifiers are used across a fleet, allocate them centrally and record the allocation somewhere outside the containers. This is the main argument for using authority codes wherever one exists.

Does the description field matter?

Not to any parser, and considerably to whoever opens the container in three years. For an EPSG system the name is self-explanatory; for a local grid it is the only place the origin, the units and the reason for its existence are recorded. Treat it as the comment field it effectively is.