How to read the metadata already inside your spatial files
On this page
Problem statement
Before writing a metadata record, find out what the file already tells you. Most spatial formats carry more than people realise โ a GeoPackage has a whole metadata table, a GeoTIFF has tags, a Parquet file has a footer describing its own geometry column โ and reading it is faster than asking the person who made it.
It also tells you what you are about to lose. A conversion that drops the record is invisible at the time and expensive later, and the only way to know is to look at both sides.
This guide reads every slot, format by format, with what each one actually held when the same dataset โ Rutland's 26 OpenStreetMap schools, with a four-item record written into it โ was saved in each format, plus a Copernicus DEM tile of the county as the raster.
Quick answer
import json
import pyarrow.parquet as pq
import rasterio
from osgeo import gdal
gdal.UseExceptions()
# Vector, any GDAL format: dataset- and layer-level metadata
ds = gdal.OpenEx("data/schools.gpkg")
print("dataset:", ds.GetMetadata())
print("layers:", [ds.GetLayer(i).GetName() for i in range(ds.GetLayerCount())])
# Raster
with rasterio.open("data/dem.tif") as src:
print("tags:", src.tags(), "| descriptions:", src.descriptions, "| units:", src.units)
# GeoParquet
md = pq.read_metadata("data/schools.parquet").metadata
print("footer keys:", [k.decode() for k in md])
geo = json.loads(md[b"geo"])
print("geo:", sorted(geo), "| per column:", sorted(geo["columns"][geo["primary_column"]]))
dataset: {'ABSTRACT': 'School grounds mapped in OpenStreetMap', 'LICENSE': 'ODbL-1.0', 'SOURCE': 'Geofabrik rutland-260101', 'TITLE': 'Rutland schools'}
layers: ['schools']
tags: {'ABSTRACT': 'School grounds mapped in OpenStreetMap', 'LICENSE': 'ODbL-1.0', 'SOURCE': 'Geofabrik rutland-260101', 'TITLE': 'Rutland schools', 'AREA_OR_POINT': 'Area'} | descriptions: ('elevation',) | units: ('metre',)
footer keys: ['ARROW:schema', 'geo', 'pandas']
geo: ['columns', 'creator', 'primary_column', 'version'] | per column: ['bbox', 'crs', 'encoding', 'geometry_types']
Step-by-step solution
1. GeoPackage โ a real metadata table
GDAL stores dataset metadata in the GeoPackage's own gpkg_metadata and gpkg_metadata_reference tables, as defined by the standard:
import sqlite3
con = sqlite3.connect("data/schools.gpkg")
for table in ("gpkg_metadata", "gpkg_metadata_reference"):
print(table, con.execute(f"select count(*) from {table}").fetchone()[0], "row(s)")
row = con.execute("select md_scope, md_standard_uri, mime_type, metadata from gpkg_metadata").fetchone()
con.close()
print(row[:3], row[3][:70].replace("\n", " ") + "...")
gpkg_metadata 1 row(s)
gpkg_metadata_reference 1 row(s)
('dataset', 'http://gdal.org', 'text/xml') <GDALMultiDomainMetadata> <Metadata> <MDI key="ABSTRACT">School ...
Four items written through GDAL became one row in each table, with the payload stored as XML under a GDAL-specific standard URI. You can read it through GDAL, or straight out of SQLite if you would rather not depend on the bindings.
2. GeoTIFF โ tags, per band and per dataset
with rasterio.open("data/dem.tif") as src:
print(len(src.tags()), "dataset tags:", sorted(src.tags()))
print("band 1 tags:", src.tags(1))
print("IMAGE_STRUCTURE:", src.tags(ns="IMAGE_STRUCTURE"))
5 dataset tags: ['ABSTRACT', 'AREA_OR_POINT', 'LICENSE', 'SOURCE', 'TITLE']
band 1 tags: {}
IMAGE_STRUCTURE: {'COMPRESSION': 'DEFLATE', 'INTERLEAVE': 'BAND'}
src.tags() returns the dataset tags; src.tags(1) the tags of band 1. Rasterio reports AREA_OR_POINT alongside yours โ it is GDAL's pixel-registration flag, not one of the four tags the setup wrote โ and the compression and interleaving live in a separate IMAGE_STRUCTURE domain.
3. GeoParquet โ the footer
Arrow stores arbitrary keyโvalue metadata in the Parquet footer. The geo key holds the GeoParquet block; pandas holds the DataFrame's index and dtypes. You can add your own key at write time and read it back without a spatial library:
import pyarrow as pa
table = pq.read_table("data/schools.parquet")
record = {"title": "Rutland schools", "licence": "ODbL-1.0"}
table = table.replace_schema_metadata({**table.schema.metadata, b"dataset": json.dumps(record).encode()})
pq.write_table(table, "out/schools_with_record.parquet")
print(json.loads(pq.read_metadata("out/schools_with_record.parquet").metadata[b"dataset"]))
print("GeoPandas still reads it:", len(gpd.read_parquet("out/schools_with_record.parquet")), "schools")
{'title': 'Rutland schools', 'licence': 'ODbL-1.0'}
GeoPandas still reads it: 26 schools
4. Shapefile โ almost nothing
print(sorted(p.name for p in Path("data").glob("schools.*") if p.suffix not in (".gpkg", ".parquet", ".geojson")))
print(".cpg says:", Path("data/schools.cpg").read_text(), "| .prj starts:", Path("data/schools.prj").read_text()[:32])
print("dataset metadata through GDAL:", gdal.OpenEx("data/schools.shp").GetMetadata())
['schools.cpg', 'schools.dbf', 'schools.prj', 'schools.shp', 'schools.shx']
.cpg says: UTF-8 | .prj starts: PROJCS["British_National_Grid",G
dataset metadata through GDAL: {}
The .prj holds the CRS as WKT, the .dbf holds field names truncated to ten characters, the .cpg holds the encoding. Anything else lives in a .shp.xml sidecar that GeoPandas did not write here and that many tools do not read; the four-item record did not survive into the shapefile at all.
5. GeoJSON โ nothing standard
doc = json.loads(Path("data/schools.geojson").read_text())
print("top-level members:", list(doc))
doc.update({"title": "Rutland schools", "license": "ODbL-1.0"})
Path("out/schools_members.geojson").write_text(json.dumps(doc))
print("GeoPandas reads it:", len(gpd.read_file("out/schools_members.geojson")), "features")
print("GDAL dataset metadata:", gdal.OpenEx("out/schools_members.geojson").GetMetadata())
gpd.read_file("out/schools_members.geojson").to_file("out/schools_rewritten.geojson")
print("after a rewrite:", list(json.loads(Path("out/schools_rewritten.geojson").read_text())))
top-level members: ['type', 'name', 'crs', 'features']
GeoPandas reads it: 26 features
GDAL dataset metadata: {}
after a rewrite: ['type', 'name', 'crs', 'features']
The format has no dataset-level metadata slot. You can add your own members โ GeoPandas still reads the file โ but GDAL does not report them as dataset metadata, and a tool that rewrites the file drops them, as the last line shows.
6. Compare the two sides of a conversion
The reason to read all of this is to know what a conversion costs. Run the reader before and after and diff the result โ example 3 does it for GeoPackage to shapefile.
7. Recover what you can from the data itself
Even with no record at all, the file tells you the CRS, the extent, the schema, the geometry types and the null pattern:
schools = gpd.read_file("data/schools.shp")
print("CRS:", schools.crs, "| extent:", schools.total_bounds.round(4))
print("schema:", schools.dtypes.astype(str).to_dict())
print("geometry types:", schools.geom_type.value_counts().to_dict(), "| null names:", int(schools["name"].isna().sum()))
CRS: EPSG:27700 | extent: [482612.2072 298591.8048 503751.3125 315628.3198]
schema: {'osm_id': 'str', 'name': 'str', 'geometry': 'geometry'}
geometry types: {'Polygon': 25, 'MultiPolygon': 1} | null names: 0
That is most of the derived half of a record, and it is often enough to make a dataset usable again.
Code examples
The data used on this page
Rutland's schools from Geofabrik's OpenStreetMap extract of 1 January 2026 (ยฉ OpenStreetMap contributors, ODbL), saved as GeoPackage, shapefile, GeoJSON and GeoParquet; and the Copernicus GLO-30 DEM over the county (ยฉ DLR e.V. 2010โ2014 and ยฉ Airbus Defence and Space GmbH 2014โ2018, provided under COPERNICUS by the European Union and ESA), saved as a GeoTIFF. The setup writes the same four-item record into the GeoPackage and the GeoTIFF.
from pathlib import Path
import geopandas as gpd
import rasterio
import requests
from osgeo import gdal
from rasterio.windows import from_bounds
gdal.UseExceptions()
for folder in ("data", "out"):
Path(folder).mkdir(exist_ok=True)
RECORD = {"TITLE": "Rutland schools", "ABSTRACT": "School grounds mapped in OpenStreetMap",
"LICENSE": "ODbL-1.0", "SOURCE": "Geofabrik rutland-260101"}
if not Path("data/rutland.shp.zip").exists():
Path("data/rutland.shp.zip").write_bytes(requests.get(
"https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
schools = gpd.read_file("/vsizip/data/rutland.shp.zip/gis_osm_pois_a_free_1.shp", where="fclass = 'school'")
schools = schools[["osm_id", "name", "geometry"]].to_crs(27700)
schools.to_file("data/schools.gpkg", layer="schools")
ds = gdal.OpenEx("data/schools.gpkg", gdal.OF_UPDATE)
ds.SetMetadata(RECORD)
ds = None
schools.to_file("data/schools.shp")
schools.to_file("data/schools.geojson")
schools.to_parquet("data/schools.parquet")
with rasterio.open("https://copernicus-dem-30m.s3.amazonaws.com/Copernicus_DSM_COG_10_N52_00_W001_00_DEM/"
"Copernicus_DSM_COG_10_N52_00_W001_00_DEM.tif") as src:
window = from_bounds(-0.84, 52.52, -0.42, 52.77, src.transform)
profile = src.profile | {"driver": "GTiff", "height": int(round(window.height)), "width": int(round(window.width)),
"transform": src.window_transform(window), "compress": "deflate"}
with rasterio.open("data/dem.tif", "w", **profile) as dst:
dst.write(src.read(window=window))
dst.update_tags(**RECORD)
dst.set_band_description(1, "elevation")
dst.set_band_unit(1, "metre")
print(len(schools), "schools;", sorted(p.name for p in Path("data").iterdir() if p.name.startswith(("schools", "dem"))))
26 schools; ['dem.tif', 'schools.cpg', 'schools.dbf', 'schools.geojson', 'schools.gpkg', 'schools.parquet', 'schools.prj', 'schools.shp', 'schools.shx']
Example 1: One function that reports every slot
import pathlib
def read_all_metadata(path):
path = pathlib.Path(path)
out = {"file": path.name, "bytes": path.stat().st_size}
if path.suffix in {".tif", ".tiff"}:
with rasterio.open(path) as src:
out["dataset_tags"] = src.tags()
out["band_tags"] = {i: src.tags(i) for i in range(1, src.count + 1)}
out["descriptions"] = list(src.descriptions)
out["units"] = list(src.units)
return out
if path.suffix == ".parquet":
md = pq.read_metadata(path).metadata or {}
out["footer_keys"] = [k.decode() for k in md]
return out
ds = gdal.OpenEx(str(path))
out["dataset_metadata"] = ds.GetMetadata()
out["layers"] = []
for i in range(ds.GetLayerCount()):
layer = ds.GetLayer(i)
defn = layer.GetLayerDefn()
out["layers"].append({
"name": layer.GetName(),
"features": layer.GetFeatureCount(),
"geometry": gdal.ogr.GeometryTypeToName(defn.GetGeomType()),
"fields": [defn.GetFieldDefn(j).GetName() for j in range(defn.GetFieldCount())],
})
return out
for name in ("schools.gpkg", "schools.shp", "schools.geojson", "schools.parquet", "dem.tif"):
report = read_all_metadata(Path("data") / name)
record = report.get("dataset_metadata", report.get("dataset_tags", report.get("footer_keys")))
print(f"{name:16} {len(record) if record is not None else 0:>2} dataset items "
f"{sorted(record) if isinstance(record, dict) else record}")
schools.gpkg 4 dataset items ['ABSTRACT', 'LICENSE', 'SOURCE', 'TITLE']
schools.shp 0 dataset items []
schools.geojson 0 dataset items []
schools.parquet 3 dataset items ['ARROW:schema', 'geo', 'pandas']
dem.tif 5 dataset items ['ABSTRACT', 'AREA_OR_POINT', 'LICENSE', 'SOURCE', 'TITLE']
One reader, five answers: the GeoPackage and the GeoTIFF kept the record, the shapefile and the GeoJSON have nowhere to put it, and the Parquet footer holds only what GeoPandas and Arrow wrote about themselves.
Example 2: Read GeoPackage metadata without GDAL bindings
import xml.etree.ElementTree as ET
def gpkg_metadata(path):
con = sqlite3.connect(path)
try:
tables = {r[0] for r in con.execute("select name from sqlite_master where type='table'")}
if "gpkg_metadata" not in tables:
return []
rows = con.execute("select md_scope, md_standard_uri, mime_type, metadata from gpkg_metadata").fetchall()
finally:
con.close()
out = []
for scope, uri, mime, payload in rows:
entry = {"scope": scope, "standard": uri, "mime": mime}
if mime == "text/xml" and payload.lstrip().startswith("<GDALMultiDomainMetadata"):
entry["items"] = {e.get("key"): e.text for e in ET.fromstring(payload).iter("MDI")}
else:
entry["raw"] = payload[:500]
out.append(entry)
return out
print(gpkg_metadata("data/schools.gpkg"))
[{'scope': 'dataset', 'standard': 'http://gdal.org', 'mime': 'text/xml', 'items': {'ABSTRACT': 'School grounds mapped in OpenStreetMap', 'LICENSE': 'ODbL-1.0', 'SOURCE': 'Geofabrik rutland-260101', 'TITLE': 'Rutland schools'}}]
Useful in a container without GDAL's Python bindings, and useful for confirming that what GDAL wrote is what the GeoPackage standard expects.
Example 3: Diff the metadata across a conversion
def conversion_report(src_path, dst_path):
a, b = read_all_metadata(src_path), read_all_metadata(dst_path)
lost = {k: v for k, v in (a.get("dataset_metadata") or {}).items()
if k not in (b.get("dataset_metadata") or {})}
ga, gb = gpd.read_file(src_path), gpd.read_file(dst_path)
return {
"dataset_metadata_lost": sorted(lost),
"columns": (list(ga.columns), list(gb.columns)),
"dtype_changes": {c: (str(ga[c].dtype), str(gb[c].dtype)) for c in ga.columns
if c in gb.columns and str(ga[c].dtype) != str(gb[c].dtype)},
"crs_same": ga.crs == gb.crs,
}
for key, value in conversion_report("data/schools.gpkg", "data/schools.shp").items():
print(f"{key:22} {value}")
dataset_metadata_lost ['ABSTRACT', 'LICENSE', 'SOURCE', 'TITLE']
columns (['osm_id', 'name', 'geometry'], ['osm_id', 'name', 'geometry'])
dtype_changes {}
crs_same True
Run it once on your own pipeline and you will find at least one surprise. Metadata disappears when you convert the file collects the common ones.
Explanation
Why GDAL is the right reader for the vector side
The GDAL abstraction gives you the same three levels โ dataset, layer, field โ for every driver, so one function covers GeoPackage, FileGDB, GML, shapefile and the rest. Reading each format with its own library means writing the same code five times and missing the slots you did not know about.
Why the GeoPackage record is XML inside a table
The GeoPackage standard defines a metadata table but not a metadata format: the md_standard_uri column says which standard the payload follows. GDAL writes its own GDALMultiDomainMetadata XML with a http://gdal.org URI. A record written by a different tool may be ISO 19139 instead โ read the URI before assuming.
Why the Parquet footer is the nicest of the lot
It is keyโvalue metadata attached to the file by the format itself, readable without any spatial library, and it survives copying and range reads. GeoParquet uses it for the CRS and geometry encoding; there is nothing stopping you adding a dataset key with your own record, which then travels inside the file โ step 3 did, and GeoPandas read the file as before.
Why GeoJSON is a problem worth planning around
It is the most common interchange format and the only common one with no metadata slot at all. Extra top-level members survive a read by GeoPandas but are dropped by anything that rewrites the file, and GDAL reports no dataset metadata for them. For GeoJSON deliveries, ship the sidecar and accept that it can be separated.
Edge cases or notes
AREA_OR_POINTis always there. GDAL reports it with the dataset tags; it is not one of yours.- Band descriptions are not tags.
src.descriptionsandsrc.tags(i)are different slots โ the setup set a description and a unit, and band 1's tags stayed empty. - Metadata domains exist.
src.tags(ns="IMAGE_STRUCTURE")holds compression and interleaving. - FileGDB has rich metadata and it is XML, per item.
- NetCDF attributes are metadata. Global and per-variable; read both.
- A
.shp.xmlmay exist. Check for it before assuming a shapefile has nothing. - Layer metadata is separate from dataset metadata in GDAL; read both.
- Reading is cheap. Do it in the pipeline, not only when something has gone wrong.
Internal links
- Metadata disappears when you convert the file โ the diff this makes possible
- How to store metadata inside a GeoPackage โ writing the slot you just read
- How to embed provenance tags in a GeoTIFF โ the raster slot
- How to write a metadata record for a dataset in Python โ assembling the full record
- GIS file formats compared โ the wider trade-offs between these formats
- GDAL and OGR explained โ the abstraction the reader uses
- GeoParquet and columnar storage explained โ what is in the footer
- How to explore an unfamiliar spatial dataset in Python โ the next step when there is no record
FAQ
How do I read metadata from a GeoPackage in Python?
Open it with GDAL and call GetMetadata(), or read the gpkg_metadata table directly with sqlite3. GDAL stores its items as GDALMultiDomainMetadata XML in that table.
Where does a GeoTIFF keep its metadata?
In TIFF tags, at dataset and band level. src.tags() and src.tags(band) in rasterio; band descriptions and units are separate properties.
Does GeoJSON have a metadata slot?
No. You can add top-level members and GeoPandas will still read the file, but GDAL reports no dataset metadata and any tool that rewrites the file will drop them.
What is in a GeoParquet footer?
Keyโvalue metadata including a geo block with the primary geometry column, its encoding, CRS, geometry types and bbox, plus Arrow and pandas schema blocks.
Can I read all of this without GDAL's Python bindings?
Mostly. GeoPackage metadata is readable with sqlite3, Parquet with pyarrow, GeoTIFF with rasterio. GDAL bindings help for the less common drivers.
Why read metadata before converting a file?
So you can tell what the conversion cost. Running the same reader on both sides is the only reliable way to see what a driver dropped.