Monitoring GeoPackage File Growth Over Time
Record page_count, freelist_count and the size of any -wal sidecar alongside the file size in every run’s manifest. Bytes on disk alone cannot distinguish a container that gained data from one that deleted rows and kept the pages, or from one whose write-ahead log has simply never been checkpointed — and the three cases call for entirely different responses.
This page belongs to the Data Quality Gates & Monitoring guide.
Why This Matters
On a field device, storage is fixed and a container that grows without bound eventually stops the deployment. The unhelpful part is that file size is an ambiguous signal: SQLite never returns pages to the filesystem on its own, so a container that deleted half its rows is exactly as large as it was, with the freed pages sitting on an internal free list waiting to be reused.
That means a growth alert based on bytes fires for three unrelated situations — real growth, accumulated free pages after deletions, and an unchecked write-ahead log — and only one of them is about the data. Distinguishing them costs two pragmas.
Prerequisites
- A manifest emitted per run, per How to Write a Data Quality Gate for a Field Dataset
sqlite3on the path — nothing here needs the spatial extension- A few runs of history, so a trend exists to look at
- Knowledge of the device’s storage budget, which is what any threshold should come from
Primary Method
# growth.py — the four numbers that describe a container's size honestly
import sqlite3
from pathlib import Path
def size_profile(path: str) -> dict:
p = Path(path)
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
try:
page_size = conn.execute("PRAGMA page_size").fetchone()[0]
page_count = conn.execute("PRAGMA page_count").fetchone()[0]
freelist = conn.execute("PRAGMA freelist_count").fetchone()[0]
finally:
conn.close()
wal = p.with_name(p.name + "-wal")
return {
"bytes_on_disk": p.stat().st_size,
"page_size": page_size,
"page_count": page_count,
"free_pages": freelist,
"free_bytes": freelist * page_size,
"live_bytes": (page_count - freelist) * page_size,
"wal_bytes": wal.stat().st_size if wal.exists() else 0,
}
live_bytes is the number that answers “is this container growing?”. free_bytes answers “is it fragmenting?”. wal_bytes answers “has anyone checkpointed?”. Watching only the first field of the four gives an alert that cannot tell you which question just changed.
Step-by-Step Walkthrough
1. Add the profile to the manifest
manifest["size"] = size_profile(container_path)
Recording all four numbers costs nothing and makes the history interpretable later. A manifest holding only bytes leaves a future investigation with the same ambiguity this page is about.
2. Compare the right number between runs
def growth_report(now: dict, was: dict) -> list[str]:
notes = []
live_drift = (now["live_bytes"] - was["live_bytes"]) / max(was["live_bytes"], 1)
if abs(live_drift) > 0.25:
notes.append(f"live data moved {live_drift:+.1%}")
free_frac = now["free_bytes"] / max(now["bytes_on_disk"], 1)
if free_frac > 0.20:
notes.append(
f"{free_frac:.0%} of the file is free pages — a VACUUM would reclaim "
f"{now['free_bytes'] / 1e6:.1f} MB"
)
if now["wal_bytes"] > now["live_bytes"] * 0.10:
notes.append(
f"write-ahead log is {now['wal_bytes'] / 1e6:.1f} MB — checkpoint before shipping"
)
return notes
Three separate conditions, three separate messages, three different fixes. That is the whole benefit over a single size threshold.
3. Reclaim free pages deliberately, not routinely
VACUUM rewrites the entire container to eliminate free pages, which takes an exclusive lock and time proportional to the file. It is the right response to a large one-off deletion and the wrong response to a container that cycles rows continually, where the free pages will be reused anyway.
# After a bulk delete, before shipping — never during a sync window
sqlite3 field.gpkg "VACUUM;"
One caveat matters for spatial containers: VACUUM may renumber implicit row identifiers, and the R-tree keys entries by those. Rebuild the spatial index afterwards, following How to Automate R-tree Index Rebuilds After Bulk Load.
4. Bound the write-ahead log
A long-running writer under WAL can leave the log growing for a long time, because automatic checkpointing only happens when a connection does work and the threshold is exceeded. On a container that is mostly read, that can be indefinitely.
-- Fold the log back in and truncate it to zero
PRAGMA wal_checkpoint(TRUNCATE);
Running that on a schedule — at the end of a sync cycle, at shutdown, before an artefact ships — keeps the sidecar bounded without stalling daytime collection.
5. Attribute growth to a layer
When live data really has grown, the next question is which layer. Page-level attribution needs dbstat, which is available in most builds.
-- Bytes per table, where the dbstat virtual table is compiled in
SELECT name, sum(pgsize) AS bytes
FROM dbstat
GROUP BY name
ORDER BY bytes DESC
LIMIT 10;
Where dbstat is unavailable, row counts from the manifest plus an estimate of row width give a usable approximation — and the manifest already has the counts.
Verification
# The profile must add up
p = size_profile("field.gpkg")
assert p["live_bytes"] + p["free_bytes"] == p["page_count"] * p["page_size"]
assert p["free_bytes"] <= p["bytes_on_disk"]
assert p["page_count"] * p["page_size"] <= p["bytes_on_disk"] + p["page_size"]
And confirm a vacuum does what the profile predicted, which is the check that validates the whole model:
before = size_profile("field.gpkg")
reclaimable = before["free_bytes"]
subprocess.run(["sqlite3", "field.gpkg", "VACUUM;"], check=True)
after = size_profile("field.gpkg")
actual = before["bytes_on_disk"] - after["bytes_on_disk"]
assert after["free_bytes"] < before["free_bytes"]
assert abs(actual - reclaimable) < before["page_size"] * 64, (
f"predicted {reclaimable} reclaimable, actually recovered {actual}"
)
If those diverge substantially, the free-page count is not the whole story for that container — usually because a large deletion happened between the two measurements.
Alternative Approaches or Edge Cases
auto_vacuum. SQLite can return free pages to the filesystem incrementally, which avoids the exclusive lock a full vacuum takes. It must be enabled before the container has any data, adds a little overhead to every delete, and fragments the file more than a vacuum does — a reasonable trade for a long-lived container on a device, and not for a build artefact that is rebuilt each night.
Page size. A container’s page size is fixed at creation and affects both size and read performance. Larger pages suit geometry-heavy tables, where a single feature may not fit in a small page and spills to overflow pages; the default is a reasonable compromise and is worth revisiting only with a measurement.
Splitting the container. When live growth is genuinely the problem, splitting by area or by time is usually better than pruning. A device that only needs its own region does not benefit from carrying the whole dataset, and per-region containers also make the sync smaller.
Troubleshooting
The file grew and no rows were added
Cause: Free pages from deletions, or an unchecked write-ahead log. Fix: Read the profile rather than the size; the two are distinguishable in one query and have different responses.
VACUUM reclaimed nothing
Cause: freelist_count was already near zero — the space was in live pages. Fix: The container really did grow, and the response is a capacity decision rather than a maintenance one.
Spatial queries return nothing after a VACUUM
Cause: Implicit row identifiers were renumbered and the R-tree still references the old ones. Fix: Rebuild the spatial index after any vacuum on a table without an explicit integer primary key. This is the most common way a routine maintenance operation breaks a container.
Frequently Asked Questions
How often should a container be vacuumed?
Rarely, and only in response to a measurement. A container that cycles rows continually reuses its free pages and gains nothing from a vacuum; one that had a large one-off deletion gains the whole free-page total. Scheduling a vacuum unconditionally spends an exclusive lock and a full file rewrite on a condition that may not exist.
Should the sidecar size be part of the growth alert?
As its own condition, yes. A large log is a real operational problem — it consumes storage and it means a single-file copy of the container would lose data — but it is not growth, and lumping it into a size alert produces a recommendation to vacuum, which does not help. Keep it as a separate check with its own remedy.
What is a reasonable free-page threshold?
Twenty per cent of the file is a defensible starting point for a container that is shipped rather than continually written. For one that cycles rows, a much higher fraction is normal and healthy, because those pages are about to be reused. As with drift tolerances, the number should come from a couple of weeks of observation rather than from a first guess.
Does a tile pyramid change how this should be read?
Substantially, because a pyramid dominates the live-byte figure and never changes once built. A container carrying both tiles and features will show a large, flat live-byte total with the feature growth buried inside it, and a percentage threshold on the total becomes almost impossible to trip. Where both are present, attribute the size per table with dbstat and track the feature layers separately, or the monitor stops being able to see the part that actually moves.
Related
- Data Quality Gates & Monitoring — parent guide: the manifest this profile joins
- How to Automate R-tree Index Rebuilds After Bulk Load — the rebuild a vacuum makes necessary
- Read-Only WAL Mode for Distributed Field Devices — checkpointing before an artefact ships
- Building a Nightly Integrity Report — where this profile is presented to a person