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']
Five formats against the metadata slots each provides. what each format can holdGPKGGeoTIFFParquetGeoJSONdataset recordโœ“tableโœ“tagsโœ“footerโœ•noneper layer / bandโœ“yesโœ“yesn/aโœ•nofield typesโœ“yesn/aโœ“yes!inferredreadable without GDALโœ“sqlite3โœ“rasterioโœ“pyarrowโœ“jsonShapefile is the fifth: a .prj, a .cpg and an .xml half the tools ignore spatialworkflow.io
Two of the five common formats have nowhere to put a dataset-level record.

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.

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.

The order to look for metadata, cheapest first. four places, in order1container metadataGetMetadata()2layer and band metadataseparate slots3derived factsCRS, extent, schema4sidecars and the original zipREADME, .shp.xmlThe third step alone rebuilds most of a lost record spatialworkflow.io
Four places to look, in the order that costs least.

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.

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.

The key-value blocks in a GeoParquet footer. GeoParquet footer keysARROW:schemacolumn typeswritten by Arrowgeoprimary_column, version, creatorGeoParquet blockโ†’ columns.geometryencoding, crs, bboxper geometry columnpandasindex and dtypeswritten by pandasReadable with pyarrow alone, and nothing stops you adding your own key spatialworkflow.io
The observed keys on a GeoPandas-written file, and what each holds.

Edge cases or notes

  • AREA_OR_POINT is always there. GDAL reports it with the dataset tags; it is not one of yours.
  • Band descriptions are not tags. src.descriptions and src.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.xml may 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.

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.

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.