How to Append a GeoDataFrame to a GeoPackage Layer
Call gdf.to_file("data.gpkg", layer="my_layer", driver="GPKG", mode="a") — but first make the GeoDataFrame’s CRS, column names, and column dtypes match the existing layer exactly, because to_file appends rows without validating that the incoming schema lines up.
This page is part of the GeoPandas & GeoPackage Integration guide. Where converting Shapefiles to GeoPackage with GeoPandas writes a whole new layer from scratch, this walkthrough targets the incremental case: adding rows to a layer that already exists without rewriting the file.
Why This Matters
Appending is the common shape of a real pipeline. A daily job pulls the latest field observations and needs to add them to a observations layer that already holds a month of history; a sync process merges edits from several devices into one master GeoPackage. Rewriting the entire layer on every run is wasteful and, on large tables, risks leaving a half-written file if the process is interrupted.
GeoPandas makes the append itself a one-liner with mode="a", which was added to to_file in GeoPandas 0.8 and routes through the Fiona or pyogrio engine. The risk is not the call — it is the data. GeoPandas does not reproject on append, does not reconcile column order, and maps pandas dtypes to GeoPackage column types on the fly. A mismatched coordinate reference system writes geometry in the wrong projection; a stray NaN in an integer column silently promotes it to a float. This page is about getting those three things — CRS, columns, dtypes — right before you append.
Prerequisites
- Python 3.9+
geopandas0.12+ with either thefiona(1.9+) orpyogrioengine- GDAL/OGR 3.4+ with the
GPKGdriver - An existing GeoPackage with at least one feature layer
- Familiarity with GeoDataFrame CRS handling (
gdf.crs,to_crs)
Primary Method
# GeoPandas 0.12+ / GDAL 3.4+ — safely append rows to an existing GeoPackage layer
import geopandas as gpd
def append_to_layer(gdf: gpd.GeoDataFrame, gpkg_path: str, layer: str) -> int:
"""
Append gdf to an existing GeoPackage layer after aligning CRS,
column order, and dtypes to the target layer. Returns rows written.
"""
# 1. Read the target layer's schema (empty read is cheap: rows=0 via a slice)
target = gpd.read_file(gpkg_path, layer=layer, rows=1)
# 2. Reproject to the layer's CRS — appending in the wrong CRS is silent corruption
if gdf.crs != target.crs:
gdf = gdf.to_crs(target.crs)
# 3. Align columns: same names, same order, drop extras, fill missing
geom_name = target.geometry.name
cols = [c for c in target.columns if c != geom_name]
gdf = gdf.reindex(columns=cols + [geom_name])
# 4. Match dtypes so an int column does not drift to float on append
for col in cols:
if col in gdf and gdf[col].dtype != target[col].dtype:
gdf[col] = gdf[col].astype(target[col].dtype)
gdf.to_file(gpkg_path, layer=layer, driver="GPKG", mode="a")
return len(gdf)
The mode="a" argument is what turns to_file from “create or overwrite” into “append”. Everything before it exists to guarantee the incoming frame is shaped like the layer it is joining.
Step-by-Step Walkthrough
1. Inspect the target layer
Read one row to learn the layer’s CRS, geometry column name, and dtypes without loading the whole table:
import geopandas as gpd
target = gpd.read_file("field.gpkg", layer="observations", rows=1)
print(target.crs) # EPSG:4326
print(target.geometry.name) # 'geometry' or 'geom'
print(target.dtypes)
2. Reproject the incoming frame
to_file writes coordinates verbatim, so a frame in EPSG:3857 appended to an EPSG:4326 layer produces geometry in the wrong place. Reproject explicitly:
if new_gdf.crs != target.crs:
new_gdf = new_gdf.to_crs(target.crs)
3. Align column names and order
The append matches columns by position under some engine/driver combinations, so reorder to the target and drop columns the layer does not have:
geom_name = target.geometry.name
cols = [c for c in target.columns if c != geom_name]
new_gdf = new_gdf.reindex(columns=cols + [geom_name])
4. Reconcile dtypes
An integer column containing a missing value becomes float64 in pandas, which GeoPackage stores as REAL — a schema drift from the original INTEGER. Cast back before appending, using a nullable integer type if you truly have missing values:
new_gdf["sensor_id"] = new_gdf["sensor_id"].astype("int64")
# or, if NULLs are legitimate:
new_gdf["sensor_id"] = new_gdf["sensor_id"].astype("Int64") # pandas nullable int
5. Append
new_gdf.to_file("field.gpkg", layer="observations", driver="GPKG", mode="a")
6. Choose your engine deliberately
pyogrio is markedly faster than Fiona for bulk appends because it moves data in bulk rather than feature-by-feature. Select it explicitly when appending large frames:
new_gdf.to_file(
"field.gpkg", layer="observations", driver="GPKG",
mode="a", engine="pyogrio",
)
Verification
Confirm the row count grew by exactly the number you appended and that the CRS did not change:
import geopandas as gpd
after = gpd.read_file("field.gpkg", layer="observations")
print(len(after), after.crs)
A direct SQL count avoids loading geometry and is fast on large layers:
-- GeoPackage: row count and registered SRS for the layer
SELECT
(SELECT count(*) FROM observations) AS n_rows,
(SELECT srs_id FROM gpkg_contents WHERE table_name = 'observations') AS srs;
Alternative Approaches or Edge Cases
Replace instead of append. To swap a layer’s contents wholesale, use mode="w" — but note that on a GeoPackage this replaces only the named layer, leaving other layers intact, unlike a raw file truncation. Use mode="w" when the run is authoritative and mode="a" when it is incremental.
Deduplicating on append. GeoPackage does not enforce uniqueness on appended rows, so a re-run can create duplicates. Either add a UNIQUE constraint and catch the integrity error, or read existing keys and filter the incoming frame with new_gdf = new_gdf[~new_gdf["obs_id"].isin(existing_ids)] before writing. For change-tracked syncs, see tracking row changes for incremental GeoPackage sync.
Troubleshooting
Appended features land in the wrong location on the map
Cause: The incoming GeoDataFrame was in a different CRS than the target layer and to_file wrote the coordinates without reprojecting. Fix: Call gdf = gdf.to_crs(target.crs) before appending, and keep per-layer CRS consistent as covered in managing spatial reference systems in SQLite.
sqlite3.OperationalError: database is locked during the write
Cause: Another connection (QGIS, a second script, an open reader) holds a lock on the .gpkg. Fix: Close other readers, or wrap the write with a busy-timeout and retry as described in how to retry locked database writes in SQLite.
Integer column becomes REAL after appending
Cause: A missing value promoted the pandas column to float64, and the driver mapped that to REAL, diverging from the layer’s INTEGER type. Fix: Cast with astype("int64"), or astype("Int64") if NULLs are legitimate, before calling to_file.
Related
- GeoPandas & GeoPackage Integration — parent guide: reading, writing, and CRS handling with GeoDataFrames
- Converting Shapefiles to GeoPackage with GeoPandas — create a full layer from a Shapefile rather than appending
- How to Retry Locked Database Writes in SQLite — handle lock contention when appends run concurrently
- Managing Spatial Reference Systems in SQLite — keep the append CRS aligned with the target layer