Raster Dtypes, Scaling and Numerical Precision

The most insidious errors in raster processing are numerical, because they do not raise. Two uint16 bands added together wrap around silently when the sum exceeds 65,535. A scale factor applied twice produces reflectance of 0.00004 that looks like a very dark pixel. A nodata value of zero survives into a ratio as a genuine zero reflectance. Each produces a raster that loads, plots and passes a schema check, and is wrong. This topic — part of Core Raster Fundamentals & STAC Mapping — is about making those errors impossible rather than merely unlikely.

The organising rule is simple: store compact, compute in float, and record the conversion between them in the file. Almost every numerical defect in a raster pipeline is a violation of one of those three clauses.


Prerequisites

pip install "rasterio>=1.3.0" "numpy>=1.23" "xarray>=2024.1" "rioxarray>=0.15"
Package Minimum version Why required
rasterio 1.3.0 Reading dtype, nodata, scales and offsets from band metadata
numpy 1.23 Explicit dtype conversion and overflow-safe arithmetic
xarray 2024.1 Automatic scale and offset application through CF conventions
rioxarray 0.15 mask_and_scale on read, encoding on write

The metadata inspection this builds on is covered in extracting nodata and dtype from a GeoTIFF, and the band-math context in handling nodata and scale factors in band math.


The Dtype Landscape

Which dtype holds what uint8 holds zero to 255 in one byte and suits class maps and display images. int16 and uint16 hold about sixty-five thousand levels in two bytes and suit scaled reflectance and elevation. float32 holds seven significant digits and NaN in four bytes and suits computation. float64 doubles that for precision raster data rarely needs. Bytes per pixel against what each dtype is for dtype bytes right for uint8 1 class maps, masks, display renditions int16 / uint16 2 stored reflectance, indices, elevation — with a scale float32 4 all computation; NaN for missing float64 8 coordinates and accumulations; rarely pixel data Store in the middle rows, compute in float32, and let float64 stay in the transform.

The pattern that falls out of that table is the one most mature archives use: pixels stored as scaled 16-bit integers, unpacked to float32 for any arithmetic, and repacked for storage. The rest of this topic is about doing each conversion without losing information or inventing it.


Step-by-Step Workflow

Step 1 — Read the storage contract before the values

import rasterio


def storage_contract(path: str) -> list[dict]:
    """Dtype, nodata, scale and offset for every band."""
    with rasterio.open(path) as src:
        return [
            {
                "band": i,
                "name": src.descriptions[i - 1],
                "dtype": src.dtypes[i - 1],
                "nodata": src.nodatavals[i - 1],
                "scale": src.scales[i - 1],
                "offset": src.offsets[i - 1],
            }
            for i in range(1, src.count + 1)
        ]


for band in storage_contract("S2A_36NYF_20260614_B04.tif"):
    print(band)

Many products do not set scales and offsets in the file and instead document them in a catalog record or a product specification — Sentinel-2 L2A before its processing-baseline change, most Landsat Collection 2 products, and a great many derived archives. src.scales then reports 1.0, which is technically correct and practically misleading. Where the file is silent, take the values from the catalog item and record where they came from.

Step 2 — Unpack to float with nodata masked first

import numpy as np
import rasterio


def read_physical(path: str, band: int = 1, *, scale: float | None = None,
                  offset: float | None = None) -> np.ndarray:
    """Read a band as float32 physical values with nodata as NaN."""
    with rasterio.open(path) as src:
        raw = src.read(band)
        nodata = src.nodata
        scale = scale if scale is not None else src.scales[band - 1]
        offset = offset if offset is not None else src.offsets[band - 1]

    out = raw.astype("float32")                  # convert BEFORE any arithmetic
    if nodata is not None:
        out[raw == nodata] = np.nan               # mask using the RAW values
    return out * np.float32(scale) + np.float32(offset)

Two ordering rules carry the whole function. Convert to float before applying the scale, or integer arithmetic truncates the result. And compare against nodata using the raw integer array, not the scaled one — after scaling, the nodata value is no longer the number you are looking for, and comparing floats for equality is fragile anyway.

Step 3 — Compute in float32

Once every input is float32 with NaN for missing, band arithmetic is safe by construction: sums cannot overflow, divisions by zero produce inf or nan that can be handled explicitly, and missing data propagates rather than being silently treated as zero.

import numpy as np

red = read_physical("B04.tif", scale=0.0001, offset=-0.1)
nir = read_physical("B08.tif", scale=0.0001, offset=-0.1)

den = nir + red
ndvi = np.divide(nir - red, den, out=np.full_like(den, np.nan), where=den != 0)

