Write a Vector Layer to GeoPackage in PyQGIS
Every analysis eventually has to land somewhere. GeoPackage is the right default: one file, many layers, real field types, no ten-character column limit, no encoding guesswork, and a spatial index that makes it fast to read back. The only thing that catches people out is the option that decides whether you are adding a layer to the file or replacing the entire file — and getting that wrong in a scheduled job deletes work.
This recipe belongs to PostGIS and Database Workflows in PyQGIS. It covers the modern writer API, appending layers safely, renaming and dropping fields on the way out, and saving the layer's style inside the container so the next person opens a finished map rather than grey polygons.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
writeAsVectorFormatV3exists from 3.20 onward. - A valid source layer — a Processing output, a memory layer, or anything else that answers
isValid(). - Write permission on the target directory, and enough space for the output.
Write a layer to a new GeoPackage
from qgis.core import (
QgsVectorFileWriter,
QgsCoordinateTransformContext,
QgsProject,
)
options = QgsVectorFileWriter.SaveVectorOptions()
options.driverName = "GPKG"
options.layerName = "flood_zones"
options.fileEncoding = "UTF-8"
error, message, new_file, new_layer = QgsVectorFileWriter.writeAsVectorFormatV3(
layer,
"/data/outputs/analysis.gpkg",
QgsProject.instance().transformContext(),
options,
)
if error != QgsVectorFileWriter.NoError:
raise RuntimeError(f"write failed ({error}): {message}")
print("wrote", new_layer or options.layerName, "to", new_file or "analysis.gpkg")
Breakdown: writeAsVectorFormatV3 returns a four-tuple; the first element is an error code, and it is the only reliable indicator of success — the function does not raise on failure. Passing the project's transform context rather than a bare QgsCoordinateTransformContext() matters when the output CRS differs from the source, because that context carries the datum-transform choices configured for the project. layerName is the name the layer will carry inside the container, independent of the file name, which is what makes multi-layer containers possible.
Add a second layer without destroying the first
This is the option that deserves a moment of attention. actionOnExistingFile decides what happens when the file already exists.
options = QgsVectorFileWriter.SaveVectorOptions()
options.driverName = "GPKG"
options.layerName = "gauges"
options.actionOnExistingFile = QgsVectorFileWriter.CreateOrOverwriteLayer
Breakdown: CreateOrOverwriteLayer replaces just the named layer and leaves every other layer in the container untouched — this is what a nightly job that refreshes one dataset should use. CreateOrOverwriteFile (the default) deletes the whole GeoPackage and starts again, taking every other layer and every saved style with it. AppendToLayerNoNewFields adds rows to an existing layer and refuses new columns; AppendToLayerAddFields adds rows and widens the schema where needed.
Shape the output on the way out
The options object also decides which fields, which features and which CRS the output carries — doing this in the writer avoids creating an intermediate layer just to drop three columns.
from qgis.core import QgsCoordinateReferenceSystem
options.attributes = [
layer.fields().indexOf("ward_name"),
layer.fields().indexOf("incidents"),
]
options.ct = None
options.destCRS = QgsCoordinateReferenceSystem("EPSG:4326")
options.onlySelectedFeatures = False
options.layerOptions = ["FID=fid", "SPATIAL_INDEX=YES"]
Breakdown: attributes is a list of field indexes to keep — everything else is omitted, which is the cheapest way to publish a subset. Setting destCRS reprojects during the write using the transform context passed to the writer, so no separate reprojection step is needed; see Transform Point Coordinates in PyQGIS for what that transformation involves. layerOptions passes GDAL driver options straight through: naming the FID column keeps identifiers stable across rewrites, and the spatial index is what makes the result fast to read back.
Save the style with the data
A GeoPackage can carry symbology, so the recipient opens a styled map rather than grey polygons.
layer_uri = "/data/outputs/analysis.gpkg|layername=flood_zones"
written = QgsVectorLayer(layer_uri, "Flood zones", "ogr")
written.loadNamedStyle("/data/styles/flood.qml")
written.saveStyleToDatabase(
name="flood_default",
description="Depth bands, published 2026-08",
useAsDefault=True,
uiFileContent="",
)
Breakdown: saveStyleToDatabase() writes into the container's layer_styles table, which QGIS creates on first use. useAsDefault=True means the style is applied automatically whenever the layer is opened — the difference between a deliverable and a data dump. The same mechanism works against PostGIS, which is how a team shares one canonical symbology; the file-based alternative is covered in Save and Load a QML Style in PyQGIS.
Let Processing write into the container directly
The writer is the explicit route, but most outputs come out of a Processing chain — and an algorithm can target a layer inside a GeoPackage without a separate write step.
import processing
container = "/data/outputs/analysis.gpkg"
processing.run("native:buffer", {
"INPUT": "/data/roads.gpkg|layername=roads",
"DISTANCE": 25,
"DISSOLVE": True,
"OUTPUT": f"ogr:dbname='{container}' table=\"road_buffers\" (geom)",
})
Breakdown: The ogr: output syntax names the container, the layer inside it and the geometry column, so the algorithm writes straight into the GeoPackage — no temporary file, no second pass. Each algorithm run adds or replaces its own layer and leaves the others alone, which is the same guarantee CreateOrOverwriteLayer gives the writer. Quoting matters: the container path is in single quotes and the table name in double quotes, because the string is parsed by GDAL rather than by Python.
For a chain that produces several outputs, this turns a pipeline into a single self-describing deliverable — inputs, intermediates worth keeping and final results all in one file, each named for what it is:
outputs = {
"flood_zones": flood_result,
"affected_parcels": parcels_result,
"gauges": gauges_result,
}
for name, layer in outputs.items():
options.layerName = name
options.actionOnExistingFile = QgsVectorFileWriter.CreateOrOverwriteLayer
QgsVectorFileWriter.writeAsVectorFormatV3(
layer, container, QgsProject.instance().transformContext(), options
)
Breakdown: Reusing one options object across the loop is safe because each iteration sets the layer name before writing. The dictionary makes the container's contents obvious at a glance, and gives the recipient a file whose layer names document the analysis — considerably more useful than five files named output_1 through output_5 in a folder.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.16 LTR | 3.7 | Only writeAsVectorFormatV2, which takes the same options object but returns a three-tuple. |
| 3.28 LTR | 3.9 | writeAsVectorFormatV3 available and recommended; identical options. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | V2 is deprecated and warns; V3 unchanged. |
Where a script must run on both old and new releases, check for the attribute — hasattr(QgsVectorFileWriter, "writeAsVectorFormatV3") — and fall back rather than pinning the whole project to the older call.
Troubleshooting
- The file exists but the earlier layers are gone.
actionOnExistingFilewas left at its default. UseCreateOrOverwriteLayer. - "failed to create layer" on a network drive. SQLite needs real file locking, which SMB and NFS mounts often fake. Write locally and copy the finished file.
- Field names changed. GeoPackage allows almost any name, but a source shapefile may already have truncated them. Rename before writing with the field-management patterns in Add a Field to a Layer in PyQGIS.
- The output is much larger than expected. GeoPackage does not reclaim space automatically after overwrites. Run
VACUUMthrough the ogr connection, or write to a fresh file. - Date fields arrive as text. The source field type was string. Cast during the write by building the target fields explicitly, or fix the type at the source.
- Nothing was written and the error code is 3. That is
ErrCreateDataSource— nearly always a path that does not exist. Create the directory first; the writer will not.
Conclusion
Writing to GeoPackage is one call plus one options object, and the option that matters most is actionOnExistingFile — CreateOrOverwriteLayer for anything that shares a container. Trim fields and reproject inside the writer rather than in an extra step, always test the returned error code, and save the style into the container so the file arrives ready to read.
Frequently Asked Questions
Should I still use shapefiles for anything? Only when a downstream system demands one. GeoPackage removes the field-name limit, the encoding ambiguity, the 2 GB ceiling and the sidecar-file fragility in one move.
Can I write several layers in one transaction?
Each writeAsVectorFormatV3 call is its own transaction. To make a multi-layer publish atomic, write to a temporary file and rename it into place once every layer has been written.
How do I append rows every night without duplicating them?
Append with AppendToLayerNoNewFields and give the source a stable identifier column, or delete the day's rows first through the ogr connection's executeSql(). The transactional version of this is covered in Append Features to a PostGIS Table in PyQGIS.
Does writing preserve the layer's filter or selection?
A subset string is honoured — the writer sees the filtered feature source. A selection is only used when onlySelectedFeatures is set to True.
Why is the first read of a large GeoPackage slow?
Usually a missing spatial index. Pass SPATIAL_INDEX=YES in layerOptions, or create it afterwards through the ogr connection.