Extracting NoData and dtype from a GeoTIFF

A GeoTIFF stores its fill value and per-band data type in the header, so you can read both with rasterio without touching a single pixel — open the file and read src.nodata and src.dtypes:

import rasterio

with rasterio.open("scene.tif") as src:
    print("nodata:", src.nodata)     # e.g. 0.0, 65535.0, or None if undeclared
    print("dtypes:", src.dtypes)     # ('uint16',) — one entry per band
    print("mask flags:", src.mask_flag_enums)  # how validity is signalled

This page belongs to Extracting and Parsing Raster Metadata and drills into the two fields that most often trip up index math and type casting — the nodata sentinel and the dtype — including the awkward case where the file declares no nodata at all.


Why This Arises in Remote Sensing Workflows

Two header fields decide whether a downstream computation is correct. The dtype determines memory footprint and the legal value range: a uint16 Sentinel-2 band holds 0–65535, and casting it to int16 before scaling silently overflows. The nodata value tells you which cells are fill rather than measurement — if you average a stack without excluding nodata, padding pixels drag the result toward the sentinel.

The trap is that nodata is optional in the GeoTIFF spec. Plenty of real products pad their edges with 0 or 65535 but never write the nodata tag, so src.nodata returns None. Treat None as “no fill pixels” and every edge cell contaminates your statistics. You therefore need both a way to read the declared value cheaply and a strategy for when it is absent — GDAL’s per-pixel dataset mask, or inference from the dtype range and edge samples.

Because these fields live in the header, reading them is a metadata operation, not a pixel read — the same principle behind How to Read COG Headers Without Downloading Full Files. This single-file inspection is also the building block for the bulk workflow in Automating Metadata Extraction for Batch Raster Jobs. For the wider context, see Core Raster Fundamentals & STAC Mapping.


Where Validity Comes From

mask_flag_enums reports which source GDAL uses to decide whether a pixel is valid. The decision tree below shows how to resolve a mask whether or not a nodata value is declared.

Resolving pixel validity from a GeoTIFF's mask flags Starting from the mask flags, the tree branches: if a nodata value is set, cells equal to it are invalid; if an alpha or per-dataset mask exists, dataset_mask returns validity directly; if nodata is None and no mask flag is set, infer a sentinel from the dtype range and edge pixels. src.mask_flag_enums what backs the mask? nodata set cells == src.nodata → invalid alpha / per_dataset dataset_mask() → 0/255 all_valid, nodata None no fill declared dataset_mask() → boolean valid map infer from dtype range + edge-pixel sampling exclude invalid, then compute

dataset_mask() is the unifying call: it returns a uint8 array where 255 marks valid pixels and 0 marks fill, regardless of whether the validity came from a nodata value, an alpha band, or an internal mask. When even that reports everything valid but you can see padding, you are in the inference branch on the right.

The individual MaskFlags values are worth knowing because each implies a different masking strategy:

Mask flag Meaning How to get validity
all_valid No mask and no nodata declared Infer a sentinel, or trust every pixel
nodata A nodata value is set in the header array != src.nodata, or read with masked=True
alpha An alpha band carries opacity dataset_mask() folds it in automatically
per_dataset An internal .msk mask band exists dataset_mask() reads it directly

Because these are per-band flags, a file can legitimately mix sources — a nodata value on the reflectance bands and an alpha band for display, for instance. Read mask_flag_enums per band rather than assuming one policy covers the whole dataset.


Environment & Setup

Package Minimum version Why required
rasterio 1.3.0 nodata, dtypes, mask_flag_enums, dataset_mask()
numpy 1.23 Boolean masking and dtype range queries via iinfo
pip install "rasterio>=1.3.0" "numpy>=1.23"

Inspect a file’s declared fields in one glance:

import rasterio

with rasterio.open("scene.tif") as src:
    print(dict(nodata=src.nodata, dtypes=src.dtypes, count=src.count))
    print("mask flags per band:", src.mask_flag_enums)

Complete Working Example

This function returns a structured summary of nodata and dtype, resolves a validity mask through whichever source the file provides, and infers a sentinel when none is declared.

import numpy as np
import rasterio
from rasterio.enums import MaskFlags
from typing import Any


