How to Inspect a GeoPackage File Header in Python

Open the file in binary mode, read the first 72 bytes, confirm bytes 0–15 equal SQLite format 3\x00, then unpack the big-endian applicationid at offset 68…

Open the file in binary mode, read the first 72 bytes, confirm bytes 0–15 equal SQLite format 3\x00, then unpack the big-endian application_id at offset 68 (0x47504B47 = “GPKG”) and the user_version at offset 60, which encodes the GeoPackage version as the integer MMmmPP.

This page sits under the File Structure & Header Analysis guide. That guide surveys the whole 100-byte header; here we zoom in on the two fields that specifically identify a GeoPackage — application_id and user_version — and show how to read them from raw bytes without ever handing the file to SQLite.

Why This Matters

The GeoPackage standard requires that a conformant container advertise itself in two header fields rather than relying on its .gpkg extension. Requirement 2 of the OGC standard fixes the application_id to the ASCII bytes GPKG (0x47504B47), and the accompanying rule sets user_version to a four-or-five digit integer encoding the specification version. A file that carries the right extension but leaves these fields at zero is, byte for byte, an ordinary SQLite database — and a spatial pipeline that trusts the extension will discover the difference only when a gpkg_contents query throws.

Reading these fields directly from bytes has two operational advantages over opening the file with sqlite3. First, it is safe against locked or partially synced files: a plain open(path, "rb") never acquires a database lock, so it works on a .gpkg that a mobile sync process still holds open. Second, it is fast and dependency-free — 72 bytes and a couple of struct.unpack calls, no SQLite connection, no WAL replay. That makes it the ideal first gate in a batch ingestion loop before any heavier validation runs.

Why the Header, Not PRAGMA

PRAGMA application_id and PRAGMA user_version return the same values, but running a PRAGMA requires opening a connection, which replays the WAL and can block on a locked file. Reading the bytes is the lock-free equivalent — identical data, no side effects. Use the PRAGMA form only when you already hold an open connection for other reasons.

Prerequisites

  • Python 3.9+ (standard library only — struct and pathlib; no third-party packages)
  • Read access to the target .gpkg file; it may be locked by another process
  • Familiarity with big-endian integer encoding — the SQLite header stores all multi-byte integers most-significant-byte first
  • The broader 100-byte layout from the parent File Structure & Header Analysis guide, and the generic-field reading covered in Reading the SQLite Database Header for Spatial Files

Primary Method

The function below reads only what it needs, validates the magic string, and returns a structured verdict including the decoded GeoPackage version string. It never opens SQLite.

python
# GeoPackage -- header-only identity and version check (no SQLite connection)
import struct
from pathlib import Path
from dataclasses import dataclass

SQLITE_MAGIC     = b"SQLite format 3\x00"   # bytes 0..15
GPKG_APP_ID      = 0x47504B47               # 'GPKG', GeoPackage 1.2+
GP10_APP_ID      = 0x47503130               # 'GP10', legacy GeoPackage 1.0/1.1
HEADER_MIN_BYTES = 72                       # enough to reach application_id (68..71)


@dataclass
class GeoPackageHeader:
    is_sqlite: bool
    is_geopackage: bool
    app_id_hex: str
    app_id_ascii: str
    user_version: int
    gpkg_version: str   # e.g. "1.3.1" decoded from user_version


