Reading the SQLite Database Header for Spatial Files
Read the first 100 bytes of any SpatiaLite or GeoPackage file and unpack them with struct to recover the page size (offset 16), the file-format write and read versions (offsets 18 and 19) that tell you whether the file uses WAL or a rollback journal, the text encoding (offset 56), and the change counter and page count — the state signals you need before opening the file.
This page is part of the File Structure & Header Analysis guide. Its companion, How to Inspect a GeoPackage File Header in Python, reads the GeoPackage-specific application_id; this page reads the generic SQLite header structure that every spatial file shares, and explains what each field says about the file’s storage state.
Why This Matters
Every SpatiaLite database and every GeoPackage is a SQLite file, and every SQLite file opens with the same fixed 100-byte header. Those bytes are written before any spatial table exists, so they are readable on a file you cannot or should not open with a connection — one that is locked, mid-sync, or of uncertain integrity. The header answers questions that matter operationally: Is this file journaling with WAL, so a -wal sidecar must travel with it? What text encoding were its string columns written in? How many pages does it claim, and does that match the bytes on disk?
For field and offline-first pipelines, the WAL-versus-rollback distinction is the highest-value fact in the header. A GeoPackage copied off a device without its -wal and -shm companions silently loses every change still sitting in the write-ahead log. Detecting write version 2 at ingestion tells your tooling to demand the sidecar files before treating the copy as complete. Reading these fields costs a single 100-byte read and no database lock, which makes it a cheap, safe pre-flight gate ahead of the heavier extension loading and schema checks.
Prerequisites
- Python 3.9+ (standard library
structandpathlibonly) - Read access to the target
.gpkg,.sqlite, or.dbfile - Awareness that all multi-byte integers in the header are big-endian
- The GeoPackage identity fields covered in How to Inspect a GeoPackage File Header in Python — read together, the two pages decode the whole header
Primary Method
The parser below reads the full 100 bytes and returns every field this page discusses, translating the coded values (journal mode, text encoding) into readable labels.
# SpatiaLite / GeoPackage -- full generic SQLite header parse
import struct
from pathlib import Path
from dataclasses import dataclass
SQLITE_MAGIC = b"SQLite format 3\x00"
JOURNAL_MODE = {1: "rollback journal (legacy)", 2: "WAL (write-ahead log)"}
TEXT_ENCODING = {1: "UTF-8", 2: "UTF-16le", 3: "UTF-16be"}
@dataclass
class SqliteHeader:
page_size: int
write_version: int
read_version: int
journal_mode: str
change_counter: int
page_count: int
text_encoding: str
sqlite_version: int
def read_sqlite_header(path: str | Path) -> SqliteHeader:
path = Path(path)
with open(path, "rb") as fh:
h = fh.read(100)
if len(h) < 100:
raise ValueError(f"Truncated file: {len(h)} bytes (need 100).")
if h[0:16] != SQLITE_MAGIC:
raise ValueError("Not a SQLite database (magic string mismatch).")
# Page size: 2-byte big-endian; a raw value of 1 encodes 65536 per spec.
raw_page = struct.unpack(">H", h[16:18])[0]
page_size = 65536 if raw_page == 1 else raw_page
write_version = h[18] # single byte
read_version = h[19] # single byte
change_counter = struct.unpack(">I", h[24:28])[0]
page_count = struct.unpack(">I", h[28:32])[0]
enc_code = struct.unpack(">I", h[56:60])[0]
sqlite_version = struct.unpack(">I", h[96:100])[0]
# Journal mode is signalled by the write/read version pair: 2 => WAL.
mode_code = 2 if max(write_version, read_version) >= 2 else 1
return SqliteHeader(
page_size=page_size,
write_version=write_version,
read_version=read_version,
journal_mode=JOURNAL_MODE.get(mode_code, "unknown"),
change_counter=change_counter,
page_count=page_count,
text_encoding=TEXT_ENCODING.get(enc_code, f"unknown ({enc_code})"),
sqlite_version=sqlite_version,
)
Note that offsets 18 and 19 are single bytes, not multi-byte integers — index them directly (h[18]) rather than unpacking. Only the wider fields (page size, counters, encoding) go through struct.unpack.
Step-by-Step Walkthrough
1. Read the page size at offset 16
The 2-byte page size sets the block alignment for the whole file. Spatial files with large geometry BLOBs or raster tiles are often written at 8 KB, 32 KB, or 64 KB. The spec encodes 65536 as the raw value 1, so handle that special case:
# SpatiaLite / GeoPackage -- page size with the 65536 special case
import struct
raw = struct.unpack(">H", header[16:18])[0]
page_size = 65536 if raw == 1 else raw
print(f"page size: {page_size} bytes")
2. Read the write and read versions at offsets 18 and 19
These two single bytes are the header’s journaling signal. A value of 1 means the classic rollback-journal mode; 2 means write-ahead logging. When either is 2, the file has (or recently had) a -wal companion:
# SpatiaLite / GeoPackage -- detect WAL vs rollback
write_version = header[18]
read_version = header[19]
uses_wal = max(write_version, read_version) >= 2
print(f"journaling: {'WAL' if uses_wal else 'rollback journal'}")
For field pipelines this is the decisive check: a WAL-mode GeoPackage must be copied together with its -wal and -shm sidecars or you lose uncommitted changes. WAL mode itself is configured through PRAGMA journal_mode=WAL, and its role in concurrent access is covered in the Extension Compatibility in Spatial SQLite guide.
3. Read the change counter at offset 24 and page count at offset 28
The change counter increments on every committed write transaction; the page count is the database size in pages. Together they let you reason about the file’s write history and expected on-disk size:
# SpatiaLite / GeoPackage -- counters
import struct
change_counter = struct.unpack(">I", header[24:28])[0]
page_count = struct.unpack(">I", header[28:32])[0]
print(f"changes committed: {change_counter}, pages: {page_count}")
4. Read the text encoding at offset 56
The 4-byte encoding field governs how TEXT columns — attribute values, layer names, styling metadata — are stored. Nearly all modern spatial writers use UTF-8 (1), but legacy or Windows-origin files may use UTF-16, which changes how you decode attribute strings pulled straight from pages:
# SpatiaLite / GeoPackage -- text encoding
import struct
enc = {1: "UTF-8", 2: "UTF-16le", 3: "UTF-16be"}
code = struct.unpack(">I", header[56:60])[0]
print(f"text encoding: {enc.get(code, 'unknown')}")
5. Combine the fields into a file-state summary
Reading all five together gives a one-line profile of the file before you open it: its block size, whether it needs sidecars, how much it has been written, its declared size, and its string encoding. That profile is what you log per file in a batch run.
Verification
Confirm every decoded field against SQLite’s own PRAGMAs. Agreement across all of them proves the offsets and endianness are correct:
# SpatiaLite / GeoPackage -- verify header parse against PRAGMAs
import sqlite3
hdr = read_sqlite_header("field.gpkg")
print(hdr)
conn = sqlite3.connect("file:field.gpkg?mode=ro", uri=True)
assert conn.execute("PRAGMA page_size").fetchone()[0] == hdr.page_size
assert conn.execute("PRAGMA page_count").fetchone()[0] == hdr.page_count
# journal_mode reflects the live mode; 'wal' should agree with the header signal
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
conn.close()
print(f"PRAGMA journal_mode={mode}; header says {hdr.journal_mode}")
From the shell you can confirm the size arithmetic without Python:
# SpatiaLite / GeoPackage -- page_size * page_count should match file bytes
sqlite3 field.gpkg "PRAGMA page_size; PRAGMA page_count;"
# Multiply the two and compare with: stat -c %s field.gpkg
Alternative Approaches or Edge Cases
Change counter versus version-valid-for. If the change counter at offset 24 differs from the version-valid-for field at offset 92, the file was written by an old SQLite or a write was interrupted. Reading both and comparing them is a lightweight interrupted-write detector.
Reserved bytes per page. Encrypted or checksum-augmented spatial files reserve trailing bytes on every page (offset 20 holds that count). If you compute usable page payload for low-level parsing, subtract this reserved-byte value; a nonzero value here also hints the file may be an SQLCipher or checksum-VFS database rather than plain SQLite.
Zero page count on a nonempty file. SQLite treats the offset-28 page count as authoritative only when the change counter matches version-valid-for; some tools leave it zero. When it is zero, fall back to file_size // page_size for an estimate.
Troubleshooting
ValueError: Not a SQLite database (magic string mismatch)
Cause: The first 16 bytes are not SQLite format 3\x00 — the file is encrypted, compressed, truncated at the front, or simply not SQLite.
Fix: Confirm the source. An SQLCipher-encrypted spatial file has random-looking leading bytes by design and cannot be header-parsed without the key; decrypt it first. A truncated transfer must be re-fetched.
journal_mode reads WAL but no -wal file is present
Cause: The file was last closed cleanly (SQLite checkpointed and removed the -wal), or the sidecar was dropped during a copy. The header still records write version 2.
Fix: A missing -wal after a clean checkpoint is harmless — all data is in the main file. But if the file was copied while open, re-copy it with its -wal and -shm companions to avoid losing uncommitted changes.
Decoded page size or counters look absurd (e.g. billions of pages)
Cause: The bytes were unpacked little-endian, or a single-byte field (offset 18/19) was read as a multi-byte integer.
Fix: Use big-endian format strings (">H", ">I") for the wide fields and index the write/read versions as single bytes (header[18]). The SQLite header is big-endian throughout.
Related
- File Structure & Header Analysis — parent guide: the complete header table, format routing, and batch validation
- How to Inspect a GeoPackage File Header in Python — the GeoPackage
application_idanduser_versionfields that sit inside this same header - Extension Compatibility in Spatial SQLite — WAL mode and extension loading for the files this header describes
- Connection Pooling & Lifecycle Management — opening header-validated files exactly once per pipeline run