Short answer
GeoPackage and PostGIS solve different problems. A GeoPackage is an OGC-standard file: a single SQLite database (.gpkg) that can carry many vector layers, raster tile pyramids, attribute tables, styles, and metadata in one portable container. PostGIS is not a file at all; it is the spatial extension to a running PostgreSQL server that adds geometry and geography types, spatial indexing, and several hundred SQL functions for analysis and concurrent access.
Choose GeoPackage when one analyst or one project owns the data, when it must travel offline, or when you are handing a self-contained deliverable to a client. Choose PostGIS when several people or services read and write the same data, when you need transactional integrity, scheduled processing, or an application backend behind a web map or API.
What each one actually is
GeoPackage
GeoPackage (OGC 12-128r19) defines a profile on top of SQLite 3. Every layer becomes a table; geometries are stored in the Standard GeoPackage Binary encoding (a small header plus WKB). The format requires that spatial reference systems be declared in the gpkg_spatial_ref_sys table, and feature tables are registered in gpkg_contents and gpkg_geometry_columns. Spatial indexing is optional and implemented as a SQLite R-tree virtual table named rtree_<table>_<geom>.
Because it is a single file, a GeoPackage copies cleanly, checksums cleanly, and opens with no server. GDAL, QGIS, ArcGIS Pro, and most modern tooling read and write it natively. There is no .dbf field-name truncation, no separate sidecar files to lose, and attribute types include real DATETIME, BOOLEAN, and 64-bit integers — all things the shapefile cannot do.
PostGIS
PostGIS adds the geometry, geography, raster, and topology types to PostgreSQL. Geometries are stored with an explicit SRID; spatial indexes are GIST indexes over the geometry's bounding box. You get ACID transactions, row-level locking, materialized views, triggers, foreign keys, and the full SQL planner. A query like the one below runs server-side and returns only the rows you need:
SELECT g.unit_name, ST_Area(g.geom) AS area_m2
FROM geology g
JOIN license_block b ON ST_Intersects(g.geom, b.geom)
WHERE b.block_id = 'BLK-204';
That ST_Intersects call uses the GIST index automatically through the && bounding-box operator, then refines with exact geometry — something a flat file cannot do without scanning every feature.
The concurrency line — the real decision
The single most important difference is write concurrency. SQLite, and therefore GeoPackage, uses file-level locking: one writer at a time, full stop. Two analysts editing the same .gpkg on a network share (SMB/NFS) will eventually corrupt it, because file locking over network filesystems is unreliable. This is not a bug; it is the documented expected behaviour.
PostgreSQL was built for concurrency. With MVCC (multi-version concurrency control), many readers and writers operate at once without blocking each other, and each transaction sees a consistent snapshot. The moment your answer to "who edits this?" stops being "one person on one machine," you have crossed into PostGIS territory.
A decision frame that holds up
- One analyst, bounded project, offline laptop, archive or client handoff → GeoPackage.
- Multiple concurrent editors, web app backend, scheduled ETL, API → PostGIS.
- Read-heavy single-user analysis on local SSD → GeoPackage is often faster and simpler.
- Tens of millions of features, complex joins, row-level security, audit history → PostGIS.
- You need to ship the data to someone who has no server → GeoPackage, even if you author in PostGIS.
A pragmatic pattern: keep the authoritative data in PostGIS, and export GeoPackage snapshots for delivery. The two are complementary, not rivals.
Worked example: migrating GeoPackage to PostGIS
Say you have built survey.gpkg with a samples point layer in UTM Zone 31N (EPSG:32631) and it now needs to back a multi-user web map. Load it with GDAL's ogr2ogr:
ogr2ogr -f PostgreSQL \
PG:"host=db.internal dbname=geo user=etl" \
survey.gpkg samples \
-nln public.samples \
-nlt PROMOTE_TO_MULTI \
-lco GEOMETRY_NAME=geom \
-lco FID=id \
-lco SPATIAL_INDEX=GIST \
-t_srs EPSG:32631
A few flags earn their place. -nlt PROMOTE_TO_MULTI avoids the classic failure where a layer holds both POINT and MULTIPOINT (or polygons and multipolygons) and PostgreSQL rejects the mixed type. -lco SPATIAL_INDEX=GIST builds the index during load. -lco GEOMETRY_NAME=geom gives you a predictable column name. If the source SRID is missing, set it explicitly rather than letting the driver guess.
After loading, confirm the geometry registered correctly and the index exists:
SELECT f_table_name, srid, type FROM geometry_columns
WHERE f_table_name = 'samples';
CREATE INDEX IF NOT EXISTS samples_geom_gist
ON public.samples USING GIST (geom);
VACUUM ANALYZE public.samples;
The VACUUM ANALYZE step matters: the query planner needs fresh statistics to choose the spatial index instead of a sequential scan.
To go the other direction — a delivery snapshot from PostGIS to a .gpkg:
ogr2ogr -f GPKG delivery.gpkg \
PG:"host=db.internal dbname=geo user=reader" \
-sql "SELECT id, unit_name, geom FROM geology WHERE region='north'"
Performance notes that actually matter
- GeoPackage R-tree is excellent for single-user, local-disk reads. The bottleneck is usually disk and the lack of parallel writers, not the index.
- PostGIS GIST plus
VACUUM ANALYZEand connection pooling (PgBouncer) scales to many simultaneous queries. Remember to runANALYZEafter large loads, or the planner will misjudge selectivity. - Avoid putting a GeoPackage on a network share for anything beyond read-only sharing. The latency and locking behaviour make it slow and risky.
- For raster, GeoPackage tile pyramids are convenient for offline basemaps; for analytical rasters, Cloud-Optimized GeoTIFF or PostGIS raster usually serve better.
Common pitfalls and why they happen
- Editing a shared
.gpkgover SMB. It works until two people save at once, then the file corrupts. The cause is unreliable network file locking, not GeoPackage itself. - Forgetting to register the SRID in PostGIS. Loading geometry as SRID 0 makes
ST_Transformand distance functions fail or return nonsense. Always confirm the SRID after migration. - Mixed geometry types on load. Without
PROMOTE_TO_MULTI, ogr2ogr aborts midway, leaving a partial table. Promote, or split the layer. - No
ANALYZEafter a big import. The planner falls back to sequential scans and queries crawl, leading people to wrongly blame PostGIS for being "slow." - Treating GeoPackage as obsolete shapefile replacement only. It also stores tiles, styles, and metadata; ignoring those features wastes the format.
QA and validation
Before trusting either store: confirm the declared SRID matches the actual coordinates (compare a known point), verify geometry validity with ST_IsValid in PostGIS or gdalinfo/ogrinfo -al -so on the GeoPackage, check feature counts before and after migration, and spot-check a few attribute values to confirm no encoding or type drift. For GeoPackage specifically, run an integrity check with PRAGMA integrity_check; if you suspect corruption.
Bathyl perspective
We treat the GeoPackage/PostGIS choice as an architecture decision, not a format preference. The authoritative copy usually lives in PostGIS where it can be queried, versioned, and served; GeoPackage is how we hand a frozen, self-contained slice to a client or a field laptop. The format follows the access pattern, never the reverse.
Related reading
- Best GIS File Format for Geological Mapping
- GeoParquet for Spatial Analytics
- Why Your GIS Layers Do Not Line Up
- Spatial data products