def describe_nodata_and_dtype(path: str, band: int = 1) -> dict[str, Any]:
    """
    Summarise a GeoTIFF's nodata, dtype and validity source without a full read.

    Returns a dict with the declared nodata, per-band dtypes, the mask source,
    the count of valid pixels, and — if nodata is undeclared — an inferred
    sentinel derived from the dtype range and edge sampling.
    """
    with rasterio.open(path) as src:
        info: dict[str, Any] = {
            "path": path,
            "dtypes": src.dtypes,                 # header field, no pixels read
            "declared_nodata": src.nodata,        # may be None
            "mask_flags": [str(f) for f in src.mask_flag_enums[band - 1]],
        }

        # dataset_mask() returns 255 for valid, 0 for fill — from whatever
        # source (nodata value, alpha band, or internal mask) GDAL found.
        mask = src.dataset_mask()
        info["valid_pixel_count"] = int((mask == 255).sum())
        info["fill_pixel_count"] = int((mask == 0).sum())

        # When the file declares no nodata AND GDAL sees every pixel as valid,
        # padding (if any) is untagged — infer a candidate sentinel.
        flags = src.mask_flag_enums[band - 1]
        if src.nodata is None and MaskFlags.all_valid in flags:
            info["inferred_nodata"] = _infer_sentinel(src, band)
        else:
            info["inferred_nodata"] = None

    return info


def _infer_sentinel(src: rasterio.DatasetReader, band: int) -> float | None:
    """
    Guess an undeclared nodata sentinel from the dtype extremes and the
    values that actually occur on the four scene edges.
    """
    dtype = src.dtypes[band - 1]
    if not np.issubdtype(np.dtype(dtype), np.integer):
        return None  # floats usually use NaN, not an integer sentinel

    lo, hi = np.iinfo(dtype).min, np.iinfo(dtype).max
    data = src.read(band)                       # full read only in this branch
    edges = np.concatenate([
        data[0, :], data[-1, :], data[:, 0], data[:, -1],
    ])
    # A dtype extreme that dominates the border is almost certainly fill.
    for candidate in (lo, hi, 0):
        share = float((edges == candidate).mean())
        if share > 0.20:
            return float(candidate)
    return None


if __name__ == "__main__":
    summary = describe_nodata_and_dtype("scene.tif")
    for key, value in summary.items():
        print(f"{key}: {value}")

    # Apply the resolved mask before any statistics.
    with rasterio.open("scene.tif") as src:
        arr = src.read(1).astype("float32")
        valid = src.dataset_mask() == 255
        mean = arr[valid].mean()   # fill pixels excluded
        print("mean over valid pixels:", mean)

The inference branch is the only place a full .read() happens, and only when a file both omits nodata and marks everything valid. Everywhere else the summary comes straight from the header.


Variant Patterns

1. Building a masked array in one step

rasterio can hand you a NumPy masked array directly, applying the dataset mask so arithmetic skips fill automatically:

import rasterio

with rasterio.open("scene.tif") as src:
    # masked=True applies dataset_mask(); fill pixels become np.ma.masked
    band = src.read(1, masked=True)

print("valid mean:", band.mean())   # masked entries are excluded
print("fill count:", band.mask.sum())

2. Setting a nodata value the file forgot to declare

When you have inferred the sentinel, write it into the header (or into an in-memory copy) so downstream tools honour it. Update the profile and re-save:

import rasterio

with rasterio.open("scene.tif") as src:
    profile = src.profile
    data = src.read()

profile.update(nodata=0)   # tag 0 as fill for every band

with rasterio.open("scene_tagged.tif", "w", **profile) as dst:
    dst.write(data)

3. Per-band dtype and nodata for mixed-type files

A single GeoTIFF can carry bands of different dtypes. Iterate band indices to capture each one — useful when a batch job aligns dtypes before stacking, as in Automating Metadata Extraction for Batch Raster Jobs:

import rasterio

with rasterio.open("multiband.tif") as src:
    per_band = [
        {"band": i,
         "dtype": src.dtypes[i - 1],
         "nodata": src.nodatavals[i - 1]}   # tuple of per-band nodata values
        for i in range(1, src.count + 1)
    ]

for row in per_band:
    print(row)

Common Errors

src.nodata is None but the scene has black edges

The file never declared a nodata tag. Do not assume the edges are valid — call dataset_mask(), and if that also reports all-valid, infer the sentinel from the dtype extremes and edge sampling, then tag it explicitly before computing statistics.

Statistics or index values skewed toward 0 or 65535

Fill pixels were included in the math because the nodata value was ignored. Read with masked=True, or build a boolean mask from dataset_mask() == 255 and apply it before any mean, ratio, or histogram.

OverflowError or wrapped values after casting a band

You cast a uint16 band to a narrower or signed dtype without checking src.dtypes. Read the header dtype first, promote to float32 before scaling, and only downcast once the value range is known to fit.