How to embed provenance tags in a GeoTIFF

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, East Sussex",
        SOURCE="Sentinel-2 L2A, tiles T30UXB, 2026-04-02 โ€ฆ 2026-04-27",
        LINEAGE="cloud mask SCL 3,8,9,10 โ†’ median composite โ†’ (B08-B04)/(B08+B04)",
        LICENSE="CC-BY-4.0",
        ATTRIBUTION="Contains modified Copernicus Sentinel data 2026",
        CONTENT_DATE="2026-04-30",
        PROCESSING_SOFTWARE="rasterio 1.5.1",
        CONTACT="[email protected]",
    )
    dst.update_tags(1, DESCRIPTION="NDVI", UNITS="dimensionless", VALID_RANGE="-1,1")

Reading them back gives exactly what you wrote plus one extra: rasterio writes AREA_OR_POINT itself, so a file with seven of your tags reads back eight.

Stack showing GeoTIFF dataset tags, band tags, descriptions and units alongside the pixel data.
Four separate slots, and band descriptions are not band tags.

Step-by-step solution

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 bloats the header, slows every open, and will be truncated by some readers. 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.

Flow from source raster through processing to an output whose tags are copied forward and extended.
Tags do not survive processing by themselves; carrying them is a step you write.

Code examples

Example 1 โ€” write tags at creation, with the band slots filled

import rasterio, numpy as np, datetime

profile = dict(driver="GTiff", width=1024, height=1024, count=1, dtype="float32",
               crs="EPSG:27700", 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, East Sussex",
        SOURCE="Sentinel-2 L2A via Earth Search, T30UXB/T31UCT",
        LINEAGE="SCL mask (3,8,9,10) โ†’ median over 2026-04-02..2026-04-27 โ†’ NDVI",
        SCENE_COUNT="11",
        CONTENT_DATE="2026-04-30",
        LICENSE="CC-BY-4.0",
        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", SCALE="1", OFFSET="0")

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)

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

Explanation

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 large embedded document costs a header read on every access and can hit reader-specific limits. The pattern that works is a short block plus a METADATA_SHA256 tag pointing at the full record.

Two panels contrasting a compact GeoTIFF tag block with a large embedded JSON document in a tag.
The header is parsed on every open, so the block stays small.

Edge cases or notes

  • 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+ mode rewrites the header only. Adding tags to a finished file is cheap.
  • COG layout is preserved by a tag update; the overviews are untouched.
  • 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.