GeoPackage Specification Deep Dive

Without a precise understanding of GeoPackage's mandatory schema contracts, spatial pipelines fail in hard-to-diagnose ways: QGIS silently drops layers,…

Without a precise understanding of GeoPackage’s mandatory schema contracts, spatial pipelines fail in hard-to-diagnose ways: QGIS silently drops layers, GDAL-based readers return empty feature sets, and mobile runtimes reject containers that pass naive sqlite3 integrity checks. A file that opens without error in Python is not the same as a file that satisfies OGC compliance — the difference lives in four system tables, a binary geometry envelope, and an R-tree registration that most tutorials omit entirely.

This page is part of the Core Architecture & Format Standards for Spatial SQLite reference. It covers the specification constraints that matter for production automation: header validation, mandatory table structures, GeoPackage Binary (GPB) geometry encoding, spatial indexing mechanics, and the failure modes most likely to surface in real field-device or cloud-sync deployments.

GeoPackage specification layersFour concentric horizontal bands show the GeoPackage stack from bottom to top: SQLite file (100-byte header, application ID 0x47504B47, WAL journal); mandatory metadata tables (gpkg_spatial_ref_sys, gpkg_contents, gpkg_geometry_columns, gpkg_extensions); user data (feature tables with GPB geometry BLOBs and R-tree shadow tables); and the OGC compliance envelope that wraps everything.OGC GeoPackage 1.4 Compliance EnvelopeSQLite File Layer100-byte header · Application ID 0x47504B47 · Page size ≥ 512 B · WAL journal modePRAGMA application_id = 1196444487 · PRAGMA user_version ≥ 10200Mandatory Metadata Tablesgpkg_spatial_ref_sysgpkg_contentsgpkg_geometry_columnsgpkg_extensionsCRS registry · table registry · geometry-column constraintsUser Data LayerFeature tables (geom BLOB column, GPB-encoded)rtree_<table>_<col>Attribute tables · Tile matrix sets (optional)R-tree shadow tables (_node, _parent, _rowid)Every layer must be present and internally consistent for OGC compliance
The three layers of a valid GeoPackage: the SQLite file header with its mandatory application ID, the four mandatory metadata tables that register all user data, and the feature/attribute/tile tables with their geometry BLOBs and R-tree indexes.

Prerequisites

Before working through the implementation sections below, confirm your environment meets these baselines:

Concept & Specification Reference

File Header Constraints

A GeoPackage is an SQLite 3 database file with two hard requirements beyond the normal 16-byte SQLite magic string (SQLite format 3\000):

Byte offsetFieldRequired valueNotes
0–15SQLite magicSQLite format 3\000Standard SQLite header
68–71Application ID0x47504B47 (GPKG)GeoPackage 1.2+; older 1.0/1.1 used 0x47503130
60–63User version10200 (GeoPackage 1.2)Encode as major*10000 + minor*100 + patch

The application ID is what distinguishes a GeoPackage from an ordinary SQLite file. GDAL’s GeoPackage driver checks this field first; a mismatch causes the driver to reject the file before reading any table. The file structure and header analysis page covers the full 100-byte SQLite header layout and how to read these fields programmatically without opening the database.

Mandatory Table Structures

The OGC specification requires four system tables in every compliant container. Missing or malformed rows in any of them breaks interoperability with QGIS, ArcGIS, and every GDAL-based pipeline.

