Reading and Writing GDAL Tags and Band Descriptions

Give every band a name and record provenance as tags, so the file explains itself:

import rasterio

with rasterio.open("stack.tif", "r+") as dst:
    for i, name in enumerate(["B02", "B03", "B04", "B08"], start=1):
        dst.set_band_description(i, name)
    dst.update_tags(source="S2B_36NYF_20260614", processing="l2a-stack v1.3")
    dst.update_tags(4, wavelength_nm="842", role="near infrared")

A band order that exists only in a script’s comments is an accident waiting to happen; one written into the file is a contract. This page belongs to extracting and parsing raster metadata in Core Raster Fundamentals & STAC Mapping.


Where Each Kind of Fact Lives

Three places to put metadata in a GeoTIFF Dataset tags hold facts about the whole file such as its source scene, processing software and date. Each band has one description, its short identity. Each band can also carry its own tags for facts specific to that band such as wavelength, scale, or a decoding rule. All three live in the header and are read on open. Inside the file header dataset tags — facts about the whole file source=S2B_36NYF_20260614 · processing=l2a-stack v1.3 · created=2026-09-18 band 1 description: B04 tags: wavelength_nm=665 role=red band 2 description: B08 tags: wavelength_nm=842 role=near infrared band 3 description: SCL tags: categorical=true classes=0..11 Descriptions identify; tags explain. Code should select bands by description, never by index.

The practical rule is short. Band descriptions are for identity, and code should select bands by them. Band tags are for facts that apply to one band — its wavelength, its decoding rule, whether it is categorical. Dataset tags are for provenance: what the file was made from, by what, and when.


Environment & Setup

Package Version pin Used for
rasterio >=1.3.0 descriptions, tags, update_tags, set_band_description
rio-cogeo >=5.0 Checking that conversion preserves tags
pip install "rasterio>=1.3.0" "rio-cogeo>=5.0"

Complete Working Example

import getpass
import json
import platform
from datetime import datetime, timezone

import rasterio


def describe_stack(path: str, bands: list[dict], *, sources: list[str],
                   software: str) -> None:
    """Write band identities and provenance into an existing raster, in place."""
    with rasterio.open(path, "r+") as dst:
        if len(bands) != dst.count:
            raise ValueError(f"{len(bands)} band specs for {dst.count} bands")
        for i, spec in enumerate(bands, start=1):
            dst.set_band_description(i, spec["name"])
            dst.update_tags(i, **{k: str(v) for k, v in spec.items() if k != "name"})

        dst.update_tags(
            sources=json.dumps(sources),
            software=software,
            created=datetime.now(timezone.utc).isoformat(timespec="seconds"),
            host=platform.node(),
            author=getpass.getuser(),
        )


def band_index(src: rasterio.DatasetReader, name: str) -> int:
    """1-based band index by description — the only safe way to select a band."""
    names = list(src.descriptions)
    if name not in names:
        raise KeyError(f"band {name!r} not in {names}")
    return names.index(name) + 1


if __name__ == "__main__":
    describe_stack(
        "stack.tif",
        [{"name": "B04", "wavelength_nm": 665, "role": "red"},
         {"name": "B08", "wavelength_nm": 842, "role": "near infrared"},
         {"name": "SCL", "categorical": True, "classes": "0..11"}],
        sources=["S2B_36NYF_20260614_0_L2A"],
        software="l2a-stack 1.3.0",
    )
    with rasterio.open("stack.tif") as src:
        nir = src.read(band_index(src, "B08"))
        print(src.descriptions, src.tags())

band_index is the function the rest of a codebase should use. A stack built by another team, or by an earlier version of your own pipeline, may order bands differently, and selecting by position silently reads the wrong one; selecting by description fails loudly instead, which is the right behaviour. The consequences of getting this wrong for a model are set out in feature engineering for pixel-based models.


What Survives Which Conversion

Conversions that keep, and lose, metadata Copying with gdal_translate or rio-cogeo generally preserves descriptions and default-namespace tags. Rebuilding a file from arrays with a new profile loses everything unless it is copied explicitly. Writing through rioxarray preserves attributes it knows about and may drop others. Any path that changes the band count loses the mapping between old tags and new bands. Read the tags back after every step path descriptions and tags gdal_translate / rio cogeo create generally preserved rebuild from arrays with a new profile lost unless copied explicitly rioxarray to_raster partly: known attributes only any step that changes band count band tags no longer line up Rows describe typical behaviour; versions differ, which is why the read-back is not optional.

