How to embed provenance tags in a GeoTIFF

On this page

Problem statement

A GeoTIFF travels further than its sidecar. It gets copied into a shared drive, attached to an email, loaded into QGIS from somebody's downloads folder, and at every step the .json file that explained it stays behind. Six months later there is a raster with no source, no date and no licence, and somebody has to guess what it is.

TIFF has held arbitrary key–value tags since long before anyone put a CRS in one, and rasterio exposes them in two lines. Using them costs nothing, survives copying, and turns gdalinfo into a provenance reader.

This guide writes a provenance block into a GeoTIFF, keeps it intact through the operations that normally destroy it, and checks it on the way out.

Quick answer

import rasterio

with rasterio.open("scene.tif", "r+") as dst:
    dst.update_tags(
        TITLE="Sentinel-2 NDVI composite, Lewes, East Sussex",
        SOURCE=f"Sentinel-2 L2A via Earth Search, MGRS 30UYB, {FIRST} … {LAST}",
        LINEAGE="SCL keep 4,5,7 → (B08-B04)/(B08+B04) per date → median composite",
        LICENSE="Copernicus Sentinel data terms",
        ATTRIBUTION="Contains modified Copernicus Sentinel data 2026",
        CONTENT_DATE=LAST,
        PROCESSING_SOFTWARE=f"rasterio {rasterio.__version__}",
        CONTACT="gis@example.org",
    )
    dst.update_tags(1, UNITS="dimensionless", VALID_RANGE="-1,1")

with rasterio.open("scene.tif") as src:
    print(len(src.tags()), "dataset tags:", sorted(src.tags()))
9 dataset tags: ['AREA_OR_POINT', 'ATTRIBUTION', 'CONTACT', 'CONTENT_DATE', 'LICENSE', 'LINEAGE', 'PROCESSING_SOFTWARE', 'SOURCE', 'TITLE']

Reading them back gives exactly what you wrote plus one extra: GDAL records AREA_OR_POINT itself, so eight tags of yours read back as nine. The scene.tif here is a plain tiled GeoTIFF; a Cloud Optimised GeoTIFF refuses this, for a good reason (Example 4).

The metadata slots in a GeoTIFF alongside the pixel data. four slots, not onedataset tagsupdate_tags(**kw)TITLE, SOURCE, LICENSEband tagsupdate_tags(1, **kw)VALID_RANGE, SCALEdescriptionsdst.descriptionsband names in QGISunitsdst.unitsper bandpixelsthe datauntouched by a tag writeGDAL adds AREA_OR_POINT itself, so expect one more tag than you wrote spatialworkflow.io
Four separate slots, and band descriptions are not band tags.

File or sidecar, dataset and band tags

1. Decide what belongs in the file rather than the sidecar

Tags are for the facts a stranger needs in order not to misuse the raster: what it is, what the values mean, when it was true, where it came from and what they may do with it. The full lineage record — every parameter of every step — can stay in the sidecar. A tag block of eight to twelve items is right.

2. Write dataset tags and band tags separately

update_tags(**kw) writes at dataset level; update_tags(band, **kw) writes at band level. Anything that is per band — the index it holds, the units, the valid range, the scale factor — belongs on the band.

3. Set band descriptions and units through their own properties

with rasterio.open(path, "r+") as dst:
    dst.descriptions = ("NDVI", "cloud fraction")
    dst.units = ("dimensionless", "fraction")

These are distinct TIFF slots from tags, and they are the ones QGIS shows as band names. Setting both is cheap and avoids "Band 1" in every legend.

4. Write tags at creation time where you can

Adding them in the same with block that writes the pixels means a raster cannot exist without them.

5. Carry the block through derived products

The operations that make new rasters — reproject, merge, a windowed read, a band-math step — do not carry tags across. Copy them forward explicitly and add a step to the lineage.

6. Do not exceed what the format handles comfortably