Mandatory GeoPackage metadata table relationshipsgpkg_spatial_ref_sys sits at the top centre as the CRS registry. gpkg_contents and gpkg_geometry_columns both reference it via srs_id foreign keys. gpkg_geometry_columns also references gpkg_contents via table_name. A user feature table called field_observations is referenced by both gpkg_contents and gpkg_geometry_columns. gpkg_extensions sits to the right with no foreign-key dependencies.gpkg_spatial_ref_syssrs_id PK · srs_name · organizationdefinition (WKT) · descriptiongpkg_contentstable_name PK · data_typeidentifier · bbox (min/max x,y)srs_id FK → gpkg_spatial_ref_sysgpkg_geometry_columnstable_name FK → gpkg_contentscolumn_name · geometry_type_namesrs_id FK · z TINYINT · m TINYINTgpkg_extensionstable_name · column_nameextension_name · scopefield_observations (user table)id PK · name TEXT · geom BLOB (GPB-encoded)srs_idsrs_idtable_name
Every user feature table must be registered in gpkg_contents (table registry) and described in gpkg_geometry_columns (geometry column constraints). Both tables reference gpkg_spatial_ref_sys by srs_id. gpkg_extensions records enabled extensions such as the R-tree spatial index; it has no outbound foreign keys.

gpkg_spatial_ref_sys — the coordinate reference system registry. Every container must seed this table with three rows before any geometry data arrives: srs_id = -1 (undefined Cartesian), srs_id = 0 (undefined geographic), and at least one real CRS for your data (e.g. EPSG:4326). Definitions are stored as WKT, which supports custom projections for localized surveys. For the mechanics of registering and querying CRS rows, see Managing Spatial Reference Systems in SQLite.

gpkg_contents — the primary table registry. One row per user data table; must declare the data_type (features, attributes, or tiles), a bounding box, and the srs_id. An out-of-date bounding box causes QGIS to mis-zoom on layer load; an invalid data_type causes GDAL to skip the table entirely. Unlike SpatiaLite’s implicit geometry discovery (compared in SpatiaLite Metadata Tables Explained), GeoPackage requires every table to be explicitly registered here.

gpkg_geometry_columns — maps geometry columns to their parent tables with type constraints (POINT, LINESTRING, POLYGON, MULTIPOLYGON, etc.), dimensionality flags (z, m), and an srs_id. The table_name column is a foreign key into gpkg_contents, so gpkg_contents must be populated first.

gpkg_extensions — tracks enabled extensions by table, column, extension name, and scope (read-write or write-only). If your container uses an R-tree spatial index, the extension row for gpkg_rtree_index must exist here; readers that check the extension table before executing spatial queries will skip indexing otherwise. Extension Compatibility in Spatial SQLite documents how mismatched extension registrations cause silent failures across heterogeneous runtime environments.

GeoPackage Binary (GPB) Geometry Encoding

This is the single most common source of silent data corruption in GeoPackage automation. Geometry columns do not store raw WKB. The spec requires a GPB envelope prepended to every WKB payload:

OffsetLengthValue
02 bytesMagic: 0x47 0x50 (GP)
21 byteVersion: 0x00
31 byteFlags byte (endianness, envelope type, empty flag)
44 bytessrs_id as little-endian int32
8variableOptional bounding box envelope (controlled by flags byte)
8 or 8+NvariableISO WKB payload

The flags byte at offset 3 is critical: bit 0 controls byte order (0 = big-endian header, 1 = little-endian), bits 1–3 encode the envelope type (0 = no envelope, 1 = minx/maxx/miny/maxy, etc.), and bit 4 marks an empty geometry. Storing raw WKB without the GP prefix causes ogrinfo to report the geometry column as None and QGIS to show an empty attribute table — with no error messages.

Step-by-step Implementation

The following implementation builds a fully compliant GeoPackage from scratch using Python’s sqlite3 module. Table creation order matters because of foreign key constraints; the sequence below is the only safe order.

Step 1 — Set PRAGMAs and application ID

python
# GeoPackage context — set mandatory file-level metadata
import sqlite3
import struct

conn = sqlite3.connect("field_survey.gpkg")
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA foreign_keys=ON;")
# GeoPackage 1.2+ application ID (decimal: 1196444487)
conn.execute("PRAGMA application_id=1196444487;")
# GeoPackage 1.2 user_version: major*10000 + minor*100 + patch → 10200
conn.execute("PRAGMA user_version=10200;")

Step 2 — Create gpkg_spatial_ref_sys first