def _decode_version(user_version: int) -> str:
    """user_version encodes the spec version as MMmmPP (major, minor, patch)."""
    if user_version == 0:
        return "unset"
    patch = user_version % 100
    minor = (user_version // 100) % 100
    major = user_version // 10000
    return f"{major}.{minor}.{patch}"


def inspect_geopackage_header(path: str | Path) -> GeoPackageHeader:
    path = Path(path)
    with open(path, "rb") as fh:
        head = fh.read(HEADER_MIN_BYTES)
    if len(head) < HEADER_MIN_BYTES:
        raise ValueError(
            f"Truncated file: {len(head)} bytes read, need >= {HEADER_MIN_BYTES}."
        )

    is_sqlite = head[0:16] == SQLITE_MAGIC
    # user_version lives at offset 60..63, application_id at 68..71 — both big-endian.
    user_version = struct.unpack(">I", head[60:64])[0]
    app_id       = struct.unpack(">I", head[68:72])[0]

    app_id_ascii = app_id.to_bytes(4, "big").decode("ascii", errors="replace")
    return GeoPackageHeader(
        is_sqlite=is_sqlite,
        is_geopackage=app_id in (GPKG_APP_ID, GP10_APP_ID),
        app_id_hex=f"0x{app_id:08X}",
        app_id_ascii=app_id_ascii,
        user_version=user_version,
        gpkg_version=_decode_version(user_version),
    )

The application_id and user_version sit at fixed offsets 68 and 60, so reading 72 bytes is sufficient — there is no need to pull the full 100-byte header when GeoPackage identity is all you want.

Step-by-Step Walkthrough

1. Read just the bytes you need

Sixty-eight through seventy-one is the last field of interest, so 72 bytes covers it. Reading a fixed slice keeps the check O(1) regardless of file size:

python
# GeoPackage -- minimal byte read
with open("survey.gpkg", "rb") as fh:
    head = fh.read(72)
assert len(head) == 72, "file shorter than the GeoPackage identity fields"

2. Confirm it is a SQLite container at all

Every GeoPackage is first a SQLite database, so the 16-byte magic string must match before the GeoPackage-specific fields mean anything:

python
# GeoPackage -- magic string gate
if head[0:16] != b"SQLite format 3\x00":
    raise ValueError("Not a SQLite database — cannot be a GeoPackage.")

3. Decode the application_id at offset 68

The four bytes at offset 68 are the ASCII characters GPKG for GeoPackage 1.2 and later. Decoding both as an integer and as text makes the check readable in logs:

python
# GeoPackage -- application_id decode
import struct
app_id = struct.unpack(">I", head[68:72])[0]
print(f"application_id: 0x{app_id:08X} ({app_id.to_bytes(4, 'big').decode('ascii', 'replace')})")
# Expect 0x47504B47 = 'GPKG'; legacy files show 0x47503130 = 'GP10'

4. Decode the user_version at offset 60

user_version is a separate 4-byte big-endian integer that carries the GeoPackage specification version. The OGC standard defines it as MMmmPP: 10301 means major 1, minor 3, patch 1 — GeoPackage 1.3.1. Splitting the integer recovers the human-readable version:

python
# GeoPackage -- user_version decode (MMmmPP)
uv = struct.unpack(">I", head[60:64])[0]
major, minor, patch = uv // 10000, (uv // 100) % 100, uv % 100
print(f"user_version {uv} -> GeoPackage {major}.{minor}.{patch}")

A user_version of 0 means the writer never set it; the file may still be a valid GeoPackage if application_id is GPKG, but the version is simply unknown from the header.

5. Combine into a single verdict

Treat identity as a two-part test: the magic string proves SQLite, and application_id proves GeoPackage. user_version is informational — record it, but do not reject a file solely because it is zero, since some older writers set only the application_id.

Verification

Cross-check the byte-level reading against what SQLite itself reports. The values must agree, which proves your offset arithmetic:

python
# GeoPackage -- confirm header bytes match PRAGMA output
import sqlite3

hdr = inspect_geopackage_header("survey.gpkg")
print(hdr)

conn = sqlite3.connect("file:survey.gpkg?mode=ro", uri=True)
pragma_app = conn.execute("PRAGMA application_id").fetchone()[0]
pragma_uv  = conn.execute("PRAGMA user_version").fetchone()[0]
conn.close()

assert pragma_app == int(hdr.app_id_hex, 16), "application_id mismatch"
assert pragma_uv == hdr.user_version, "user_version mismatch"
print("Header bytes agree with PRAGMA values.")

You can also confirm the raw integer from the shell without Python:

bash
# GeoPackage -- PRAGMA cross-check
sqlite3 survey.gpkg "PRAGMA application_id; PRAGMA user_version;"
# application_id 1196444487 = 0x47504B47 ('GPKG'); user_version e.g. 10301 = 1.3.1

Alternative Approaches or Edge Cases

Empty GeoPackage created by GDAL. A freshly created, layer-free GeoPackage still carries the correct application_id and user_version, so header inspection accepts it even though a gpkg_contents query would return no rows. That is the intended behavior — identity and content are separate concerns.

Legacy GP10 files. GeoPackage 1.0 and 1.1 used the application_id 0x47503130 (“GP10”) and did not consistently set user_version. The primary method treats both GPKG and GP10 as GeoPackages; branch on which one matched if you need to reject pre-1.2 files.

Files behind a WAL. Because the identity fields live in the main database file’s page 1 and are not rewritten by ordinary content changes, header inspection is reliable even when uncommitted changes sit in a -wal sidecar. For the fields that do reveal WAL state, see the companion page on the generic header.

Troubleshooting

ValueError: Truncated file: N bytes read, need >= 72

Cause: The file is shorter than 72 bytes — almost always an interrupted download or an incomplete mobile sync that never wrote page 1 fully.

Fix: Re-transfer the file. A GeoPackage smaller than one page cannot contain valid spatial data, so reject it at ingestion rather than attempting repair.

application_id reads 0x00000000 on a .gpkg file

Cause: The file was produced by a writer that created a plain SQLite database and renamed it, or by a tool that populated gpkg_* tables but forgot to stamp the application_id.

Fix: Set it with a compliant writer — PRAGMA application_id = 1196444487; — or regenerate the file through GDAL/OGR. Do not rely on the extension alone; downstream OGC-conformance tooling checks this byte.

user_version decodes to an implausible version like 655.36.0

Cause: The bytes were read with the wrong endianness (little-endian instead of big-endian), producing a garbage integer.

Fix: The SQLite header is big-endian for every multi-byte integer — use struct.unpack(">I", ...), never "<I". The > in the format string is what guarantees the correct decode.