Tags are stored in the TIFF header. A short block is free; a 200 KB JSON blob in a tag is read on every open. Measured with GDAL 3.12, a 200 KB tag came back whole, but opening the file and reading its tags took twice as long — 0.93 ms instead of 0.42 (Example 4). Put the long record in the sidecar and a checksum of it in the tag.

7. Check the tags on the way out

A publish gate that requires TITLE, SOURCE, LICENSE and CONTENT_DATE is four lines and catches the raster somebody generated in a hurry.

Carrying GeoTIFF tags through a derived product. tags do not carry themselvesread source tagsdataset and bandprocessreproject, merge, mathwrite tags forwardexplicitlyappend to LINEAGEwhat just happenedrasterio.warp writes pixels and copies no metadata spatialworkflow.io
Tags do not survive processing by themselves; carrying them is a step you write.

Tags written at creation, then carried through processing

The data used on this page

A median NDVI for April 2026 over Lewes, East Sussex, from every Sentinel-2 L2A scene on MGRS tile 30UYB with less than 10% cloud, read from Element 84's Earth Search. Earth Search lists one date twice — a reprocessed copy whose earthsearch:boa_offset_applied is false, so its values still carry the +1000 offset of processing baseline 04.00 — so the setup keeps one scene per date and removes the offset where it has not been applied. scene.tif is the composite as a plain tiled GeoTIFF for the quick answer; the examples write their own files.

import numpy as np
import pystac_client
import rasterio
from rasterio.enums import Resampling
from rasterio.warp import transform_bounds
from rasterio.windows import from_bounds

BBOX = (-0.10, 50.82, 0.06, 50.92)
found = pystac_client.Client.open("https://earth-search.aws.element84.com/v1").search(
    collections=["sentinel-2-l2a"], bbox=BBOX, datetime="2026-04-01/2026-04-30",
    query={"eo:cloud_cover": {"lt": 10}, "grid:code": {"eq": "MGRS-30UYB"}}).items()
by_date = {}
for item in sorted(found, key=lambda it: not it.properties["earthsearch:boa_offset_applied"]):
    by_date.setdefault(item.datetime.date().isoformat(), item)     # prefer the harmonised copy
DATES = sorted(by_date)
FIRST, LAST = DATES[0], DATES[-1]

layers = []
for date in DATES:
    item = by_date[date]
    offset = 0 if item.properties["earthsearch:boa_offset_applied"] else 1000
    with rasterio.open(item.assets["red"].href) as r:
        window = from_bounds(*transform_bounds("EPSG:4326", r.crs, *BBOX), r.transform)
        window = window.round_offsets().round_lengths()
        red = r.read(1, window=window).astype("float32") - offset
        crs, transform = r.crs, r.window_transform(window)
    with rasterio.open(item.assets["nir"].href) as r:
        nir = r.read(1, window=window).astype("float32") - offset
    with rasterio.open(item.assets["scl"].href) as r:
        scl = r.read(1, window=from_bounds(*transform_bounds("EPSG:4326", r.crs, *BBOX), r.transform),
                     out_shape=red.shape, resampling=Resampling.nearest)
    clear = np.isin(scl, [4, 5, 7]) & (red + nir > 0)
    with np.errstate(invalid="ignore", divide="ignore"):
        layers.append(np.where(clear, (nir - red) / (nir + red), np.nan))
with np.errstate(all="ignore"):
    ndvi = np.nanmedian(np.stack(layers), axis=0).astype("float32")

with rasterio.open("scene.tif", "w", driver="GTiff", width=ndvi.shape[1], height=ndvi.shape[0],
                   count=1, dtype="float32", crs=crs, transform=transform, nodata=np.nan,
                   tiled=True, compress="deflate") as dst:
    dst.write(ndvi, 1)
print(f"{len(DATES)} dates {FIRST} … {LAST}; {ndvi.shape[1]} x {ndvi.shape[0]} in {crs}; "
      f"median NDVI {np.nanmedian(ndvi):.3f}")
