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.
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.
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.
Edge cases or notes
AREA_OR_POINTis 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.
Internal links
- How to read the metadata already inside your spatial files โ reading tags back
- How to write a metadata record for a dataset in Python โ the full record the tags summarise
- Provenance and lineage explained for spatial workflows โ what belongs in LINEAGE
- Metadata disappears when you convert the file โ what drops tags
- An introduction to rasterio in Python โ the profile and the write pattern
- How to write a Cloud Optimised GeoTIFF in Python โ the layout tags sit in
- How to reproject a raster with rasterio โ the step that drops them
- rasterio reads the wrong values because of nodata or scaling โ why band metadata matters
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.