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.

Three places metadata can liveMetadata held only in the project travels with the project and is lost if the data is shared alone. A qmd sidecar sits next to the data file and is picked up automatically when the layer is loaded. A GeoPackage can store the metadata inside the container itself, so a single file carries both the data and its description.Only two of these survive the data being emailed onin the projectsurvey.qgzset in the properties dialogtravels with the projectlost if the data aloneis shareda .qmd sidecarparcels.gpkgparcels.qmdread automatically on loadplain XML, diffabletwo files to keeptogetherinside the GeoPackageparcels.gpkggpkg_metadata tableone file carries bothnothing to losethe best default

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.

Metadata fields as answers to questionsTitle and abstract answer what the dataset is. Contacts answer who to ask. Licence answers whether it can be used. Lineage answers what has been done to it. Keywords make it findable. Everything else in the ISO model is optional detail.Five fields answer the questions people actually askwhat is it?title · abstractincluding the caveatswho do I ask?contactsa team, not a personmay I use it?licenses · rightsan SPDX id, not prosewhat happened to it?history — the lineageone dated line per processing stephow do I find it?keywords, by vocabularya standard vocabulary beats a clever one

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 versionPythonNotes
3.16 LTR3.7QgsLayerMetadata and .qmd sidecars present.
3.22 LTR3.9GeoPackage gpkg_metadata writing supported by the OGR provider.
3.28 LTR3.9QgsNativeMetadataValidator stable.
3.34 LTR3.12Baseline for this page.
3.40+3.12Layer 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 .qmd extension.
  • Keywords are rejected. setKeywords() takes a dictionary of vocabulary to list, not a flat list.
  • Contact cannot be imported. It lives on QgsAbstractMetadataBase, not on QgsLayerMetadata.
  • Metadata vanished after a rewrite. Writing a new file with QgsVectorFileWriter does 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.