5 dates 2026-04-07 … 2026-04-30; 1170 x 1157 in EPSG:32630; median NDVI 0.772

Example 1 — write tags at creation, with the band slots filled

import rasterio, numpy as np, datetime

profile = dict(driver="GTiff", width=ndvi.shape[1], height=ndvi.shape[0], count=1,
               dtype="float32", crs=crs, transform=transform, nodata=np.nan,
               compress="deflate", predictor=3, tiled=True, blockxsize=512, blockysize=512)

with rasterio.open("ndvi_2026-04.tif", "w", **profile) as dst:
    dst.write(ndvi.astype("float32"), 1)
    dst.descriptions = ("NDVI",)
    dst.units = ("dimensionless",)
    dst.update_tags(
        TITLE="Sentinel-2 NDVI median composite, Lewes, East Sussex",
        SOURCE="Sentinel-2 L2A via Earth Search, MGRS 30UYB",
        LINEAGE=f"one scene per date, BOA offset removed → SCL keep 4,5,7 → NDVI → "
                f"median over {FIRST}..{LAST}",
        SCENE_COUNT=str(len(DATES)),
        CONTENT_DATE=LAST,
        LICENSE="Copernicus Sentinel data terms",
        ATTRIBUTION="Contains modified Copernicus Sentinel data 2026",
        PROCESSING_SOFTWARE=f"rasterio {rasterio.__version__}",
        GENERATED=datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
    )
    dst.update_tags(1, VALID_RANGE="-1,1")

with rasterio.open("ndvi_2026-04.tif") as src:
    for key, value in src.tags().items():
        print(f"{key:20} {value if key != 'GENERATED' else '(the time of the run)'}")
    print("band 1:", src.descriptions[0], src.units[0], src.tags(1))
ATTRIBUTION          Contains modified Copernicus Sentinel data 2026
CONTENT_DATE         2026-04-30
GENERATED            (the time of the run)
LICENSE              Copernicus Sentinel data terms
LINEAGE              one scene per date, BOA offset removed → SCL keep 4,5,7 → NDVI → median over 2026-04-07..2026-04-30
PROCESSING_SOFTWARE  rasterio 1.5.1
SCENE_COUNT          5
SOURCE               Sentinel-2 L2A via Earth Search, MGRS 30UYB
TITLE                Sentinel-2 NDVI median composite, Lewes, East Sussex
AREA_OR_POINT        Area
band 1: NDVI dimensionless {'VALID_RANGE': '-1,1'}

Example 2 — carry tags through a derived product

import rasterio
from rasterio.warp import calculate_default_transform, reproject, Resampling

def reproject_keeping_tags(src_path, dst_path, dst_crs, step_note):
    with rasterio.open(src_path) as src:
        transform, width, height = calculate_default_transform(
            src.crs, dst_crs, src.width, src.height, *src.bounds)
        profile = src.profile | {"crs": dst_crs, "transform": transform,
                                 "width": width, "height": height}
        tags = src.tags()
        band_tags = {i: src.tags(i) for i in range(1, src.count + 1)}
        descriptions, units = src.descriptions, src.units

        with rasterio.open(dst_path, "w", **profile) as dst:
            for i in range(1, src.count + 1):
                reproject(source=rasterio.band(src, i), destination=rasterio.band(dst, i),
                          src_transform=src.transform, src_crs=src.crs,
                          dst_transform=transform, dst_crs=dst_crs,
                          resampling=Resampling.bilinear)
            dst.descriptions = descriptions
            dst.units = units
            dst.update_tags(**tags)
            dst.update_tags(LINEAGE=(tags.get("LINEAGE", "") +
                                     f" → {step_note}").strip(" →"))
            for i, bt in band_tags.items():
                dst.update_tags(i, **bt)


reproject_keeping_tags("ndvi_2026-04.tif", "ndvi_2026-04_bng.tif", "EPSG:27700",
                       "reprojected to EPSG:27700 (bilinear)")