python
# GeoPackage context — CRS registry; must exist before contents or geometry_columns
conn.execute("""
    CREATE TABLE IF NOT EXISTS gpkg_spatial_ref_sys (
        srs_name                 TEXT    NOT NULL,
        srs_id                   INTEGER NOT NULL PRIMARY KEY,
        organization             TEXT    NOT NULL,
        organization_coordsys_id INTEGER NOT NULL,
        definition               TEXT    NOT NULL,
        description              TEXT
    );
""")

conn.execute("""
    INSERT OR IGNORE INTO gpkg_spatial_ref_sys VALUES
        ('Undefined Cartesian',   -1, 'NONE', -1, 'undefined', ''),
        ('Undefined Geographic',   0, 'NONE',  0, 'undefined', ''),
        ('WGS 84 geodetic',     4326, 'EPSG', 4326,
         'GEOGCS["WGS 84",DATUM["World Geodetic System 1984",'
         'SPHEROID["WGS 84",6378137,298.257223563]],'
         'PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]',
         'WGS 84 geographic 2D');
""")

Step 3 — Create gpkg_contents

python
# GeoPackage context
conn.execute("""
    CREATE TABLE IF NOT EXISTS gpkg_contents (
        table_name  TEXT     NOT NULL PRIMARY KEY,
        data_type   TEXT     NOT NULL,
        identifier  TEXT     UNIQUE,
        description TEXT     DEFAULT '',
        last_change DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
        min_x REAL, min_y REAL, max_x REAL, max_y REAL,
        srs_id INTEGER REFERENCES gpkg_spatial_ref_sys(srs_id)
    );
""")

Step 4 — Create gpkg_geometry_columns and gpkg_extensions

python
# GeoPackage context
conn.execute("""
    CREATE TABLE IF NOT EXISTS gpkg_geometry_columns (
        table_name         TEXT    NOT NULL,
        column_name        TEXT    NOT NULL,
        geometry_type_name TEXT    NOT NULL,
        srs_id             INTEGER NOT NULL,
        z                  TINYINT NOT NULL,
        m                  TINYINT NOT NULL,
        CONSTRAINT pk_geom_cols PRIMARY KEY (table_name, column_name),
        CONSTRAINT fk_gc_tn  FOREIGN KEY (table_name)
            REFERENCES gpkg_contents(table_name),
        CONSTRAINT fk_gc_srs FOREIGN KEY (srs_id)
            REFERENCES gpkg_spatial_ref_sys(srs_id)
    );
""")

conn.execute("""
    CREATE TABLE IF NOT EXISTS gpkg_extensions (
        table_name     TEXT,
        column_name    TEXT,
        extension_name TEXT NOT NULL,
        definition     TEXT NOT NULL,
        scope          TEXT NOT NULL,
        CONSTRAINT ge_tce UNIQUE (table_name, column_name, extension_name)
    );
""")

Step 5 — Create user feature table, then register it

python
# GeoPackage context — register in contents before geometry_columns (FK order)
conn.execute("""
    CREATE TABLE IF NOT EXISTS field_observations (
        id   INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT    NOT NULL,
        geom BLOB
    );
""")

conn.execute("""
    INSERT OR IGNORE INTO gpkg_contents
        (table_name, data_type, identifier, srs_id, min_x, min_y, max_x, max_y)
    VALUES ('field_observations', 'features', 'Field Observations',
            4326, -180.0, -90.0, 180.0, 90.0);
""")

conn.execute("""
    INSERT OR IGNORE INTO gpkg_geometry_columns
        (table_name, column_name, geometry_type_name, srs_id, z, m)
    VALUES ('field_observations', 'geom', 'POINT', 4326, 0, 0);
""")
conn.commit()

Step 6 — Create the R-tree spatial index and register the extension

python
# GeoPackage context — R-tree via SQLite rtree module
conn.execute("""
    CREATE VIRTUAL TABLE IF NOT EXISTS rtree_field_observations_geom
    USING rtree(id, minx, maxx, miny, maxy);
""")