The offset=-0.1 in that example is not illustrative decoration: Sentinel-2 L2A products from processing baseline 04.00 onward apply an offset of −1000 digital numbers to reflectance, and ignoring it shifts every index. The version-specific details are covered in converting int16 reflectance to float safely.

Step 4 — Choose a storage dtype from the result’s range and precision

From result to storage dtype If the result is a small set of classes, store uint8. If it is continuous with a known bounded range and needs about four significant digits, pack into int16 with a scale and offset. If the range is unbounded or needs more precision, store float32 and accept the size. What does the result look like? classes or continuous? classes fewer than 255 codes 0 reserved for nodata uint8 bounded continuous e.g. NDVI in -1..1 four digits is enough int16 + scale unbounded or precise e.g. biomass, ratios range unknown in advance float32 The middle column is where most remote sensing products belong, and where most are needlessly float32.

The middle branch is the one worth defaulting to for indices and reflectance. NDVI stored as int16 with a scale of 0.0001 represents every value from −1 to 1 at four decimal places — more precision than any sensor delivers — at half the size of float32. The packing arithmetic and its edge cases are the subject of packing float rasters into int16 with scale and offset.

Step 5 — Pack and record the conversion

import numpy as np
import rasterio


def write_packed(arr: np.ndarray, profile: dict, path: str, *,
                 scale: float = 0.0001, offset: float = 0.0,
                 nodata: int = -32768, description: str = "") -> None:
    packed = np.round((arr - offset) / scale)
    packed = np.where(np.isfinite(arr), packed, nodata)
    if np.nanmax(np.abs(packed[packed != nodata])) > 32767:
        raise OverflowError("values do not fit int16 at this scale")

    profile = profile | {"dtype": "int16", "nodata": nodata, "count": 1}
    with rasterio.open(path, "w", **profile) as dst:
        dst.write(packed.astype("int16"), 1)
        dst.scales = (scale,)
        dst.offsets = (offset,)
        dst.set_band_description(1, description)

Setting dst.scales and dst.offsets writes them into the GeoTIFF’s metadata, where rasterio, GDAL and rioxarray’s mask_and_scale will all find and apply them. A file that carries its own scale is self-describing; a file whose scale lives only in a README is a trap for the next person.


Nodata Is Part of the Dtype Decision

The nodata value has to be representable in the dtype, outside the range of valid data, and preserved by every operation between read and write. Those three constraints interact in ways that catch people repeatedly.

Nodata values and what happens to them Zero as nodata collides with real zero reflectance and water in some indices. A large negative sentinel such as minus 32768 in int16 is safe while the data stays integer but becomes an enormous negative number if it reaches arithmetic unmasked. NaN in float32 propagates through arithmetic automatically and is the safest representation during computation. Three nodata conventions, three behaviours 0 in uint16 collides with real zeros dark water, deep shadow becomes a real value in any average or ratio avoid -32768 in int16 outside any valid range safe on disk and in transit disastrous if it reaches arithmetic unmasked good for storage NaN in float32 cannot be a real value propagates automatically nan-aware reductions skip it by design best for computation Store with a sentinel, compute with NaN, and convert at the boundary — never in the middle.

The conversion at the boundary is the whole discipline. Read a sentinel-coded integer band, turn the sentinel into NaN as part of the float conversion, compute with NaN throughout, and turn NaN back into the sentinel only when packing for storage. Any code path that does arithmetic on the raw sentinel-coded integers is a bug waiting for a scene with enough nodata to matter. The detailed choice of sentinel is covered in choosing a nodata value that survives band math.


Letting the Libraries Do the Unpacking

Everything above can be done by hand, and it is worth understanding at that level. In day-to-day work, though, the safer route is to let a library apply the storage contract consistently, because hand-rolled unpacking is exactly where scale factors get applied twice.

import rioxarray

# mask_and_scale applies nodata -> NaN, then scale and offset, returning float
red = rioxarray.open_rasterio("B04.tif", mask_and_scale=True).squeeze("band")
nir = rioxarray.open_rasterio("B08.tif", mask_and_scale=True).squeeze("band")
ndvi = (nir - red) / (nir + red)

mask_and_scale=True reads the file’s own nodata, scale and offset and applies them in the right order, returning float values with NaN for missing data. It does exactly what read_physical above does, with one important caveat: it can only apply what the file declares. For products that document their scale outside the file, it will silently apply 1.0 — correctly according to the metadata and wrongly according to the product.

The robust pattern is therefore to decide, per product family, where the contract lives. If the file carries it, use mask_and_scale and nothing else. If it does not, write the scale and offset into your own copy of the file once, at ingest, so that everything downstream can rely on the file again. Mixing the two — some code applying the scale by hand, some reading it from the file — is how a pipeline ends up with a scale applied twice in one branch and not at all in another.