The rebuild-from-arrays row is where most metadata dies. A processing step that reads bands into NumPy, computes, and writes a fresh file from a hand-built profile starts with an empty header. The fix is a small helper that copies descriptions and tags from the source dataset to the destination as part of every write, so preservation is the default rather than something each step has to remember.

def copy_metadata(src: rasterio.DatasetReader, dst: rasterio.DatasetWriter,
                  band_map: dict[int, int] | None = None) -> None:
    """Copy dataset tags, and band descriptions/tags for mapped bands."""
    dst.update_tags(**src.tags())
    band_map = band_map or {i: i for i in range(1, min(src.count, dst.count) + 1)}
    for s, d in band_map.items():
        if src.descriptions[s - 1]:
            dst.set_band_description(d, src.descriptions[s - 1])
        dst.update_tags(d, **src.tags(s))

Provenance Worth Recording

A short, consistent set of dataset tags answers almost every later question about where a file came from. The source identifiers — catalog item ids or input file names — say what went in. The software name and version say what processed it. The creation timestamp says when. For derived products, a hash of the configuration used says exactly how.

Keep these as identifiers rather than documents. A tag holding a catalog item id is small and precise; a tag holding the full item JSON bloats every file header and duplicates what the catalog already stores. The richer record belongs in a catalog, as described in attaching model metadata to STAC items, with the file’s tags pointing to it.


Verification

Check after every step, not only at the end A four step pipeline — stack, compute, reproject, convert to COG — runs a metadata check after each step. The check after the compute step fails because that step rebuilt the file from arrays, which pinpoints where the tags were lost instead of discovering the loss only in the final product. Find the step that dropped the tags stack ✓ compute ✗ reproject to COG The compute step rebuilt the file from arrays with a fresh profile. Without the per-step check, the loss is only noticed in the final product.
import rasterio

with rasterio.open("stack_cog.tif") as src:
    assert all(src.descriptions), f"missing descriptions: {src.descriptions}"
    assert len(set(src.descriptions)) == src.count, "duplicate band names"
    tags = src.tags()
    for key in ("sources", "software", "created"):
        assert key in tags, f"provenance tag {key!r} lost"
    print(src.descriptions, {k: tags[k] for k in ("software", "created")})

Run this check after every conversion step in a pipeline, not only at the end: finding that tags disappeared at step three of seven is much easier when step three is the one that fails. The broader audit of a whole collection is covered in automating metadata extraction for batch raster jobs.


Common Errors

Descriptions are None after writing

They were set on a dataset opened read-only, or before the band was written in a way that reset them. Open with r+ or set them after writing in w mode.

Tags vanish after a processing step

The step rebuilt the file from arrays. Copy metadata from the source as part of the write.

Band tags describe the wrong band

A step reordered or dropped bands without remapping tags. Pass an explicit band map when copying.

File opens slowly after adding tags

A very large value — a whole JSON document — was stored. Keep tags to identifiers and move bulk to a catalog.


Frequently Asked Questions

Q: What is the difference between a band description and a band tag? A description is a single short string per band, shown by most GIS tools as the band’s name. Tags are arbitrary key-value pairs attached to a band or to the whole dataset. Use the description for the band’s identity and tags for everything else.

Q: Do tags survive conversion to a COG? With rio-cogeo and gdal_translate, dataset and band tags in the default namespace are usually carried through. Some paths drop them, notably conversions that rebuild the file from arrays. Always read them back after a conversion rather than assuming.

Q: How large can tag values be? Large enough for provenance strings and small JSON documents, but tags are stored in the file header, so very large values slow every open. Keep them to identifiers and short facts, and put anything bulky in a sidecar or a catalog record.

Q: What are tag namespaces for? They separate metadata by purpose — GDAL uses them for things like image structure and resampling hints. Your own tags belong in the default namespace unless a tool expects a specific one; overview resampling hints, for example, go in the rio_overview namespace.