# Register the extension so readers know the index exists
conn.execute("""
    INSERT OR IGNORE INTO gpkg_extensions
        (table_name, column_name, extension_name, definition, scope)
    VALUES ('field_observations', 'geom',
            'gpkg_rtree_index',
            'http://www.geopackage.org/spec/#extension_rtree_index',
            'write-only');
""")
conn.commit()

Step 7 — Insert features using GPB-encoded geometry

python
from shapely.geometry import Point
from shapely.wkb import dumps as wkb_dumps

def to_gpkg_blob(geom, srs_id: int = 4326) -> bytes:
    """Wrap a Shapely geometry in a GeoPackage Binary (GPB) header.

    Layout: 'GP' | version=0x00 | flags=0x01 (little-endian, no envelope)
            | srs_id (little-endian int32) | ISO WKB payload.
    Raw WKB without this header is silently rejected by GDAL and QGIS.
    """
    wkb = wkb_dumps(geom, hex=False, include_srid=False)
    header = b"GP" + struct.pack("<BB", 0x00, 0x01) + struct.pack("<i", srs_id)
    return header + wkb

def insert_feature(conn: sqlite3.Connection,
                   name: str, lat: float, lon: float,
                   srs_id: int = 4326) -> int:
    geom_blob = to_gpkg_blob(Point(lon, lat), srs_id)
    cur = conn.execute(
        "INSERT INTO field_observations (name, geom) VALUES (?, ?);",
        (name, geom_blob),
    )
    row_id = cur.lastrowid
    # Keep R-tree in sync: extract envelope from Point (minx=maxx, miny=maxy)
    conn.execute(
        "INSERT OR REPLACE INTO rtree_field_observations_geom "
        "VALUES (?, ?, ?, ?, ?);",
        (row_id, lon, lon, lat, lat),
    )
    return row_id

For advanced connection pooling and lifecycle management across concurrent workers, pass a single WAL-mode connection per thread or use check_same_thread=False with explicit locking — do not open multiple write connections to the same GeoPackage without coordinating WAL checkpoints.

Validation & Verification

After building a GeoPackage programmatically, run these checks before distributing the file to field devices or downstream consumers.

Check application ID and user version:

bash
# SQLite CLI — verify GeoPackage header PRAGMAs
sqlite3 field_survey.gpkg "PRAGMA application_id; PRAGMA user_version;"
# Expected: 1196444487 then 10200 (or higher)

Verify mandatory tables exist:

sql
-- GeoPackage context — confirm all four system tables are present
SELECT name FROM sqlite_master
WHERE type='table'
  AND name IN (
      'gpkg_spatial_ref_sys',
      'gpkg_contents',
      'gpkg_geometry_columns',
      'gpkg_extensions'
  )
ORDER BY name;
-- Must return all four rows

Check contents registration:

sql
-- GeoPackage context — every feature table must appear here
SELECT table_name, data_type, srs_id FROM gpkg_contents;

Verify geometry column registration:

sql
-- GeoPackage context — must match gpkg_contents rows 1-to-1
SELECT table_name, column_name, geometry_type_name, srs_id
FROM gpkg_geometry_columns;

Use ogrinfo for end-to-end OGC validation:

bash
# Validate feature layer discovery via GDAL (OGR driver)
ogrinfo -al -so field_survey.gpkg
# Expect: layer name, geometry type, feature count, SRS info
# A missing GPB header causes geometry type to print as "None"

For a full compliance audit including tile matrix validation and extension-scope checks, see How to Validate GeoPackage OGC Compliance.

Common Failure Modes & Fixes

Missing gpkg_extensions row for R-tree

Symptom: Spatial queries run but return all features regardless of bounding box; ogrinfo -al -so shows no spatial index.

Diagnosis:

sql
-- GeoPackage context
SELECT * FROM gpkg_extensions WHERE extension_name = 'gpkg_rtree_index';
-- Returns zero rows if the extension was never registered