The same principle applies to xarray’s CF conventions when reading Zarr or NetCDF: the scale_factor, add_offset and _FillValue attributes are applied automatically on read and reversed on write. That is what makes the scaled-int16 encoding in writing an xarray datacube to Zarr invisible to the code that uses the cube.


Precision Budgets in Practice

It helps to be concrete about how much precision each quantity actually needs, because that decides whether a compact dtype loses anything.

Surface reflectance from a well-calibrated sensor has radiometric uncertainty of a few thousandths. Storing it at a resolution of 0.0001 — which scaled int16 does — keeps a decimal place more than the measurement supports. NDVI inherits that uncertainty and is similarly well served. Elevation from a global DEM has vertical errors of metres; storing it to a centimetre in int16 with a scale of 0.01 covers −327 to +327 m, which is too narrow for most terrain, so elevation typically uses int16 in whole metres or float32. Derived continuous products such as biomass or canopy height have uncertainties of tens of per cent and no natural bound, and float32 is the honest choice there.

The general test is to compare the quantisation step with the measurement’s own uncertainty. When the step is ten times finer than the uncertainty, the compact dtype loses nothing that was ever real. When it approaches the uncertainty, use a wider type. The storage-side payoff of getting this right compounds with compression, as the size comparisons in choosing COG compression: ZSTD vs DEFLATE show.


Parameter Reference

Parameter Type Default Usage note
src.dtypes tuple Storage dtype per band; never assume it
src.nodatavals tuple Per-band nodata; None means none declared, not “no nodata”
src.scales / src.offsets tuple 1.0 / 0.0 Often unset; check the catalog record when they are
astype("float32") call Before any arithmetic, always
mask_and_scale (rioxarray) bool False True applies nodata, scale and offset automatically on read
scale_factor (packing) float Choose so the valid range fits ±32,767 with headroom
_FillValue / nodata (write) int Outside the packed valid range; −32768 for int16 is conventional

Verification & Testing

import numpy as np

phys = read_physical("B04.tif", scale=0.0001, offset=-0.1)
valid = phys[np.isfinite(phys)]

assert valid.size > 0, "every pixel is nodata — check the nodata value"
assert -0.2 < np.percentile(valid, 1) and np.percentile(valid, 99) < 1.5, \
    "reflectance outside plausible range — scale or offset wrong"
assert phys.dtype == np.float32
print(f"median reflectance {np.median(valid):.4f}")

The plausibility range is the check that catches most scaling bugs. Surface reflectance lives between roughly 0 and 1, with a little overshoot from cloud and snow; values in the thousands mean the scale was never applied, values around 0.00001 mean it was applied twice, and a median shifted by exactly 0.1 means an offset was missed.

A second check worth running once per product type: read the same pixel through two independent paths — plain rasterio with manual scaling, and rioxarray with mask_and_scale=True — and confirm they agree. Disagreement means one path is reading metadata the other ignores.


Troubleshooting

Index values like 3.2 or −40 appear in an NDVI raster

Integer arithmetic overflowed or truncated. Convert to float32 before any band arithmetic; the full explanation is in avoiding integer overflow in index calculations.

Reflectance values are around 0.00001

The scale factor was applied twice — once by a reader with mask_and_scale and once by hand. Pick one place to apply it.

Indices are systematically shifted between two dates

One scene has an offset in its processing baseline and the other does not. Apply the offset per scene from its own metadata.

Masked pixels reappear after writing

NaN was cast straight to an integer dtype, which produces an arbitrary value. Replace NaN with the sentinel before casting.

Statistics include values near −3276.8

The sentinel was scaled along with valid data. Mask the raw sentinel before applying the scale.


Frequently Asked Questions

Q: Why do satellite products store reflectance as integers? Because reflectance needs about four significant digits and an int16 or uint16 holds that exactly with a scale factor, at half the size of float32. Across a petabyte archive that halving is worth the extra step of applying the scale on read.

Q: What is the safest dtype for doing arithmetic? float32 for almost everything. It represents scaled reflectance and indices with ample precision, never overflows on band sums, and propagates NaN for missing data. float64 doubles memory for precision no raster product needs.

Q: Why is my index full of strange large numbers? Integer overflow. Adding two uint16 bands whose sum exceeds 65,535 wraps around to a small number, and the resulting ratio is wrong without any error. Convert to float32 before any arithmetic.

Q: Is float16 ever a good idea? For storage of display renditions or model inputs where three significant digits suffice, occasionally. It saves half again over int16-with-scale’s equivalent float, but support across GIS tools is patchy and it cannot hold values much above 65,000, so scaled int16 is the safer compact choice.


Deep-Dive Articles