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.
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.
Related
- Extracting and Parsing Raster Metadata — the parent section on harvesting CRS, transform, and band metadata from rasters.
- Automating Metadata Extraction for Batch Raster Jobs — scale this single-file inspection across thousands of files and persist the results.
- How to Read COG Headers Without Downloading Full Files — read nodata and dtype from remote files over HTTP without transferring pixels.
- Core Raster Fundamentals & STAC Mapping — how metadata parsing fits the wider raster ingestion architecture.