Fix: Insert the missing registration row (shown in Step 6 above), then verify the R-tree virtual table exists with SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'rtree_%';.


R-tree out of sync after bulk insert

Symptom: Spatial queries miss features inserted via raw INSERT without updating the R-tree shadow tables.

Diagnosis:

sql
-- GeoPackage context — compare row counts
SELECT COUNT(*) FROM field_observations;
SELECT COUNT(*) FROM rtree_field_observations_geom;
-- If counts differ, the R-tree is stale

Fix: Rebuild the index by deleting and repopulating the R-tree from the geometry BLOBs. Because triggers on the feature table update the R-tree automatically only if GDAL created the index (it installs triggers), a manually created R-tree requires explicit updates in your Python insertion loop, as shown in insert_feature() above.


Wrong geometry blob format (raw WKB stored instead of GPB)

Symptom: ogrinfo reports geometry type as None; QGIS shows an empty layer with no error.

Diagnosis:

python
# Read back the first geometry blob and inspect the magic bytes
conn = sqlite3.connect("field_survey.gpkg")
row = conn.execute("SELECT geom FROM field_observations LIMIT 1;").fetchone()
blob = row[0]
print(blob[:2])  # Must be b'GP'; if b'\x01' or b'\x00\x00', it is raw WKB

Fix: Re-encode all rows with to_gpkg_blob() and update in place using UPDATE field_observations SET geom = ? WHERE id = ?.


gpkg_contents and gpkg_geometry_columns out of sync

Symptom: GDAL opens the file but finds no layers; ogrinfo lists zero feature classes.

Diagnosis:

sql
-- GeoPackage context
SELECT gc.table_name FROM gpkg_geometry_columns gc
LEFT JOIN gpkg_contents c ON gc.table_name = c.table_name
WHERE c.table_name IS NULL;
-- Any row returned means a geometry column has no contents registration

Fix: Insert the missing row into gpkg_contents with the correct data_type = 'features' and a valid srs_id. GDAL uses gpkg_contents as its layer discovery table; gpkg_geometry_columns alone is insufficient.


Undefined SRS rows missing

Symptom: PRAGMA foreign_keys=ON; combined with INSERT INTO gpkg_contents throws FOREIGN KEY constraint failed.

Diagnosis:

sql
-- GeoPackage context
SELECT srs_id FROM gpkg_spatial_ref_sys WHERE srs_id IN (-1, 0);
-- Must return two rows before any contents can be inserted

Fix: Run the three-row seed INSERT OR IGNORE from Step 2 before creating any other tables.

Performance Notes

R-tree rebuild cost. Rebuilding the R-tree after a bulk insert that bypassed trigger-based updates scales as O(N log N) against feature count. For imports of over 100 000 features, batch-insert into the feature table first, then rebuild the R-tree in one pass using a single INSERT INTO rtree_field_observations_geom SELECT ... statement rather than per-row updates.

WAL checkpoint timing. In WAL mode, write-ahead log files accumulate until a checkpoint runs. For GeoPackage files synced to field devices over limited bandwidth, trigger PRAGMA wal_checkpoint(TRUNCATE); after each major import batch to keep the .gpkg-wal file small and avoid shipping oversized files.

Page-cache sizing. GeoPackage spatial queries scan R-tree shadow tables (_node, _parent) as B-tree pages. Setting PRAGMA cache_size=-16000; (16 MB) before spatial query sessions keeps hot index pages in memory and reduces read amplification on datasets with dense geometry clustering. For a wider treatment of WAL tuning and page-cache configuration, see the Connection Pooling & Lifecycle Management reference.

VACUUM and spatial indexes. Running VACUUM on a GeoPackage compacts the file but does not rebuild R-tree statistics. After vacuuming a large container, run INSERT INTO rtree_field_observations_geom(rtree_field_observations_geom) VALUES('optimize'); to defragment the R-tree nodes and restore query selectivity.

Pages in This Section