with rasterio.open("ndvi_2026-04_bng.tif") as src:
    print(src.crs, src.descriptions, src.units)
    print("LINEAGE:", src.tags()["LINEAGE"])
EPSG:27700 ('NDVI',) ('dimensionless',)
LINEAGE: one scene per date, BOA offset removed → SCL keep 4,5,7 → NDVI → median over 2026-04-07..2026-04-30 → reprojected to EPSG:27700 (bilinear)

The LINEAGE append is the point: a derived raster whose lineage still says only the source is a raster that lies about itself.

Example 3 — the gate

import rasterio

REQUIRED = ("TITLE", "SOURCE", "LICENSE", "CONTENT_DATE", "LINEAGE")

def check_tags(path):
    with rasterio.open(path) as src:
        tags = src.tags()
        missing = [k for k in REQUIRED if not tags.get(k)]
        bandless = [i for i in range(1, src.count + 1) if not src.descriptions[i - 1]]
    problems = []
    if missing:
        problems.append(f"missing dataset tags: {missing}")
    if bandless:
        problems.append(f"bands with no description: {bandless}")
    if problems:
        raise ValueError(f"{path}: " + "; ".join(problems))
    return True


for path in ("ndvi_2026-04.tif", "ndvi_2026-04_bng.tif", "scene.tif"):
    try:
        check_tags(path)
        print(path, "passes")
    except ValueError as error:
        print(error)
ndvi_2026-04.tif passes
ndvi_2026-04_bng.tif passes
scene.tif: bands with no description: [1]

Example 4 — tags on a Cloud Optimised GeoTIFF, and on a big tag

import os
import time

from rio_cogeo.cogeo import cog_validate

cog_profile = dict(driver="COG", width=ndvi.shape[1], height=ndvi.shape[0], count=1,
                   dtype="float32", crs=crs, transform=transform, nodata=np.nan, compress="deflate")
with rasterio.open("ndvi_cog.tif", "w", **cog_profile) as dst:
    dst.write(ndvi, 1)
    dst.update_tags(TITLE="Sentinel-2 NDVI median composite, Lewes", CONTENT_DATE=LAST)
print("written with tags:", cog_validate("ndvi_cog.tif")[0], sorted(rasterio.open("ndvi_cog.tif").tags()))

try:
    with rasterio.open("ndvi_cog.tif", "r+") as dst:
        dst.update_tags(LICENSE="Copernicus Sentinel data terms")
except Exception as error:                     # a GDAL error, not a RasterioError
    print("r+ refused:", type(error).__name__, str(error)[:70], "…")

with rasterio.open("ndvi_cog.tif", "r+", IGNORE_COG_LAYOUT_BREAK="YES") as dst:
    dst.update_tags(LICENSE="Copernicus Sentinel data terms")
valid, errors, _ = cog_validate("ndvi_cog.tif", quiet=True)
print("forced update: still a COG?", valid, "|", errors[0])

blob = "x" * 200_000
for name, extra in (("small_tags.tif", {}), ("big_tag.tif", {"RECORD": blob})):
    with rasterio.open(name, "w", **profile) as dst:
        dst.write(ndvi, 1)
        dst.update_tags(TITLE="t", **extra)
    start = time.perf_counter()
    for _ in range(200):
        with rasterio.open(name) as src:
            tags = src.tags()
    print(f"{name:15} open + tags {(time.perf_counter() - start) / 200 * 1000:.2f} ms; "
          f"RECORD read back {len(tags.get('RECORD', '')):,} characters")
