How to Append Layers to an Existing GeoPackage with Fiona

Open the existing container with fiona.listlayers(path) to see what is already there, then call fiona.open(path, "a", driver="GPKG", layer="newlayer",…

Open the existing container with fiona.listlayers(path) to see what is already there, then call fiona.open(path, "a", driver="GPKG", layer="new_layer", schema=..., crs=...) — the append mode adds a new named layer (or appends features to an existing one) while leaving every other layer in the file untouched.

This page belongs to the Fiona & OGR Driver Configuration guide, which covers explicit driver binding and schema control. Where the batch conversion walkthrough builds a fresh file from a directory of Shapefiles, this page assumes the .gpkg already exists and you need to grow it safely.

Why This Matters

A GeoPackage is a multi-layer container: a single .gpkg file can hold dozens of feature tables, each with its own geometry type and coordinate reference system. Field pipelines rarely produce all of those layers in one pass. A survey app might ship a base map today and append a new inspection layer next week; an ETL job might add a roads layer on Monday and a parcels layer on Tuesday. Getting append semantics right is what keeps those incremental writes from silently destroying data.

The critical trap is write mode. Opening a GeoPackage with mode "w" and a layer name does not “add” that layer — if the file already exists, "w" truncates the entire container before writing. Mode "a" is the only safe choice for an existing file. Knowing when "a" creates a brand-new layer versus when it appends rows to an existing one is the difference between a clean incremental load and a corrupted deliverable.

Two independent facts decide what a write does: the mode you pass, and whether the layer name is already registered in the container. All four combinations are legal calls, and only one of them is destructive:

What Fiona write modes do to an existing GeoPackageA two-by-two matrix. The columns are whether the layer name is new or already present; the rows are write mode w and write mode a. Mode w truncates the whole container in both columns unless the OVERWRITE layer option is set. Mode a creates the layer when the name is new, honouring the schema and CRS you pass, and appends rows when the name already exists, ignoring the schema and CRS in favour of the stored definition.layer name is newlayer name existsmode "w"destructivetruncates the fileevery other layer is losttruncates the fileunless OVERWRITE=YESmode "a"additivecreates the layeryour schema + crs are usedappends rowsstored schema wins
The bottom-right cell is the one that surprises people: your schema and crs arguments are silently ignored when the layer already exists.

Prerequisites

  • Python 3.9+
  • fiona 1.9+ with GDAL/OGR 3.4+ (pip install fiona[all] or conda install -c conda-forge fiona gdal)
  • GDAL built with the GPKG driver enabled (the default for conda-forge builds)
  • An existing .gpkg file you have write access to
  • Familiarity with Fiona schema dicts (a {"geometry": ..., "properties": {...}} mapping)

Primary Method

python
# Fiona 1.9+ / GDAL 3.4+ — append a new layer to an existing GeoPackage
import fiona


def append_layer(
    gpkg_path: str,
    layer_name: str,
    schema: dict,
    crs: str,
    features: list,
) -> str:
    """
    Add a new named layer to an existing GeoPackage without clobbering
    the layers already stored in the file.

    Raises ValueError if the layer name is already present, so an
    accidental re-run cannot silently merge into the wrong table.
    """
    existing = fiona.listlayers(gpkg_path)
    if layer_name in existing:
        raise ValueError(
            f"Layer {layer_name!r} already exists in {gpkg_path}; "
            f"existing layers: {existing}"
        )

    with fiona.open(
        gpkg_path,
        mode="a",            # append to the container — never "w" on an existing file
        driver="GPKG",
        layer=layer_name,    # a name not yet present creates a fresh layer
        schema=schema,
        crs=crs,
    ) as dst:
        dst.writerecords(features)

    return layer_name

The key detail: in mode "a", passing a layer name that does not yet exist creates that layer inside the container; passing a name that does exist appends features to it (and your schema/crs arguments are ignored in favour of the layer’s stored definition). The explicit listlayers guard above turns a silent append into a loud error, which is what you want during development.

Step-by-Step Walkthrough

1. List the layers already in the file

Always inspect before you write. fiona.listlayers reads the GeoPackage gpkg_contents metadata table and returns the current layer names.

python
import fiona

existing = fiona.listlayers("field.gpkg")
print(existing)  # e.g. ['basemap', 'survey_points']

What that call actually reads is worth knowing, because it explains why a table created by raw sqlite3 never shows up in the list. A GeoPackage keeps a registry of its own contents: gpkg_contents names every layer and its declared spatial reference, and gpkg_geometry_columns records which column holds geometry for each of them. A feature table that exists as a SQLite table but has no row in those registries is invisible to every standards-compliant reader, Fiona included.

Inside a multi-layer GeoPackage containerOne SQLite file holds two registry tables and three feature tables. The gpkg_contents registry lists table_name, data_type and srs_id for every layer; gpkg_geometry_columns records the geometry column and type. Alongside them sit the basemap, survey_points and inspections feature tables. Outside the file, fiona.listlayers reads the registry, and fiona.open in append mode writes both a new feature table and its registry rows.field.gpkg — one SQLite filegpkg_contentstable_namedata_type · srs_idthe layer registrygpkg_geometry_columnscolumn_namegeometry_type_nameone row per layerbasemapPolygon · EPSG:4326survey_pointsPoint · EPSG:4326inspectionsappended todaylistlayers()reads the registry onlyopen(…, "a")writes table + registry
Fiona's append path writes the feature table and its registry rows; a hand-rolled CREATE TABLE writes only the former.

2. Read the schema and CRS of a reference layer

If your new layer must be consistent with an existing one, pull its definition rather than hand-writing a schema:

python
with fiona.open("field.gpkg", layer="survey_points") as ref:
    ref_schema = dict(ref.schema)
    ref_crs = ref.crs
    print(ref_schema)   # {'geometry': 'Point', 'properties': {...}}
    print(ref_crs)      # CRS.from_epsg(4326)

3. Define the schema for the new layer

Each layer carries its own schema, so a new inspections layer can have completely different columns and geometry type from survey_points:

python
inspection_schema = {
    "geometry": "Polygon",
    "properties": {
        "inspection_id": "int",
        "inspector": "str:64",
        "passed": "bool",
        "recorded_at": "datetime",
    },
}

4. Open in append mode and create the layer

Because inspections is not yet in the container, mode "a" creates it:

python
sample = [
    {
        "geometry": {
            "type": "Polygon",
            "coordinates": [[(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)]],
        },
        "properties": {
            "inspection_id": 1,
            "inspector": "A. Okafor",
            "passed": True,
            "recorded_at": "2026-07-11T09:30:00",
        },
    }
]

with fiona.open(
    "field.gpkg", "a", driver="GPKG",
    layer="inspections", schema=inspection_schema, crs="EPSG:4326",
) as dst:
    dst.writerecords(sample)

5. Append more features to that same layer later

Re-opening the file in mode "a" with an existing layer name appends rows instead of creating a table. Note that you omit schema/crs — the layer already defines them:

python
more = [
    {
        "geometry": {
            "type": "Polygon",
            "coordinates": [[(2, 2), (2, 3), (3, 3), (3, 2), (2, 2)]],
        },
        "properties": {
            "inspection_id": 2,
            "inspector": "R. Devi",
            "passed": False,
            "recorded_at": "2026-07-11T14:05:00",
        },
    }
]

with fiona.open("field.gpkg", "a", driver="GPKG", layer="inspections") as dst:
    dst.writerecords(more)

Seen over the life of a deliverable, those two calls are the same operation with different arguments. The container starts with whatever the first build produced and grows in place: a new name adds a layer, a repeated name adds rows, and nothing already in the file is rewritten either way.

A GeoPackage growing across three incremental loadsThree snapshots of the same container. In week one it holds only a basemap layer. In week two, an append with a new layer name adds an inspections layer beside it. In week three, an append with the same layer name adds forty-two more rows to inspections without creating a new table.mode "a" · new namemode "a" · same nameweek 1basemap1 layerinitial buildweek 2basemapinspections2 layersweek 3basemapinspections +42still 2 layers
The file grows monotonically: neither append rewrites the layers already present.

Because the container only ever grows, size management becomes a separate concern. Repeated appends leave free pages behind whenever rows are later deleted, and the GeoPackage keeps that space reserved. Run VACUUM on the container after a bulk delete — never during an append — and rebuild the R-tree index afterwards so spatial queries do not degrade over successive loads.

6. Match the CRS when appending to an existing layer

Appending features whose coordinates are in a different projection than the target layer writes silently wrong geometry — Fiona does not reproject on write. If your incoming data uses another CRS, transform the geometries first with fiona.transform.transform_geom so they match the layer’s stored coordinate reference system before calling write.

Verification

Confirm the new layer landed and no existing layer lost features:

python
import fiona

for layer in fiona.listlayers("field.gpkg"):
    with fiona.open("field.gpkg", layer=layer) as lyr:
        print(f"{layer}: {len(lyr)} features, CRS={lyr.crs.to_epsg()}")

A quick SQL cross-check against the container’s contents registry confirms the driver registered the layer with a geometry column:

sql
-- GeoPackage: every feature layer is registered here
SELECT table_name, data_type, srs_id
FROM gpkg_contents
WHERE data_type = 'features';

Alternative Approaches or Edge Cases

Overwriting a single layer. GDAL 3.4+ accepts a layer creation option to replace just one layer while keeping the rest of the container. Open with mode="w" plus the OVERWRITE="YES" layer option so only the named layer is dropped and recreated:

python
with fiona.open(
    "field.gpkg", "w", driver="GPKG", layer="inspections",
    schema=inspection_schema, crs="EPSG:4326",
    layer_options={"OVERWRITE": "YES"},
) as dst:
    dst.writerecords(sample)

Without OVERWRITE="YES", plain "w" on an existing file path truncates the whole GeoPackage — use this option deliberately.

Schema drift on append. If your incoming records carry a property absent from the target layer’s schema, that property is dropped silently. Compare set(feature["properties"]) against the layer schema keys before a bulk append and fail loudly on mismatch.

Troubleshooting

fiona.errors.DriverError: Failed to create GeoPackage ... : sqlite3_open failed

Cause: The parent directory does not exist, or the file is open (and locked) by another process such as QGIS. Fix: Ensure the directory exists and close any GIS application holding the file. For concurrent writers, see how to retry locked database writes in SQLite.

ValueError: Record's geometry type does not match collection schema

Cause: You are appending a MultiPolygon to a layer declared as Polygon (or vice versa). Fix: Create the layer with the Multi* variant so it accepts both single and multipart geometries, matching the promotion strategy used in the batch conversion walkthrough.

New layer silently missing after the run

Cause: The file was opened with mode "w" instead of "a", truncating the container, or the process crashed before the with block closed and flushed the transaction. Fix: Use "a" for existing files and always let the with block exit normally so the layer commit is flushed to disk.