Add Features to a Vector Layer in PyQGIS
Adding a feature looks like a one-liner, and in the simplest case it is. In practice the questions start immediately: should the script write through the data provider or the layer's edit buffer? Why did the primary key come back as NULL? Why does the form's default value not appear? Why does adding ten thousand features take a minute? Each of these has a clear answer once you see the two paths a new feature can take into a layer.
This recipe belongs to Features, Geometries & Memory Layers. It builds features correctly, chooses between the provider and the edit buffer, applies the layer's default values and constraints, recovers the ids the data source assigns, and keeps bulk inserts fast.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series.
- A layer to add to: a GeoPackage, a PostGIS table or a memory layer. Check
layer.dataProvider().capabilities()includesAddFeatures; read-only sources such as CSV files do not.
Build a feature that matches the layer
A QgsFeature is a geometry plus an ordered list of attribute values. The order and count must match the layer's fields exactly, which is why a feature should always be created from those fields rather than empty.
from qgis.core import QgsProject, QgsFeature, QgsGeometry, QgsPointXY
trees = QgsProject.instance().mapLayersByName("street_trees")[0]
print(trees.fields().names()) # ['fid', 'species', 'girth_cm', 'planted', 'notes']
f = QgsFeature(trees.fields())
f["species"] = "Tilia cordata"
f["girth_cm"] = 118
f["planted"] = "1987-04-12"
f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(571244.3, 5934019.8)))
print(f.attributes()) # [NULL, 'Tilia cordata', 118, '1987-04-12', NULL]
print(f.isValid(), f.hasGeometry())
Breakdown: QgsFeature(fields) initialises one NULL slot per field, so assigning by name works and any field you leave alone stays NULL rather than shifting the others along. Setting attributes by name is clearer than setAttributes([...]) with a positional list, and it survives a field being added to the layer later. The fid primary key is left NULL deliberately: GeoPackage and PostGIS assign it on insert. A date can be passed as an ISO string; the provider converts it to the field type, though passing a QDate avoids any ambiguity.
Write through the data provider
For a script that loads data — an import, a nightly sync, the output of an analysis — writing straight through the provider is the simplest and fastest route. There is no edit session, nothing to commit and nothing to roll back.
provider = trees.dataProvider()
ok, added = provider.addFeatures([f])
if not ok:
raise RuntimeError(f"insert failed: {provider.lastError()}")
new_fid = added[0].id()
print("inserted feature id", new_fid)
trees.updateExtents()
trees.triggerRepaint()
Breakdown: addFeatures returns a pair: a success flag and the list of features as stored, with ids assigned by the data source. That second value is how you learn the new primary key — the f you passed in still has its old, invalid id. lastError() carries the provider's message, typically a constraint violation from the database. Because the layer itself did not perform the edit, call updateExtents() so the cached extent includes the new feature and triggerRepaint() so an open map shows it.
Use the edit buffer when edits must be undoable
Inside a plugin or a console session where a person is working, an addition should behave like an addition made with the digitizing tools: visible in the attribute table at once, undoable with Ctrl+Z, and written only when the user saves. That is the layer's edit buffer.
from qgis.core import edit
with edit(trees):
for x, y, species in [(571250.0, 5934025.0, "Acer platanoides"),
(571262.5, 5934031.2, "Quercus robur")]:
g = QgsFeature(trees.fields())
g["species"] = species
g.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(x, y)))
if not trees.addFeature(g):
raise RuntimeError("addFeature refused the feature")
print(trees.featureCount())
Breakdown: The edit() context manager calls startEditing(), then commitChanges() when the block ends, or rollBack() if an exception escapes — so a failure halfway through leaves the data source untouched. Inside the block, new features have temporary negative ids until commit. If you want the user to review and save instead, call trees.startEditing() and trees.addFeature(g) without committing; the layer stays in edit mode with the additions in its undo stack. Undoing edits with the edit buffer covers commit errors and the undo stack in detail.
Apply default values and constraints
Layers can define default values — an expression such as now() or @user_account_name, or a database sequence — and constraints such as "not null" or "unique". The digitizing tools apply them automatically; a script must ask for them.
from qgis.core import QgsVectorLayerUtils, QgsExpressionContextUtils
context = trees.createExpressionContext()
attrs = {trees.fields().indexOf("species"): "Betula pendula",
trees.fields().indexOf("girth_cm"): 64}
geom = QgsGeometry.fromPointXY(QgsPointXY(571270.0, 5934040.0))
new = QgsVectorLayerUtils.createFeature(trees, geom, attrs, context)
print(new.attributes()) # defaults filled in, e.g. planted = today's date
for i in range(trees.fields().count()):
ok, errors = QgsVectorLayerUtils.validateAttribute(trees, new, i)
if not ok:
print(trees.fields()[i].name(), errors)
with edit(trees):
trees.addFeature(new)
Breakdown: QgsVectorLayerUtils.createFeature builds a feature the same way the attribute form does: it evaluates default value expressions, takes provider defaults such as sequences, and then overlays the attributes you pass by field index. validateAttribute checks each field against its constraints, returning the messages a form would show. Doing this in a script that feeds a carefully configured layer keeps scripted edits consistent with hand-made ones — see setting default values and field constraints for how those rules are defined.
Keep bulk inserts fast
Adding features one at a time is the main cause of slow scripts. Every provider call is a round trip — a transaction on GeoPackage, a network request to PostGIS — and every edit-buffer addition emits signals the attribute table listens to.
BATCH = 5000
provider = trees.dataProvider()
batch, total = [], 0
for rec in read_source_records(): # your generator of dicts
feat = QgsFeature(trees.fields())
feat["species"] = rec["species"]
feat.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(rec["x"], rec["y"])))
batch.append(feat)
if len(batch) == BATCH:
provider.addFeatures(batch)
total += len(batch)
batch.clear()
if batch:
provider.addFeatures(batch)
total += len(batch)
trees.updateExtents()
print(total, "features inserted")
Breakdown: Batching bounds memory use — only five thousand features exist at once — while cutting provider calls by the same factor. On GeoPackage each addFeatures call is a single SQLite transaction, which is where most of the speed-up comes from. Use the provider rather than the edit buffer for bulk loads: the buffer keeps an undo entry for every feature, which grows large and offers nothing for an automated import. The feature-iteration performance guide covers the read side of the same problem.
Copy features from a layer with a different schema
A frequent variant is appending features from one layer into another whose fields do not match: an inspection export with extra columns going into the master table, or a survey layer whose field order differs from the target. Adding the source features directly would misalign every attribute. QgsVectorLayerUtils.makeFeaturesCompatible remaps them by field name and converts geometry where it can.
from qgis.core import QgsVectorLayerUtils, QgsFeatureRequest
survey = QgsProject.instance().mapLayersByName("tree_survey_2026")[0]
print("survey:", survey.fields().names())
print("target:", trees.fields().names())
source = list(survey.getFeatures(QgsFeatureRequest().setFilterExpression('"status" = \'new\'')))
compatible = QgsVectorLayerUtils.makeFeaturesCompatible(source, trees)
with edit(trees):
ok = trees.addFeatures(compatible)
print(ok, len(compatible), "survey records appended")
Breakdown: makeFeaturesCompatible builds a new feature for each source feature using the target's fields: attributes with matching names are copied across, fields the target lacks are dropped, and target fields with no source column are left NULL so that defaults or later updates can fill them. It also converts geometry between single and multipart and drops or adds Z and M to match the target layer, which removes the most common reason an append is rejected. Values are still converted by the provider on insert, so a text column holding "12" lands in an integer field as 12, but a text column holding "twelve" becomes NULL — check counts of NULLs after a first run against an unfamiliar source.
When the target is a database table and both layers already sit in the same database, an INSERT … SELECT statement is faster still; appending features to a PostGIS table compares the two approaches.
QGIS version compatibility
addFeatures, the edit() context manager and QgsVectorLayerUtils.createFeature are stable from QGIS 3.0 to QGIS 4. On QGIS 4, provider capability flags are reached as Qgis.VectorProviderCapability.AddFeatures; on 3.x, QgsVectorDataProvider.AddFeatures. The QgsFeatureSink.FastInsert flag, which skips per-feature checks on some providers, can be passed as a second argument to addFeatures from 3.4 onwards.
Troubleshooting
addFeaturesreturns True but nothing appears. The extent was not updated, the layer has a subset filter that excludes the new features, or the map was not repainted.- Attributes are shifted by one. The feature was built with
setAttributesand a list that omitted thefidcolumn. - "Could not add feature" on PostGIS. A NOT NULL column without a default was left NULL; read
provider.lastError(). - Edits are lost. The layer was left in edit mode and QGIS closed without saving; use
edit()or callcommitChanges().
Conclusion
Create features from the layer's fields, write through the provider for scripts and through the edit buffer when a person should be able to undo, read new ids from the returned features, use QgsVectorLayerUtils.createFeature to honour defaults and constraints, and add in batches of a few thousand.
Frequently Asked Questions
Can I add a feature without geometry? Yes, to any layer. The feature appears in the attribute table but not on the map; on a geometryless table it is the only option.
Why does my new feature have a negative id?
It is still in the edit buffer. Ids become permanent after commitChanges().
How do I copy features between layers with different fields?
Use QgsVectorLayerUtils.makeFeaturesCompatible, or map attributes by name into new features built from the target's fields.
Does addFeatures reproject geometry?
No. Transform the geometry to the layer's CRS first.