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).
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.
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
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.
String values, update mode and rasterio's own tags
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+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 withIGNORE_COG_LAYOUT_BREAKleaves 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.
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.