Features, Geometries & Memory Layers in PyQGIS
Every PyQGIS task, from a one-line console experiment to a plugin with a dozen dialogs, eventually comes down to the same three objects: a QgsFeature holding attribute values, the QgsFields that describe those values, and a QgsGeometry holding a shape. Layers, providers, Processing and the map canvas are all machinery for moving those three objects around. Knowing them well — what they contain, how they are created, how they fail — removes a whole class of bugs that otherwise show up later as wrong numbers, missing features or crashes.
This guide belongs to PyQGIS Fundamentals & Environment Setup. It is aimed at anyone who has run a few scripts and wants to understand the data model properly: analysts automating their own work, developers starting on plugins, and people moving from GeoPandas or ArcPy who need to know where the familiar concepts live in QGIS. It explains the model, then links to focused recipes for each task.
What this guide covers
The recipes below follow the order in which most scripts touch the data model:
- Make a layer to hold results — create a memory layer with a complete URI, a cloned schema or a filtered snapshot.
- Make geometry from outside data — create geometry from WKT and GeoJSON, WKB and coordinate lists, with checks that catch bad input at the source row.
- Put features into layers — add features to a vector layer through the provider or the edit buffer, honouring defaults and constraints.
- Get features out — read feature attributes and geometry efficiently, in order, and without iterator pitfalls.
- Deal with empty values — handle NULL values and QVariant on QGIS 3 and QGIS 4.
- Change data safely — undo edits with the edit buffer, group changes into undo steps and handle failed commits.
- Reshape geometry to fit — convert geometry types between single and multi, polygons and lines, curves and segments, 2D and 3D.
Fields: the schema every feature follows
A layer's fields are its columns: each QgsField has a name, a type, and optionally a length, precision, alias and comment. layer.fields() returns them as a QgsFields collection, and every feature's attribute list follows that collection's order exactly. This is the single most important rule of the data model: attributes are positional, and the positions are defined by the layer.
from qgis.core import QgsProject
layer = QgsProject.instance().mapLayersByName("hydrants")[0]
for i, field in enumerate(layer.fields()):
origin = layer.fields().fieldOrigin(i)
print(f"{i:>2} {field.name():<16} {field.typeName():<10} "
f"len={field.length():<4} origin={origin}")
Breakdown: typeName() is the provider's own type name (Integer64, varchar, Real), while type() is the Qt type QGIS uses internally. The field origin distinguishes columns stored in the data source from joined fields and virtual fields computed by expression; joined and expression fields appear in fields() but cannot be written through the provider. Primary key columns such as a GeoPackage fid appear as ordinary fields and are usually filled by the data source.
Because order matters, creating a feature from the layer's fields — QgsFeature(layer.fields()) — is the habit that prevents most attribute bugs. It produces a feature with the right number of NULL slots, and lets you assign values by name. Adding or removing fields changes the positions of every later column, which is why code that caches field indexes must refresh them after any schema change; adding fields to a layer shows the updateFields() step that keeps the layer and its provider in sync.
Geometry: shapes without a coordinate system
A QgsGeometry is a shape and nothing more. It knows its type — Point, LineString, Polygon, their multipart and curved variants, each with optional Z and M — and its coordinates. It does not know what those coordinates mean. The CRS belongs to the layer, and moving a geometry between layers in different systems requires an explicit coordinate transform.
QgsGeometry is a wrapper around an abstract geometry object (QgsAbstractGeometry subclasses such as QgsPoint, QgsLineString, QgsPolygon). Most everyday work happens on the wrapper — area(), buffer(), intersects(), asWkt() — while fine-grained work such as adding a vertex or dropping a Z value goes through constGet() for reading and get() for modifying the underlying object. The wrapper is implicitly shared: copying a QgsGeometry is cheap, and the data is duplicated only when one copy is modified.
from qgis.core import QgsGeometry, QgsWkbTypes
g = QgsGeometry.fromWkt("MultiPolygon Z (((0 0 5, 10 0 5, 10 10 6, 0 10 6, 0 0 5)))")
t = g.wkbType()
print(QgsWkbTypes.displayString(t)) # MultiPolygonZ
print(QgsWkbTypes.geometryType(t), QgsWkbTypes.isMultiType(t), QgsWkbTypes.hasZ(t))
print(g.area(), g.isGeosValid(), g.constGet().partCount())
Breakdown: QgsWkbTypes answers questions about a type value without needing a geometry — useful for checking a layer's declared type before writing. isGeosValid() checks the OGC validity rules that GEOS-based operations rely on; an invalid polygon can still be drawn, but buffers, intersections and areas computed from it may be wrong. Creating geometry from WKT and GeoJSON shows where to put those checks, and converting geometry types covers moving between types when a target layer demands it.
Memory layers: a scratch surface with the full layer API
A memory layer is a QgsVectorLayer whose provider keeps features in RAM. It needs no file, no path and no cleanup, and it supports everything a file-backed layer does: styling, labelling, editing, expressions, spatial indexing and Processing. That makes it the default container for anything a script creates — intermediate results, records fetched from an API, features generated from calculations.
from qgis.core import QgsVectorLayer, QgsProject
results = QgsVectorLayer(
"Polygon?crs=EPSG:25832&field=zone:string(20)&field=score:double&index=yes",
"suitability (scratch)", "memory")
QgsProject.instance().addMapLayer(results)
print(results.isValid(), results.fields().names())
The URI declares the geometry type, CRS and fields in one string, so the layer is complete the moment it exists. The two things to remember are that memory layers vanish when QGIS closes — save anything worth keeping with native:savefeatures — and that Python can lose them earlier if nothing holds a reference. Creating a memory layer covers both, plus cloning a schema from an existing layer and materialising a filtered copy in one call. Processing uses memory layers for the same purpose; passing memory layers between algorithms shows the memory: output shortcut.
Reading features: requests, iterators and NULL
Features come out of a layer through getFeatures(), optionally with a QgsFeatureRequest describing which features and which columns are wanted. The request is passed down to the provider, which on a database becomes part of the SQL query — so a request that filters rows, trims columns and skips geometry can be dramatically faster than reading everything and filtering in Python.
Two things trip up nearly everyone at some point. The first is NULL: on QGIS 3 an empty attribute is a QVariant that prints as NULL, is falsy, and is not equal to None; on QGIS 4 it is None. Tests written as value is None silently treat every empty value as present on QGIS 3. Handling NULL values and QVariant gives a helper that works on both versions and covers dates, which arrive as QDate and QDateTime.
The second is modifying a layer while iterating it. The iterator holds an open cursor on the data source; deleting or editing features underneath it gives undefined results. Collect ids or changes during the loop and apply them afterwards. Reading feature attributes and geometry walks through attribute access by name and index, geometry accessors for each type, ordered and paged reads, and both pitfalls.
from qgis.core import QgsFeatureRequest
req = (QgsFeatureRequest()
.setFilterExpression('"pressure_bar" IS NULL OR "pressure_bar" < 2.5')
.setSubsetOfAttributes(["hydrant_id", "pressure_bar"], layer.fields())
.setFlags(QgsFeatureRequest.NoGeometry))
low = [f["hydrant_id"] for f in layer.getFeatures(req)]
print(len(low), "hydrants with low or unknown pressure")
Writing features: provider or edit buffer
Changes reach a data source by one of two routes, and choosing the right one is mostly a question of who is in charge.
The data provider route — layer.dataProvider().addFeatures(), changeAttributeValues(), deleteFeatures() — writes immediately with no undo and no edit session. It is right for scripts that build or update layers on their own: imports, scheduled jobs, analysis outputs. It is fast, especially when features are added in batches of a few thousand.
The edit buffer route — startEditing(), then addFeature(), changeAttributeValue() or deleteFeature(), then commitChanges() — holds changes in memory, records them on an undo stack, emits signals the attribute table and other plugins rely on, and writes everything in one go at commit. It is right whenever a person is working with the layer at the same time, which covers almost every plugin.
from qgis.core import edit
with edit(layer): # start, then commit or roll back
idx = layer.fields().indexOf("pressure_bar")
layer.beginEditCommand("Clear implausible pressures")
for f in layer.getFeatures('"pressure_bar" > 20'):
layer.changeAttributeValue(f.id(), idx, None)
layer.endEditCommand()
Adding features to a vector layer shows both routes side by side, including how to recover the primary key the database assigns and how QgsVectorLayerUtils.createFeature applies default values and constraints the way a form would. Undoing edits with the edit buffer goes deeper on the buffer: grouping a batch into one undo step, inspecting pending changes, reacting to edit signals, and what to do when commitChanges() returns False. Where several layers must change together, transaction groups put them into one database transaction.
How this fits with the rest of PyQGIS
The data model is the common ground under every other section of this site. Processing algorithms read features through the same requests and write them through the same providers, which is why batch processing scripts can mix algorithm calls with hand-written loops. Renderers and labels read attributes by field name, so the NULL rules apply to expressions in styling as much as to Python. Plugins that add map tools or forms are, underneath, code that creates features, edits attributes and changes geometry through the edit buffer. And when a project outgrows QGIS's own tools, the same features convert to GeoDataFrames and shapely objects, as covered in PyQGIS and the Python data stack.
Making geometry fit its destination
Data rarely arrives in exactly the shape a target layer expects. A dissolve returns multipolygons for a single-polygon table, a CAD export brings circular arcs into a format that cannot store them, a GPS survey carries elevation into a 2D database, and an analysis needs points where the source has buildings. Each mismatch either fails loudly — addFeatures returns False — or, worse, succeeds with silent loss: a provider dropping Z values, or convertToSingleType() keeping only the first part of a multipolygon.
The safe approach is to make conversions explicit and to do them at the boundary between steps, where you know both the incoming and the required type:
from qgis.core import QgsWkbTypes
def fit_to_layer(geom, layer):
target = layer.wkbType()
if QgsWkbTypes.isMultiType(target):
geom.convertToMultiType()
elif geom.isMultipart() and geom.constGet().partCount() > 1:
raise ValueError("multipart geometry for a single-part layer: split it first")
if geom.constGet().hasCurvedSegments() and not QgsWkbTypes.isCurvedType(target):
geom = QgsGeometry(geom.constGet().segmentize())
if QgsWkbTypes.hasZ(target) and not geom.constGet().is3D():
geom.get().addZValue(0.0)
elif not QgsWkbTypes.hasZ(target) and geom.constGet().is3D():
geom.get().dropZValue()
return geom
Breakdown: The helper refuses the one conversion that loses data — collapsing several parts into one — and performs the lossless or deliberate ones. Segmentizing uses the default tolerance, fine for most display purposes; pass an explicit tolerance when accuracy matters. Adding a constant Z of zero is a placeholder that should be documented, or replaced by draping onto a DEM. Converting geometry types covers each of these conversions in detail, including the family changes — polygon to boundary line, line to polygon, any shape to an interior point — and the Processing algorithms that apply them to whole layers.
Python loops or Processing?
Everything in this guide can be done feature by feature in Python, and much of it can also be done by a Processing algorithm on a whole layer. Neither is always better.
A useful rule: if an algorithm exists for the operation, use it, and write a loop only for the part that is genuinely yours. Reading features from an unfamiliar CSV, deciding row by row which ones to keep and logging why — that is a loop. Buffering, dissolving and reprojecting the result — those are algorithm calls. Mixing the two is natural because both read and write the same layers; memory layers make the hand-off free, and chaining Processing algorithms shows how to pass results from one step to the next without touching disk.
Common failure patterns
Most data-model bugs fall into a handful of recognisable shapes. Knowing them makes the symptoms quick to diagnose.
- The layer is valid but empty on the map. A memory layer was created without
crs=, or its extent was never updated after a provider write. Fix the URI and callupdateExtents(). - Every attribute is shifted by one column. A feature was built with
setAttributesand a list that skipped the primary key. Build features fromlayer.fields()and assign by name. - Comparisons raise
TypeErroron some rows only. Those rows hold NULL. Filter with a NULL-aware helper orIS NOT NULLin the request. - An append returns False with no message. Geometry type mismatch, most often single into multi or 3D into 2D. Convert first, then read
provider.lastError()for anything else. - Edits disappear after a restart. The layer was left in edit mode, or it was a memory layer that was never saved.
RuntimeError: wrapped C/C++ object has been deleted. The layer was removed from the project while Python still held a reference. Stop using it after removal, or keep a clone.
Each of these is covered in the recipe for the step where it occurs, with the check that catches it before it spreads.
Key takeaways
- A feature is an id, attribute values in the layer's field order, and one geometry; create features from the layer's fields so positions always line up.
- Geometry has a type and coordinates but no CRS; the layer supplies the CRS, and transforms must be explicit.
- Memory layers are full layers held in RAM — declare them completely in the URI and save anything worth keeping.
- Narrow every read with a
QgsFeatureRequest: rows, columns, geometry, order and limit are pushed down to the provider. - Empty values are
NULLon QGIS 3 andNoneon QGIS 4; test for them with a helper that accepts both. - Write through the provider for unattended scripts and through the edit buffer when people share the layer; group script edits into one undo step.
- Match geometry types before writing: promote to multi, take boundaries, segmentize curves and drop or add Z and M as the target requires.
Frequently Asked Questions
What is the difference between QgsPoint and QgsPointXY?QgsPointXY is a lightweight 2D coordinate pair used by the …XY convenience methods. QgsPoint is a full geometry type that can carry Z and M values. Use QgsPoint whenever elevation or measures matter.
Should I use memory layers or temporary GeoPackages for intermediate data? Memory layers for anything that fits comfortably in RAM and lives only for the script's run; a temporary GeoPackage for very large intermediates, or when another process needs to read them.
Why does layer.getFeatures() sometimes return features in a different order?
Without addOrderBy, order is whatever the provider returns, which for databases is not guaranteed. Always order explicitly when it matters.
Can I change a layer's geometry type after creation? No. Create a new layer of the required type and copy features into it, converting geometries on the way.
Are feature ids stable? On GeoPackage and PostGIS they are the primary key and stable. On shapefiles they are row numbers and can change when the file is rewritten. Use a business key field for anything that must persist.
Related
- PyQGIS Fundamentals & Environment Setup — the section this guide belongs to
- Working with QGIS Projects — the project that layers and features live in
- Working with QGIS Expressions — filters and computed values over feature attributes
- QGIS API Architecture — modules, object ownership and signals behind these classes
- Geometry Operations & Predicates — buffers, intersections and spatial tests on the geometries described here
- Create a Memory Layer in PyQGIS
- Create Geometry from WKT and GeoJSON in PyQGIS
- Add Features to a Vector Layer in PyQGIS
- Read Feature Attributes and Geometry in PyQGIS
- Handle NULL Values and QVariant in PyQGIS
- Undo Edits with the Edit Buffer in PyQGIS
- Convert Geometry Types in PyQGIS