How to store metadata inside a GeoPackage
Problem statement
A GeoPackage is a SQLite database, and the OGC standard defines two tables for holding a dataset's own metadata: gpkg_metadata and gpkg_metadata_reference. Almost nobody uses them, which is why so many GeoPackages arrive as a layer with no title, no licence and no idea what a row represents.
The tables are easy to write through GDAL and easy to read with nothing but sqlite3, which means the record travels inside the file and can be recovered without any spatial stack at all. This guide writes a record, reads it back both ways, and covers the trap: GDAL stores its items in a GDAL-specific XML wrapper, so a reader that expects ISO 19139 will not find what it is looking for.
Quick answer
from osgeo import gdal
gdal.UseExceptions()
ds = gdal.OpenEx("flood_zones.gpkg", gdal.OF_UPDATE | gdal.OF_VECTOR)
ds.SetMetadataItem("TITLE", "Coastal flood risk zones, East Sussex")
ds.SetMetadataItem("LICENSE", "OGL-UK-3.0")
ds.SetMetadataItem("ABSTRACT", "Modelled 1-in-200-year extent at 2 m resolution.")
ds.SetMetadataItem("SOURCE", "EA LiDAR Composite DTM 2024")
ds = None # closing is what flushes it
print(gdal.OpenEx("flood_zones.gpkg").GetMetadata())
{'ABSTRACT': 'Modelled 1-in-200-year extent at 2 m resolution.',
'LICENSE': 'OGL-UK-3.0', 'SOURCE': 'EA LiDAR Composite DTM 2024',
'TITLE': 'Coastal flood risk zones, East Sussex'}
Writing four items creates the two standard tables and puts one row in each.
Step-by-step solution
1. Open for update and close to flush
gdal.OpenEx(path, gdal.OF_UPDATE | gdal.OF_VECTOR) gets a writable handle. Setting ds = None is what closes and flushes it โ leaving the object alive means the changes may not be on disk when you read them back in the same process.
2. Write dataset-level items
SetMetadataItem(key, value) on the dataset handle writes items that describe the GeoPackage as a whole: title, abstract, licence, attribution, version, content date, contact.
3. Write layer-level items where they belong
A GeoPackage can hold several layers with different sources and dates. Set those on the layer:
layer = ds.GetLayerByName("flood_zones")
layer.SetMetadataItem("FEATURE_DEFINITION", "One polygon per modelled inundation area")
layer.SetMetadataItem("CONTENT_DATE", "2025-11-01")
4. Know what GDAL actually wrote
The payload in gpkg_metadata is GDAL's own XML:
md_scope dataset
md_standard_uri http://gdal.org
mime_type text/xml
metadata <GDALMultiDomainMetadata><Metadata><MDI key="ABSTRACT">โฆ
That is valid per the GeoPackage standard โ the standard defines the table and lets the md_standard_uri column declare the payload's format โ but it is not ISO 19139, and a catalogue harvester expecting ISO will find nothing it understands.
5. Write an ISO record too, if a catalogue needs one
Insert a second row with md_standard_uri set to the ISO namespace and the XML as the payload. Readers then find whichever they know how to parse.
6. Read it back without GDAL
Three lines of sqlite3 recover the record in a container with no GDAL bindings, which matters more often than it should.
7. Check the tables survived your writer
to_file(..., driver="GPKG") on an existing path adds or replaces a layer; it does not necessarily preserve metadata rows written earlier. Write the metadata after the data, as the last step before publishing.
Code examples
Example 1 โ write the whole record
from osgeo import gdal
import json, pathlib
gdal.UseExceptions()
def write_gpkg_metadata(path, dataset_items, layer_items=None):
ds = gdal.OpenEx(str(path), gdal.OF_UPDATE | gdal.OF_VECTOR)
for k, v in dataset_items.items():
ds.SetMetadataItem(k, str(v))
for layer_name, items in (layer_items or {}).items():
layer = ds.GetLayerByName(layer_name)
if layer is None:
raise KeyError(f"no layer {layer_name!r} in {path}")
for k, v in items.items():
layer.SetMetadataItem(k, str(v))
ds = None
return path
write_gpkg_metadata(
"flood_zones.gpkg",
dataset_items={
"TITLE": "Coastal flood risk zones, East Sussex",
"ABSTRACT": "Modelled 1-in-200-year extent at 2 m resolution.",
"LICENSE": "OGL-UK-3.0",
"ATTRIBUTION": "Contains Environment Agency data ยฉ Crown copyright 2025",
"VERSION": "2026.1",
"CONTENT_DATE": "2025-11-01",
"CONTACT": "[email protected]",
"LINEAGE": "EA LiDAR DTM 2024 โ pysheds D8 โ 200-year threshold โ polygonise",
},
layer_items={"flood_zones": {
"FEATURE_DEFINITION": "One polygon per contiguous modelled inundation area",
"MIN_MAPPED_AREA_M2": "100",
}},
)
Example 2 โ read it back with sqlite3 only
import sqlite3, xml.etree.ElementTree as ET
def gpkg_metadata(path):
con = sqlite3.connect(path)
try:
names = {r[0] for r in con.execute("select name from sqlite_master where type='table'")}
if "gpkg_metadata" not in names:
return []
rows = con.execute("""
select m.id, m.md_scope, m.md_standard_uri, m.mime_type, m.metadata,
r.reference_scope, r.table_name
from gpkg_metadata m
left join gpkg_metadata_reference r on r.md_file_id = m.id
""").fetchall()
finally:
con.close()
out = []
for _id, scope, uri, mime, payload, ref_scope, table in rows:
rec = {"scope": scope, "standard": uri, "reference": ref_scope, "table": table}
if payload.lstrip().startswith("<GDALMultiDomainMetadata"):
rec["items"] = {e.get("key"): e.text for e in ET.fromstring(payload).iter("MDI")}
else:
rec["payload"] = payload
out.append(rec)
return out
for rec in gpkg_metadata("flood_zones.gpkg"):
print(rec["scope"], rec["standard"], list(rec.get("items", {})))
Example 3 โ add an ISO 19139 record alongside
import sqlite3, datetime
ISO_URI = "http://www.isotc211.org/2005/gmd"
def add_iso_record(path, xml_text, table_name=None):
con = sqlite3.connect(path)
try:
con.execute("""
insert into gpkg_metadata (md_scope, md_standard_uri, mime_type, metadata)
values (?, ?, 'text/xml', ?)""",
("dataset" if table_name is None else "table", ISO_URI, xml_text))
md_id = con.execute("select last_insert_rowid()").fetchone()[0]
con.execute("""
insert into gpkg_metadata_reference
(reference_scope, table_name, timestamp, md_file_id)
values (?, ?, ?, ?)""",
("geopackage" if table_name is None else "table", table_name,
datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), md_id))
con.commit()
finally:
con.close()
Two records in the same file, each declaring its own standard, is exactly what the table was designed for.
Explanation
Why this is better than a sidecar
The record is inside the file, so it travels with every copy. It is also queryable: a script can open a hundred GeoPackages and report which ones have no licence, without unzipping anything or looking for a matching .json.
Why the GDAL wrapper is not a problem, until it is
md_standard_uri = http://gdal.org is a legitimate use of the column โ the standard is deliberately agnostic about the payload. The problem is only that a harvester configured for ISO will skip the row. If nobody is harvesting, the GDAL form is simpler to write and to read; if a catalogue is involved, add the ISO record too.
Why metadata must be written last
GeoDataFrame.to_file(path, driver="GPKG", layer=โฆ) operates on an existing container. Depending on the write mode and the GDAL version, a layer rewrite can replace rows that a previous metadata write inserted. Making the metadata write the final step before publication removes the question entirely.
Why layer scope matters in multi-layer packages
A delivery GeoPackage often holds several layers from different sources with different dates and licences. Dataset-level metadata cannot express that, and a single record describing "the file" is how a licence gets attached to the wrong layer.
Edge cases or notes
- Closing is flushing.
ds = None, or use a context manager wrapper. - The tables are created on demand. They do not exist until you write metadata.
gpkg_metadata_referencecarries the scope.geopackage,table,columnorrow.- Some readers ignore the tables entirely. QGIS surfaces little of it; that does not make it useless.
- Keep keys uppercase and stable. They are what your checks grep for.
- A GeoPackage is a database, so VACUUM after big edits. Metadata writes are tiny, but bulk edits are not.
- Do not store large blobs. The tables are for records, not for payloads.
- Byte checksums of GeoPackages change on every write. Use a content hash instead.
Internal links
- How to read the metadata already inside your spatial files โ reading this and every other slot
- How to write a metadata record for a dataset in Python โ assembling what goes in
- How to read and write GeoPackages in Python โ the container itself
- How to embed provenance tags in a GeoTIFF โ the raster equivalent
- Metadata standards compared: ISO 19115, STAC and Frictionless โ which payload to put in the table
- Metadata disappears when you convert the file โ what happens on the way out
- Dataset versioning explained โ the VERSION item
- How to checksum spatial datasets so you can prove they match โ why not to hash the container
FAQ
Can a GeoPackage store its own metadata?
Yes. The OGC standard defines gpkg_metadata and gpkg_metadata_reference tables, and GDAL writes into them through SetMetadataItem.
How do I write GeoPackage metadata in Python?
Open the file with gdal.OpenEx(path, gdal.OF_UPDATE | gdal.OF_VECTOR), call SetMetadataItem for each key, then set the dataset handle to None to flush.
Can I read it without GDAL?
Yes. The tables are ordinary SQLite, so sqlite3 plus an XML parse recovers everything GDAL wrote.
What format does GDAL store the record in?
Its own GDALMultiDomainMetadata XML, declared with md_standard_uri = http://gdal.org. Valid per the standard, but not ISO 19139.
Should I write an ISO record as well?
Only if a catalogue harvests the file. Insert it as a second row with the ISO standard URI; the two coexist happily.
Why did my metadata disappear?
Most likely a later layer write replaced it. Write metadata as the last step before publishing, and verify it afterwards.