written with tags: True ['AREA_OR_POINT', 'CONTENT_DATE', 'TITLE']
r+ refused: CPLE_AppDefinedError File ndvi_cog.tif has C(loud) O(ptimized) G(eoTIFF) layout. Updating i …
forced update: still a COG? False | The offset of the main IFD should be < 300. It is 6126376 instead
small_tags.tif  open + tags 0.42 ms; RECORD read back 0 characters
big_tag.tif     open + tags 0.93 ms; RECORD read back 200,000 characters
What happens when tags are added to a plain GeoTIFF and to a Cloud Optimised GeoTIFF, measured with GDAL 3.12. adding tags later: fine for a GeoTIFF, not for a COGwhat GDAL doesresultGeoTIFF, r+new header at file end✓tags writtenCOG, r+refuses to open!errorCOG, forcedIGNORE_COG_LAYOUT_BREAK✕no longer a COGCOG, at creationtags in the first header✓valid COGChecked with rio-cogeo: the forced file’s main header moved to byte 6,126,376 spatialworkflow.io
For a COG, tags are a creation-time decision — or a reason to re-create the file.

Tags survive where sidecars do not

Why tags survive where sidecars do not

The tags are bytes inside the file. Copying, moving, renaming, uploading and downloading all preserve them, and gdalinfo prints them without any extra tooling. A sidecar is a separate file that people forget, mail systems strip and cloud syncs rename.

Why processing drops them silently

Rasterio's reproject, merge and mask helpers write pixels into a new dataset; they have no opinion about metadata and copy none of it. That is the correct default — the tags describe the source, and the output is not the source — but it means carrying them forward has to be explicit, and an updated lineage entry is what makes the copy honest.

Why band descriptions are worth the extra line

They are what a GIS shows in the layer tree and the band selector. A four-band raster whose bands are labelled Band 1 through Band 4 will be misread eventually, usually by somebody computing an index with the wrong two bands.

Why the tag block should stay small

TIFF tags live in the image file directory, which readers parse on every open. A compact block of short strings costs nothing measurable; a 200 KB document doubled the time to open the file and read its tags, on every open. The pattern that works is a short block plus a METADATA_SHA256 tag pointing at the full record.

A compact GeoTIFF tag block against an embedded document. how much to embeda dozen short strings✓parsed on every open, free✓visible in gdalinfo✓survives every copy✓greppable by a gatea JSON document in a tag✕header read on every access✕doubled the open time at 200 KB✕unreadable on the command line✕duplicates the sidecarEmbed the summary and a METADATA_SHA256 pointing at the full record spatialworkflow.io
The header is parsed on every open, so the block stays small.

String values, update mode and rasterio's own tags

  • AREA_OR_POINT is rasterio's, not yours. Expect one more tag than you wrote.
  • Tag values are strings. Numbers come back as text; convert deliberately.
  • r+ on a plain GeoTIFF is cheap. GDAL writes a new header at the end of the file and leaves the pixels alone.
  • r+ on a COG is refused, and forcing it with IGNORE_COG_LAYOUT_BREAK leaves a file that is no longer a valid COG. Write the tags when you create it, or re-create it.
  • NetCDF and Zarr have attributes instead. Same idea, different API.
  • Do not put secrets in tags. They travel with the file by design.
  • JPEG-compressed TIFFs still hold tags. Compression affects pixels, not the header.
  • Keep keys uppercase and stable. They are what your gate greps for.

FAQ

How do I add metadata to a GeoTIFF in Python?

Open it in r+ mode and call update_tags(**kwargs) for dataset tags, or update_tags(band, **kwargs) for band tags. Set descriptions and units separately.

Do GeoTIFF tags survive being copied?

Yes. They are stored in the file header, so copying, renaming and uploading all preserve them — unlike a sidecar file.

Why did my tags disappear after reprojecting?

Because reproject writes into a new dataset and copies no metadata. Read the tags from the source and write them to the destination explicitly.

Where do band names come from in QGIS?

From the band descriptions, which are a separate slot from band tags. Set dst.descriptions as well as the tags.

How much can I put in a tag?

Keep the block short — a dozen short strings. Tags are parsed on every open, so put the full record in a sidecar and a checksum of it in a tag.

Are tag values typed?

No. Everything comes back as a string, so convert numbers explicitly when you read them.