Split a Vector Layer by Attribute in PyQGIS

Splitting one layer into many by the value of a field is the standard preparation step before a batch: one file per district for a set of deliverables, one per survey date for a time series, one per operator for a data-sharing agreement. The built-in algorithm does it in a line; writing the loop yourself takes ten and buys you control over the filenames, which is usually what the downstream process actually needs.

This recipe belongs to Vector Data Manipulation in PyQGIS. It covers the algorithm and its filename behaviour, a hand-written split that names files predictably, splitting into one GeoPackage with many layers, and the traps around null values and characters that are illegal in paths.

Two shapes the split can takeOne input layer with a district attribute is grouped by that field's distinct values. The result can be written as one file per value in a directory, which suits handing folders to other people, or as one layer per value inside a single GeoPackage, which keeps the set together and shares one spatial reference definition.Same grouping, two very different deliverablesparcels.gpkg48 200 featuresdistrict12 distinct valuesone file per valuenorth.gpkgcentral.gpkg… 10 moreone layer per value, one filedistricts.gpkglayers: north · central · south · …a value with a slash in it breaks the first; the second does not care

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A layer with a field of manageable cardinality. Splitting on a field with fifty thousand distinct values produces fifty thousand files and a very unhappy filesystem.
  • An empty output directory. The algorithm does not clear one, so a re-run leaves stale files from the previous split alongside the new ones.

The algorithm

One call, and the outputs land in a directory.

import processing

processing.run("native:splitvectorlayer", {
    "INPUT": "/data/parcels.gpkg",
    "FIELD": "district",
    "PREFIX_FIELD": True,
    "FILE_TYPE": 0,                     # 0 GeoPackage, 1 GML, 2 GeoJSON, 3 SHP…
    "OUTPUT": "/data/output/by_district/",
})

Breakdown: PREFIX_FIELD: True prefixes each filename with the field name, giving district_north.gpkg; with False you get north.gpkg. FILE_TYPE selects the driver by index, which is brittle to read — GeoPackage is the sensible default because it has no field-name length limit and no encoding ambiguity. The output must be a directory path, and the algorithm creates it if missing.

What the algorithm will not do is control the filename beyond that prefix. A district called North / East produces a filename containing a slash, which fails on every platform, and a value with a leading space produces a file nobody can type. Where the field values are not already clean identifiers, write the loop yourself.

A hand-written split with predictable names

Ten lines, and every filename is one you chose.

import re
from pathlib import Path
from qgis.core import (
    QgsProject, QgsVectorLayer, QgsVectorFileWriter,
    QgsCoordinateTransformContext,
)

layer = QgsProject.instance().mapLayersByName("parcels")[0]
field = "district"
out_dir = Path("/data/output/by_district")
out_dir.mkdir(parents=True, exist_ok=True)

values = sorted({f[field] for f in layer.getFeatures() if f[field] is not None})

for value in values:
    slug = re.sub(r"[^a-z0-9]+", "_", str(value).lower()).strip("_") or "unnamed"
    layer.selectByExpression(f'"{field}" = \'{value}\'')

    options = QgsVectorFileWriter.SaveVectorOptions()
    options.driverName = "GPKG"
    options.onlySelectedFeatures = True
    options.fileEncoding = "UTF-8"

    QgsVectorFileWriter.writeAsVectorFormatV3(
        layer, str(out_dir / f"{slug}.gpkg"),
        QgsCoordinateTransformContext(), options,
    )

layer.removeSelection()

Breakdown: Building the slug with a regular expression handles slashes, spaces, accents and empty strings in one line, and the or "unnamed" fallback covers a value that reduces to nothing. selectByExpression plus onlySelectedFeatures is the simplest filter that works for any provider; for a large layer, setting a subset string or using a QgsFeatureRequest avoids materialising a selection. writeAsVectorFormatV3 is the current writer — writeAsVectorFormat and V2 are deprecated and produce warnings that clutter a log. Clearing the selection at the end is a courtesy that avoids leaving the user's map in a strange state.

The string interpolation into the expression is a real hazard: a value containing an apostrophe breaks it. For untrusted values, build the expression with QgsExpression.quotedValue(value) instead of an f-string.

Everything in one GeoPackage

Where the set belongs together, many layers in one file is tidier than many files, and immune to filename problems.

first = True
for value in values:
    layer.selectByExpression(
        f'"{field}" = {QgsExpression.quotedValue(value)}'
    )
    options = QgsVectorFileWriter.SaveVectorOptions()
    options.driverName = "GPKG"
    options.layerName = str(value)[:60]
    options.onlySelectedFeatures = True
    options.actionOnExistingFile = (
        QgsVectorFileWriter.CreateOrOverwriteFile if first
        else QgsVectorFileWriter.CreateOrOverwriteLayer
    )
    QgsVectorFileWriter.writeAsVectorFormatV3(
        layer, "/data/output/districts.gpkg",
        QgsCoordinateTransformContext(), options,
    )
    first = False

Breakdown: actionOnExistingFile is the whole trick. The first write creates the file with CreateOrOverwriteFile; every later write must use CreateOrOverwriteLayer, which adds a layer to the existing container. Getting this backwards means each write replaces the file and you end up with only the last district. layerName accepts spaces and most punctuation because it is a table name inside SQLite rather than a path, which is why this route sidesteps the slug problem entirely.

