Read and Write Layer Metadata in PyQGIS
Metadata is the part of a dataset that answers the questions the attribute table cannot: where did this come from, when, under what licence, and who do I ask when it looks wrong. QGIS has a full metadata model built on the ISO 19115 lineage, and almost nobody fills it in — largely because doing it by hand through a dialog for forty layers is unbearable. From Python it is a loop.
This recipe belongs to Working with QGIS Projects in PyQGIS. It covers reading and writing a layer's metadata object, the fields that matter for discovery, saving it as a sidecar or into the data source, validating before publishing, and where project metadata differs from layer metadata.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
- A layer to describe. GeoPackage is assumed for the in-container example; shapefiles support only the sidecar route.
- Write access next to the data if you are producing sidecars.
Read what is there
Every layer has a metadata object, empty or not.
from qgis.core import QgsProject
layer = QgsProject.instance().mapLayersByName("parcels")[0]
metadata = layer.metadata()
print("title: ", metadata.title() or "(empty)")
print("abstract:", (metadata.abstract() or "(empty)")[:80])
print("licences:", metadata.licenses())
print("keywords:", metadata.keywords())
print("contacts:", [c.name for c in metadata.contacts()])
Breakdown: metadata() returns a copy in most versions, so mutating it does nothing until it is set back — a detail that accounts for a great deal of confusion, because the code looks like it worked. keywords() returns a dictionary keyed by vocabulary rather than a flat list, which matters when writing them. Every getter returns an empty value rather than None for unset fields, so the or fallbacks above are for readability rather than safety.
Write the fields that matter
Not every ISO field earns its keep. Six do most of the work for discovery and reuse.
from qgis.core import QgsLayerMetadata, QgsAbstractMetadataBase
from qgis.PyQt.QtCore import QDate, QDateTime, QTime
metadata = layer.metadata()
metadata.setTitle("Cadastral parcels, Northshire, 2026")
metadata.setAbstract(
"Parcel boundaries digitised from the 2026 survey, snapped to the "
"national grid. Areas recomputed after edits; see lineage for the "
"processing chain. Not a legal record of ownership."
)
metadata.setLanguage("eng")
metadata.setLicenses(["OGL-UK-3.0"])
metadata.setKeywords({"gemet": ["cadastre", "land use"], "local": ["northshire"]})
contact = QgsAbstractMetadataBase.Contact()
contact.name = "Northshire GIS team"
contact.email = "gis@example.org"
contact.role = "pointOfContact"
metadata.setContacts([contact])
metadata.setDateTime(
QgsLayerMetadata.Published, QDateTime(QDate(2026, 8, 1), QTime(0, 0))
)
layer.setMetadata(metadata)
Breakdown: setMetadata() at the end is the line people omit, and without it every preceding statement modified a copy that is then discarded. Keywords take a dictionary of vocabulary to terms — using a recognised vocabulary such as GEMET makes the terms machine-comparable, while a local vocabulary is fine for anything organisation-specific. setLicenses() takes a list because a dataset can carry more than one, and using an SPDX identifier rather than prose means a catalogue can act on it. The abstract is where the honest caveat belongs: "not a legal record" is worth more to a future user than three more keywords.
The Contact type is on QgsAbstractMetadataBase rather than on QgsLayerMetadata, which is a small API wrinkle worth knowing before hunting for it in the wrong class.
Record lineage
Lineage is the field that turns metadata from a label into documentation.
metadata.setHistory([
"2026-07-14 imported from survey_2026.gdb (EPSG:27700)",
"2026-07-15 geometries validated with native:fixgeometries, 12 repaired",
"2026-07-16 area_ha recomputed from geometry after repair",
"2026-08-01 published",
])
layer.setMetadata(metadata)
Breakdown: A dated line per processing step is enough — this is not a version control system, it is a note to whoever opens the file in three years. The most valuable entries are the ones that explain a discrepancy: twelve repaired geometries is exactly the sort of thing that makes a later area total differ from an earlier report, and one line here saves an afternoon of archaeology. Generating these lines from the script that did the work, rather than typing them, is what makes the practice survive; the processing feedback object already knows what happened.
Save it somewhere it will survive
Setting metadata on a layer object keeps it only in memory and, if the project is saved, in the project. Neither travels with the data.
# a sidecar next to the file
message, ok = layer.saveDefaultMetadata()
if not ok:
raise RuntimeError(f"metadata not saved: {message}")
# or explicitly, to a chosen path
layer.saveNamedMetadata("/data/parcels.qmd")
Breakdown: saveDefaultMetadata() writes to wherever the provider considers default — a .qmd beside a file-based layer, or the gpkg_metadata table inside a GeoPackage, which is the better outcome and happens automatically. It returns the same (message, ok) pair as the style functions and needs the same check. Loading is the mirror: loadDefaultMetadata() runs automatically when a layer is added, so a sidecar written today is picked up by everyone who opens the file tomorrow with no action on their part.
Describing forty layers at once
The reason to do any of this from Python is that a project has thirty or forty layers and a dialog does not scale. Splitting the shared fields from the per-layer ones makes the loop short.
from qgis.core import QgsProject, QgsAbstractMetadataBase
SHARED = {
"language": "eng",
"licenses": ["OGL-UK-3.0"],
"contact_name": "Northshire GIS team",
"contact_email": "gis@example.org",
}
PER_LAYER = {
"parcels": ("Cadastral parcels, 2026", "Digitised from the 2026 survey."),
"buildings": ("Building footprints, 2026", "Derived from lidar and verified on site."),
"roads": ("Adopted highways", "Maintained network only; private roads excluded."),
}
def describe_all(project=None):
project = project or QgsProject.instance()
contact = QgsAbstractMetadataBase.Contact()
contact.name = SHARED["contact_name"]
contact.email = SHARED["contact_email"]
contact.role = "pointOfContact"
missing = []
for layer in project.mapLayers().values():
entry = PER_LAYER.get(layer.name())
if entry is None:
missing.append(layer.name())
continue
title, abstract = entry
metadata = layer.metadata()
metadata.setTitle(title)
metadata.setAbstract(abstract)
metadata.setLanguage(SHARED["language"])
metadata.setLicenses(SHARED["licenses"])
metadata.setContacts([contact])
layer.setMetadata(metadata)
layer.saveDefaultMetadata()
return missing
Breakdown: Returning the undescribed layer names rather than silently skipping them is what stops the table drifting out of step with the project — a layer added last month appears in the return value and gets noticed. Building the contact once outside the loop is not merely tidier: each Contact is a value object, and constructing forty identical ones is pure waste. Keeping PER_LAYER as a literal in a module that lives in version control means a change to an abstract is a reviewable diff rather than an untracked click in a dialog.
Running this as part of a publishing script, immediately before the data is copied out, is the arrangement that keeps metadata accurate — it is regenerated from the source of truth every time rather than maintained by hand and gradually diverging.
Validate before publishing
An incomplete record is worse than an obviously absent one, because it looks like somebody checked.
from qgis.core import QgsNativeMetadataValidator
validator = QgsNativeMetadataValidator()
ok, results = validator.validate(layer.metadata())
if not ok:
for result in results:
print(f"{result.section}: {result.note}")
Breakdown: QgsNativeMetadataValidator checks QGIS's own required set — identifier, title, language, type, CRS, extent, contacts and licences. Each failure names the section and explains what is missing, which makes it usable as a pre-publication gate in a script. There is also QgsLayerMetadataValidator subclasses for stricter profiles; running the native one and treating its output as a checklist is enough for most internal publishing.
Project metadata is a different object
QgsProject.instance().metadata() exists too, and describes the project rather than any layer. It shares most of the same fields through the common base class, so the code above transfers with the constructor swapped. What it does not have is a CRS or an extent, since a project has no single one — and what it does not do is propagate to the layers, so a project described carefully still ships layers with nothing on them. Both are worth filling in, and only the layer-level record survives the data being shared on its own.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.16 LTR | 3.7 | QgsLayerMetadata and .qmd sidecars present. |
| 3.22 LTR | 3.9 | GeoPackage gpkg_metadata writing supported by the OGR provider. |
| 3.28 LTR | 3.9 | QgsNativeMetadataValidator stable. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40+ | 3.12 | Layer metadata search and a metadata catalogue panel added. |
Troubleshooting
- Changes disappear.
layer.setMetadata(metadata)was never called —metadata()returned a copy. - The sidecar is not picked up. It must sit next to the data with the matching base name and a
.qmdextension. - Keywords are rejected.
setKeywords()takes a dictionary of vocabulary to list, not a flat list. Contactcannot be imported. It lives onQgsAbstractMetadataBase, not onQgsLayerMetadata.- Metadata vanished after a rewrite. Writing a new file with
QgsVectorFileWriterdoes not carry metadata across. Re-apply and re-save it. - A shapefile will not store it internally. It cannot; only the sidecar route works. Convert to GeoPackage.
Conclusion
Fill in title, abstract, contacts, licence and lineage — the five fields that answer real questions — set the object back onto the layer, and save it with saveDefaultMetadata() so it travels with the data. Validate before publishing, and generate the lineage lines from the script that did the processing rather than typing them afterwards.
Frequently Asked Questions
Can I export to ISO 19115 XML?
QGIS writes its own .qmd format, which maps onto the ISO model but is not ISO XML. Catalogue software such as GeoNetwork ingests .qmd via converters, or you can write ISO XML yourself from the same fields.
Does metadata affect how the layer draws? No. It is purely descriptive and separate from styles, which are stored differently and loaded independently.
Can I set metadata on a raster?
Yes — the API is identical, since metadata() and setMetadata() are on QgsMapLayer. It is particularly valuable on derived rasters, where the processing parameters belong in the lineage.
How do I do this for every layer in a project?
Iterate QgsProject.instance().mapLayers().values(), apply the shared fields and any per-layer ones from a lookup table, then saveDefaultMetadata() on each. That loop is the whole reason to do this from Python.