Comparing GeoPackage and PostGIS for Field-to-Archive Handoff

The single most consequential architecture decision in a mobile heritage workflow is which backend holds the data at each stage. Reach for a server database in the trench and you have nothing when the signal drops; keep everything in a single file and you cannot let three recorders and a specialist edit concurrently once the data reaches the office. GeoPackage and PostGIS are not competitors so much as the two ends of the same lifecycle, and the skill is knowing exactly where the handoff sits. This guide lays out the trade-offs and shows the ogr2ogr conversions that move data across the boundary in both directions. It is part of Participatory GIS and Mobile Field Sync.

GeoPackage to PostGIS lifecycle handoff GeoPackage serves the offline field stage while PostGIS serves the concurrent archive, with ogr2ogr moving data across the handoff. Where each backend fits GeoPackage offline field capture single file, portable PostGIS concurrent archive multi-user server ogr2ogr load ogr2ogr re-package

Context & When to Use

GeoPackage is an OGC-standard SQLite file: one portable artefact, no server, opens on any tablet, and travels as an email attachment or a USB copy. That portability is exactly why it is the field format — and exactly why it fails as a shared archive. SQLite takes a single file-level write lock, so two simultaneous writers are impossible; the second gets database is locked. PostGIS, by contrast, is built for concurrency, row-level locking, and MVCC, which is what a multi-user archive with a live web map needs, at the cost of a server to run and administer. The rule of thumb: file where it is offline and single-user, server where it is concurrent and shared. The heavier merge and provenance logic that lives on the PostGIS side is covered in merging field audit trails into the master database, and the offline packaging that produces the field GeoPackage in the first place is in packaging offline field projects with QFieldSync.

Dimension GeoPackage (SQLite) PostGIS (PostgreSQL)
Deployment Single .gpkg file, no server Server process, roles, network access
Concurrency One writer at a time (file lock) Many concurrent writers, row-level locks / MVCC
Portability Copy/email/USB; opens offline anywhere Needs a connection string and credentials
Practical size ceiling Comfortable to a few GB; degrades on very large, heavily-indexed tables Terabytes; partitioning and tuning available
SRID storage gpkg_spatial_ref_sys table, one SRID per geometry column spatial_ref_sys (EPSG-seeded), enforced per column
Indexing R-tree via gpkg_rtree; rebuilt on load GiST / SP-GiST, CLUSTER, partial indexes
Audit / triggers SQLite triggers, limited Full PL/pgSQL triggers, jsonb audit logs
Best lifecycle stage Offline capture, transfer, single-analyst work Central archive, web delivery, concurrent editing

Implementation

Moving field data into the archive is a load; re-cutting a fresh field package from the archive is the reverse. Both are single ogr2ogr invocations.

# GDAL/OGR 3.8.4 (system binary)
# GeoPackage --> PostGIS: load a device layer into the archive
# PostGIS 3.4 target; -t_srs guarantees the archive SRID even if the file drifted
ogr2ogr -f PostgreSQL \
  "PG:host=db.internal dbname=heritage user=archivist" \
  /data/sync/tablet_07/survey.gpkg finds \
  -nln finds_incoming \
  -t_srs EPSG:27700 \            # substitute your site's EPSG
  -lco GEOMETRY_NAME=geom \
  -lco FID=fid \
  -nlt POINT \
  --config PG_USE_COPY YES       # bulk COPY, far faster than row-by-row INSERT
# PostGIS --> GeoPackage: re-package an area of interest for the next field session
# Only the current trench block, so the device file stays small
ogr2ogr -f GPKG \
  /data/field_packages/trench_B.gpkg \
  "PG:host=db.internal dbname=heritage user=archivist" \
  -sql "SELECT uuid, site_id, feature_type, geom FROM finds_master \
        WHERE site_id = 'AVN-2026' AND ST_Intersects(geom, \
        ST_MakeEnvelope(451200,213400,451260,213470,27700))" \
  -nln finds \
  -a_srs EPSG:27700              # substitute your site's EPSG

For a repeatable check that a round trip preserved feature counts and geometry, inspect both ends with ogrinfo -so and compare the reported Feature Count and Extent rather than trusting the load silently succeeded.

Field note. A GeoPackage's built-in R-tree spatial index is not automatically rebuilt when you bulk-load features with some tools, so a field file that felt instant in the office can crawl on the tablet as QField falls back to full-table scans. After any bulk write, confirm the gpkg_rtree index exists for the geometry column and reload if it is missing — an unindexed find layer with a few thousand points is the usual cause of a "frozen" tablet in the trench.

Verification

Confirm the SRID survived the handoff and both stores agree on feature count:

# GDAL/OGR 3.8.4 — field file side
ogrinfo -so /data/field_packages/trench_B.gpkg finds | grep -E "SRS|Feature Count|Extent"
-- PostgreSQL 16 / PostGIS 3.4 — archive side
SELECT ST_SRID(geom) AS srid, count(*) AS n
FROM finds_master
WHERE site_id = 'AVN-2026'
GROUP BY ST_SRID(geom);

Expected: the ogrinfo output reports SRS: ... 27700 and a Feature Count equal to the archive query’s n, and the SQL returns a single row with srid = 27700. Two SRID rows in the archive result mean mixed projections crept in — reproject the offending features before the next re-package.

Common Errors & Fixes

  • sqlite3_step() failed: database is locked (5) — a second process (often a still-open QGIS session or a sync client) holds the GeoPackage write lock. Close every reader/writer; GeoPackage genuinely cannot support concurrent writes, which is the signal you have outgrown the file format and should move that stage to PostGIS.
  • ERROR 1: Cannot find OGRSpatialReference ... could not parse SRS on load — the source GeoPackage geometry column has SRID 0 or a custom local grid with no EPSG. Assign the correct code with -a_srs or reproject with -t_srs; never let a 0 SRID enter the archive.
  • ogr2ogr load is extremely slow — the default row-by-row insert path is in use. Add --config PG_USE_COPY YES to switch to PostgreSQL COPY, and drop non-essential indexes before a large bulk load, recreating them afterward.

Part of Heritage GIS Architecture & Fundamentals.