Create the file once, add layers thereafterThe first call uses CreateOrOverwriteFile and produces a GeoPackage containing one layer. Each subsequent call uses CreateOrOverwriteLayer and appends another layer to the same file. Using CreateOrOverwriteFile every time replaces the file each round, leaving only the final layer.One flag decides whether you keep twelve layers or onecorrectOverwriteFilecreates, 1 layerOverwriteLayeradds, 2 layersOverwriteLayer ×1012 layerswrongOverwriteFilecreates, 1 layerOverwriteFilereplaces, 1 layerOverwriteFile ×101 layer, the last one

Verifying the split

A split is one of the few operations where a complete correctness check is cheap, so there is no excuse for skipping it. Every input feature should appear in exactly one output, and the geometries should be untouched.

from pathlib import Path
from qgis.core import QgsVectorLayer

total = layer.featureCount()
written = 0
for path in sorted(Path("/data/output/by_district").glob("*.gpkg")):
    part = QgsVectorLayer(str(path), path.stem, "ogr")
    if not part.isValid():
        raise RuntimeError(f"{path.name} did not open")
    written += part.featureCount()

if written != total:
    raise RuntimeError(f"split lost features: {total} in, {written} out")
print(f"{total} features across {len(values)} outputs — accounted for")

Breakdown: Opening every output also confirms each file is readable, which catches a truncated write from a full disk that the writer reported as success. Comparing counts catches nulls dropped silently, features filtered out by a mistyped expression, and the duplicate-write case where a feature landed in two outputs. Raising rather than printing is right here: a partial split that looks complete is worse than no split, because the missing rows are discovered by whoever receives the data.

For a stricter check, compare the sum of feature ids rather than the count — two errors that cancel out in a count will not cancel in a sum. That is rarely necessary, but it costs one more expression and is worth it when the outputs are going somewhere you cannot easily correct.

Choosing between the two shapes

Directory-of-files wins when each group goes to a different person or system: a folder per district handed to a district office, one shapefile per operator uploaded to separate portals. The files are independent, individually zippable, and need no explanation.

One GeoPackage wins for everything else. It keeps the set together so nothing gets separated in transit, stores one copy of the spatial reference definition, holds styles in its own table, and accepts layer names that would be illegal as filenames. It is also dramatically kinder to a filesystem when the split produces hundreds of groups — a directory of eight hundred shapefiles is four thousand files, and the merge back is correspondingly slower.

Nulls, blanks and the group you forgot

A field with nulls produces a group the split usually drops silently, and those features then exist in no output at all.

missing = layer.materialize(
    QgsFeatureRequest().setFilterExpression(f'"{field}" IS NULL OR trim("{field}") = \'\'')
)
print(f"{missing.featureCount()} feature(s) have no {field} value")

Breakdown: Counting before splitting turns a silent loss into a decision: write them to an unassigned output, fix them upstream, or accept the loss knowingly. materialize() builds an in-memory layer from a request without writing anything, which is the cheapest way to look at a subset. Testing for a trimmed empty string as well as null catches the very common case of a field that was populated with spaces rather than left null.

A related check is the total: the sum of the output feature counts should equal the input count. Writing that assertion into the script costs one line and catches every category of silent loss at once.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.16 LTR3.7native:splitvectorlayer present; writeAsVectorFormatV2 is current.
3.203.9writeAsVectorFormatV3 introduced; V2 deprecated.
3.28 LTR3.9PREFIX_FIELD parameter added to the split algorithm.
3.34 LTR3.12Baseline for this page.
3.40+3.12Split algorithm gains an option to skip empty groups explicitly.

Troubleshooting

  • Only one output file exists. CreateOrOverwriteFile was used on every iteration. Switch to CreateOrOverwriteLayer after the first.
  • A filename contains a slash. The field value did. Slugify, or split into one GeoPackage instead.
  • Some features are in no output. Their field value is null or blank. Count them explicitly and decide.
  • The expression fails on some values. A value contains an apostrophe. Use QgsExpression.quotedValue().
  • Old files remain from a previous run. The algorithm does not clear the directory. Empty it first.
  • Field names are truncated in the outputs. The driver is Shapefile with its ten-character limit. Use GeoPackage.

Conclusion

Use native:splitvectorlayer when the field values are already clean identifiers and a directory of files is the deliverable. Write the loop yourself when filenames matter, quote values properly, count the nulls before you lose them, and prefer one GeoPackage with many layers whenever the set belongs together.

Frequently Asked Questions

How do I split by two fields? Create a virtual or real field concatenating them, then split on that. Building the combined key explicitly also gives you control over the separator, which matters for the filenames.

Can I split a layer into equal-sized chunks instead? Not with this algorithm. Add a field computed as floor((@row_number - 1) / 5000) with the field calculator and split on that.

Will the split preserve styling? No — each output is a fresh layer with default symbology. Apply a QML style to each after writing, or save the style into the GeoPackage's style table.

Is splitting the right way to speed up a big layer? Usually not. A spatial index and a subset string give most of the benefit without fragmenting the data. Split when the deliverable is per-group, not to make queries faster.