A hazard raster's nodata is read as zero
On this page
Problem statement
The mean flood depth comes out at a quarter of a metre across a floodplain whose mapped water averages a metre deep. A DEM has a lake at −32,768 m. A warped hazard grid has lost a quarter of its flooded cells. All three are the same bug: a nodata value that something in the chain treated as a number.
It bites hardest on hazard data because hazard rasters are mostly nodata. The JRC European flood maps store only the wet cells — in a window south of Valencia, 76% of the file is nodata, and the sentinel is −3.4 × 10³⁸, the most negative float32 there is. One unguarded mean() on that array returns -inf.
Quick answer
Read masked, and decide explicitly what the mask means for this product:
import numpy as np, rasterio
from rasterio.windows import from_bounds
from rasterio.warp import transform_bounds
BBOX = [-0.46, 39.25, -0.33, 39.45]
with rasterio.open("flood/floodmap_EFAS_RP100_C.tif") as src:
print(f"dtype {src.dtypes[0]}, nodata {src.nodata:.3g}")
win = from_bounds(*transform_bounds(4326, src.crs, *BBOX), src.transform)
raw = src.read(1, window=win) # nodata is just a number here
masked = src.read(1, window=win, masked=True) # nodata is masked
print(f"nodata cells: {masked.mask.sum():,} of {masked.size:,} ({masked.mask.mean():.1%})")
print(f"raw min {raw.min():.3g} mean {raw.mean():.3g}")
print(f"masked min {masked.min():.2f} mean {masked.mean():.3f} m over the wet cells")
dtype float32, nodata -3.4e+38
nodata cells: 24,791 of 32,665 (75.9%)
raw min -3.4e+38 mean -inf
masked min 0.10 mean 1.068 m over the wet cells
masked=True costs nothing and turns a silent wrong answer into a correct one. What it cannot tell you is whether those masked cells are dry land or unsurveyed ground — that is in the product documentation, not the file.
Read the nodata, mask on read, decide what gaps mean
1. Look at src.nodata before reading anything
If it is None, the file declares nothing and you must find the convention yourself — check the minimum and the histogram for a suspicious spike.
2. Read masked, or convert to NaN immediately
read(1, masked=True) for float data, then .filled(np.nan) if you prefer arrays. For integer data there is no NaN, so keep the mask.
3. Decide what the gap means for this product
Depth grids usually mean "dry", so zero is right. Elevation, intensity and susceptibility usually mean "unknown", so zero is a lie. Land cover means "outside the map".
4. Never let the sentinel through a resampling
Bilinear or cubic resampling averages neighbours. One cell of −3.4 × 10³⁸ destroys every cell it touches, which is how a warp loses flooded area around every edge.
5. Use NaN-aware statistics
np.nanmean, np.nanpercentile, and a masked array where integers are involved. A plain mean() over a zero-filled hazard grid answers a different question — usually "per unit of study area" rather than "where it is hazardous".
6. Set nodata on everything you write
A derived raster with no declared nodata pushes this whole problem onto the next person, and they will not have your notebook.
What a warp loses and a mean misstates
The data used on this page
The European Flood Awareness System's pan-European flood hazard maps from the JRC (data catalogue): water depth in metres on a 100 m grid for the 1-in-100-year flood, one zipped GeoTIFF per return period, saved under flood/.
import os
import zipfile
import requests
EFAS = "https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/FLOODS/EuropeanMaps/floodMap_RP{}.zip"
os.makedirs("flood", exist_ok=True)
for rp in ("100",):
tif = f"floodmap_EFAS_RP{rp}_C.tif"
if not os.path.exists(f"flood/{tif}"): # about 180 MB per return period
with requests.get(EFAS.format(rp), stream=True, timeout=600) as r:
r.raise_for_status()
with open("efas.zip", "wb") as f:
for chunk in r.iter_content(1 << 20):
f.write(chunk)
zipfile.ZipFile("efas.zip").extract(tif, "flood")
os.remove("efas.zip")
print(sorted(os.listdir("flood")))
['floodmap_EFAS_RP100_C.tif']
Example 1 — what the warp loses
from rasterio.enums import Resampling
from rasterio.vrt import WarpedVRT
with rasterio.open("flood/floodmap_EFAS_RP100_C.tif") as src:
win = from_bounds(*transform_bounds(4326, src.crs, *BBOX), src.transform)
grid = dict(crs=src.crs, transform=src.window_transform(win),
width=int(win.width), height=int(win.height), resampling=Resampling.bilinear)
with WarpedVRT(src, **grid) as vrt: # source nodata respected
good = vrt.read(1, masked=True).filled(np.nan)
with WarpedVRT(src, src_nodata=None, **grid) as vrt: # sentinel treated as data
bad = vrt.read(1)
print(f"nodata respected: {int(np.nansum(good > 0)):,} wet cells, "
f"mean depth {np.nanmean(good[good > 0]):.3f} m, max {np.nanmax(good):.2f} m")
print(f"nodata as data: {int((bad > 0).sum()):,} wet cells, "
f"min {bad.min():.3g}, max {bad.max():.2f} m")
print(f"cells dragged to nonsense by the kernel: {int((bad < -1000).sum()):,}")
nodata respected: 7,736 wet cells, mean depth 1.059 m, max 12.60 m
nodata as data: 5,535 wet cells, min -3.4e+38, max 12.60 m
cells dragged to nonsense by the kernel: 26,757
Nearly 30% of the flooded cells vanish, all of them around the edges of the flood extent — which is exactly where exposure sits.
Example 2 — the mean that answers a different question
depth = good
zero_filled = np.where(np.isfinite(depth), depth, 0.0)
wet = depth > 0
print(f"mean over the whole window, nodata as 0 : {zero_filled.mean():.4f} m")
print(f"mean over wet cells only : {np.nanmean(depth[wet]):.3f} m")
print(f"wet area {wet.sum() * 0.01:,.1f} km² of a {depth.size * 0.01:,.1f} km² window")
print(f"95th percentile of depth where wet : {np.nanpercentile(depth[wet], 95):.2f} m")
mean over the whole window, nodata as 0 : 0.2537 m
mean over wet cells only : 1.059 m
wet area 77.4 km² of a 322.9 km² window
95th percentile of depth where wet : 2.32 m
Both means are arithmetically correct and they answer different questions. The first is a depth averaged over dry land, which is not a quantity anybody wants; quote it as "mean flood depth" and every comparison with another study is broken.
Example 3 — write it back so the next reader is safe
profile = dict(driver="GTiff", dtype="float32", count=1, crs=grid["crs"],
transform=grid["transform"], width=grid["width"], height=grid["height"],
nodata=np.nan, compress="deflate", tiled=True)
with rasterio.open("depth_rp100_window.tif", "w", **profile) as dst:
dst.write(np.where(wet, depth, np.nan).astype("float32"), 1)
dst.update_tags(nodata_means="dry or outside the modelled floodplain",
source="JRC EFAS flood hazard map, RP100")
with rasterio.open("depth_rp100_window.tif") as src:
back = src.read(1, masked=True)
print(f"declared nodata {src.nodata}, masked cells {back.mask.sum():,}")
print(f"round-trip mean over wet cells {back.mean():.3f} m")
print(f"tag: {src.tags()['nodata_means']}")
declared nodata nan, masked cells 24,556
round-trip mean over wet cells 1.059 m
tag: dry or outside the modelled floodplain
Why hazard rasters are mostly nodata
Why hazard rasters are mostly nodata
A flood map covers a continent and floods a river corridor. A burn severity map covers a scene and burns one perimeter. Storing only the affected cells keeps these files usable, so the fraction of nodata is high by design — and the fraction of your array that a mistake corrupts is correspondingly high.
Why the sentinel is so destructive in float32
-3.4e38 is the most negative representable float32. Averaged with anything it stays enormous; summed into an accumulator it overflows to -inf; compared with > 0 it is simply False, so a wet/dry test still works and the bug survives until someone takes a mean. That is why the failure often appears far downstream of the read.
Why zero is sometimes the right substitution
For a depth grid, "not mapped" and "dry" are the same thing for most purposes, and filling with zero is correct and convenient. For a DEM, an intensity surface or a susceptibility index, the missing cells are unknown and filling with zero invents a valley, a quiet spot or a safe area. The rule is not "never fill with zero" but "know which of the two products you have, from the documentation".
Integer rasters, missing tags and alpha bands
- Integer rasters have no NaN. Keep the masked array or carry the mask separately;
astype(float)first if you need NaN. nodata=Nonein the profile means "not declared", not "no nodata cells". Look at the data.- Multiple sentinels exist in some products (−9999 and 0 both meaning missing); the histogram shows them.
- Alpha and mask bands: a GeoTIFF can carry a per-pixel mask instead of a sentinel —
src.read_masks(1)reads it. - Compression does not care, but a nodata value that is not exactly representable in the dtype can round and stop matching.
- Zonal statistics libraries usually respect nodata; check, because the default varies.
Internal links
- Rasterio returns the wrong values: NoData, scaling and dtype fixes — the general treatment
- How to extract raster values at point locations — where a sentinel silently enters a table
- Flood hazard maps explained — why these files store only wet cells
- How to combine hazard, exposure and vulnerability into a risk score — the calculation this breaks
- Raster resampling explained: nearest, bilinear and when each is wrong — what a kernel does to a mask
- The raster data model explained — bands, dtype, nodata and the transform
FAQ
Why is my mean flood depth so low?
Because nodata cells were counted as zero depth. Average over the wet cells, or say the average is over the whole area.
What is -3.4e38 in my raster?
The float32 minimum, used as a nodata sentinel. It is not a measurement.
Can I just fill nodata with zero?
For depth or damage grids, usually yes. For elevation, intensity or index surfaces, no — zero is a value there.
Why did reprojection lose flooded cells?
Bilinear resampling averaged the sentinel into real cells. Declare the source nodata, or resample with nearest.
How do I read a raster safely?
src.read(1, masked=True), then decide what the mask means before filling it.
Should I set nodata when writing?
Always. An undeclared nodata value is the same bug, passed on.