Flood hazard maps explained: depth, extent and which flood they describe

On this page

Problem statement

A flood hazard map is usually read as "the area that floods". It is not. It is the area that a particular model, driven by a particular scenario, says would be under water — with a depth at every point, for one return period, from one flood source, under the land surface and defences as they were when the model ran. Change any of those and the blue area moves.

The Valencia flood of October 2024 makes the point. The European Commission's continental flood hazard map puts 78.7 km² — 24% of a window south of the city — under water in a 1-in-100-year fluvial flood, with depths up to 13.6 m. Sentinel-1 mapped 31.6 km² of new open water three days after the real event. Just under half of that observed water lies inside the modelled zone, and three quarters of the modelled zone was not wet. Neither map is wrong: they answer different questions.

Quick answer

Read the depth grid, not just the outline, and keep the scenario attached to it:

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]                     # south of Valencia
path = "flood/floodmap_EFAS_RP100_C.tif"                # JRC EFAS, 100 m, depth in metres
with rasterio.open(path) as src:
    win = from_bounds(*transform_bounds(4326, src.crs, *bbox), src.transform)
    depth = src.read(1, window=win, masked=True).filled(np.nan)
    print("CRS", src.crs.to_epsg(), "| pixel", src.res[0], "m | nodata", src.nodata)

flooded = depth > 0
print(f"flooded area {flooded.sum() * 0.01:.1f} km² ({flooded.mean():.1%} of the window)")
print("depth percentiles (m):", np.round(np.nanpercentile(depth[flooded], [50, 90, 99]), 2).tolist())
CRS None | pixel 100.0 m | nodata -3.4028230607370965e+38
flooded area 78.7 km² (24.1% of the window)
depth percentiles (m): [0.82, 2.01, 4.56]

to_epsg() returning None is worth noting: the file carries an ESRI-style WKT for ETRS89-LAEA rather than an EPSG code, which is why every read here reprojects the bounding box through the file's own CRS object rather than assuming a code.

Depth is what the map is for. An extent polygon alone cannot tell a 10 cm nuisance flood from two metres in a living room, and the damage functions that turn hazard into loss take depth as their input.

Map of flood depth for the 100-year return period south of Valencia: flooded pixels in blues, deepest in dark blue, spreading along the coastal plain; a large uniform patch in the middle is the Albufera lagoon. Map of flood depth for the 100-year return period south of Valencia: flooded pixels in blues, deepest in dark blue, spreading along the coastal plain; a large uniform patch in the middle is the Albufera lagoon.
The 100-year depth grid from the code above: 78.7 km² flooded, a quarter of the window. The speckle of shallow and deep pixels along the coastal plain is what an outline would have hidden. The large uniform patch is the Albufera lagoon, which the model fills to a single level.
Flooded area and depth for the 10, 100 and 500 year return-period maps over one window south of Valencia. JRC EFAS flood maps, same window1-in-10 year62.8 km², median 0.36 m1-in-100 year78.7 km², median 0.82 m1-in-500 year85.8 km², median 1.11 mover 1 m deep, 1-in-50055.7 km²045 km²90 km²The extent grows by a third between 1-in-10 and 1-in-500; the deep area grows 3.5× spatialworkflow.io
Three scenarios of the same place: the area grows with the return period and so does the depth.

Which flood, which return period, defences, depth

1. Find out which flood the map is of

Fluvial (river), pluvial (rainfall on the spot), coastal (surge), or groundwater. Most continental maps are fluvial only, so they show nothing in a town flooded by its own drains.

2. Find the return period

A 1-in-10 map and a 1-in-500 map are both correct and look completely different. Return periods sets out what the number means.

3. Check whether defences are included

"Undefended" maps show the flood a river would cause with no levees or walls. Defended maps include them, and therefore need to be redrawn whenever a defence is built or fails.

4. Keep the depth, not just the outline

Depth drives damage. Keep the grid; derive the outline from it when a map needs one.

5. Check the terrain model and resolution

A 100 m continental map cannot see a 20 m-wide flood defence or a raised road. Local maps at 2–5 m from lidar can, which is why they disagree with continental ones in detail.

6. Do not read a hazard map as a forecast

It describes a scenario with an annual probability, not the next flood. The next flood will differ in source, duration and shape.

What a flood hazard map has to state, and what each choice changes. six things a flood map must saypropertyoptionswhat it changessourcefluvial / pluvial / coastalwhich places flood at allreturn period10, 100, 500 yearsextent and depthdefencesdefended / undefendedwhole townsterrain100 m vs 2 m lidarwalls, roads, channelsproductdepth grid or extentwhether damage can be computedvintagewhen it was modelleddefences and land surface sinceA map missing any of these cannot be compared with another map spatialworkflow.io
Six properties; a map missing any of them cannot be compared with another map.

Modelled floods against an observed one

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-10, 1-in-100, 1-in-500-year floods, 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 ("010", "100", "500"):
    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_RP010_C.tif', 'floodmap_EFAS_RP100_C.tif', 'floodmap_EFAS_RP500_C.tif']

sar_io.py, the helper module from the reading guide.

%%writefile sar_io.py
"""Find, read and mosaic Sentinel-1 RTC scenes from Microsoft Planetary Computer."""
from collections import defaultdict

import numpy as np
import planetary_computer
import pystac_client
import rasterio
from rasterio.enums import Resampling
from rasterio.transform import from_origin
from rasterio.vrt import WarpedVRT
from rasterio.warp import transform_bounds

STAC = "https://planetarycomputer.microsoft.com/api/stac/v1"


def search(bbox, dates, orbit=None, direction=None, collection="sentinel-1-rtc"):
    """Items over bbox (lon/lat) in a date range, filtered by relative orbit and pass direction."""
    cat = pystac_client.Client.open(STAC, modifier=planetary_computer.sign_inplace)
    items = cat.search(collections=[collection], bbox=bbox, datetime=dates).item_collection()
    keep = [i for i in items
            if (orbit is None or i.properties["sat:relative_orbit"] == orbit)
            and (direction is None or i.properties["sat:orbit_state"] == direction)]
    return sorted(keep, key=lambda i: i.datetime)


def passes(items):
    """Group frames into passes: same day, same relative orbit."""
    groups = defaultdict(list)
    for i in items:
        groups[(i.datetime.date(), i.properties["sat:relative_orbit"])].append(i)
    return dict(sorted(groups.items()))


def grid(bbox, crs, res=10):
    """A fixed target grid: every pass is read onto this, so arrays line up."""
    left, bottom, right, top = transform_bounds(4326, crs, *bbox)
    width, height = int((right - left) // res), int((top - bottom) // res)
    return {"crs": crs, "transform": from_origin(left, top, res, res),
            "width": width, "height": height}


def read_pass(frames, band, g):
    """Mosaic one pass's frames for one band onto grid g. Linear power; NaN where no data."""
    out = np.full((g["height"], g["width"]), np.nan, dtype="float32")
    for item in frames:
        with rasterio.open(item.assets[band].href) as src, WarpedVRT(
                src, crs=g["crs"], transform=g["transform"], width=g["width"],
                height=g["height"], resampling=Resampling.bilinear, nodata=np.nan) as vrt:
            a = vrt.read(1)
        a[a <= 0] = np.nan                       # -32768 nodata and zero-padding
        fill = np.isnan(out) & np.isfinite(a)
        out[fill] = a[fill]
    return out


def to_db(linear):
    return 10 * np.log10(linear)
Writing sar_io.py

sar_filters.py, the helper module from the speckle guide.

%%writefile sar_filters.py
"""The Lee speckle filter from the speckle-filter guide."""
import numpy as np
from scipy.ndimage import uniform_filter

def lee_filter(img, size=5, enl=4.4):
    """Lee filter on linear intensity. Smooths homogeneous areas, keeps edges.
    enl: equivalent number of looks of the input (4.4 for Sentinel-1 IW GRD/RTC)."""
    ok = np.isfinite(img)
    x = np.where(ok, img, 0.0)
    n = uniform_filter(ok.astype("float64"), size)
    mean = uniform_filter(x, size) / np.maximum(n, 1e-9)
    sq = uniform_filter(x * x, size) / np.maximum(n, 1e-9)
    var = np.maximum(sq - mean ** 2, 0)
    noise = (mean ** 2) / enl                    # variance speckle alone would produce
    weight = np.clip((var - noise) / np.maximum(var, 1e-12), 0, 1)
    out = mean + weight * (x - mean)
    return np.where(ok, out, np.nan)
Writing sar_filters.py

Example 1 — three return periods, one window

for rp in ("010", "100", "500"):
    with rasterio.open(f"flood/floodmap_EFAS_RP{rp}_C.tif") as src:
        win = from_bounds(*transform_bounds(4326, src.crs, *bbox), src.transform)
        d = src.read(1, window=win, masked=True).filled(np.nan)
    wet = d > 0
    print(f"1-in-{int(rp):<3} year: {wet.sum() * 0.01:5.1f} km² wet, "
          f"median depth {np.nanmedian(d[wet]):4.2f} m, "
          f"deepest {np.nanmax(d):5.1f} m, "
          f"area over 1 m {np.sum(d > 1) * 0.01:5.1f} km²")
1-in-10  year:  62.8 km² wet, median depth 0.36 m, deepest   9.4 m, area over 1 m  15.9 km²
1-in-100 year:  78.7 km² wet, median depth 0.82 m, deepest  13.6 m, area over 1 m  27.6 km²
1-in-500 year:  85.8 km² wet, median depth 1.11 m, deepest  13.9 m, area over 1 m  55.7 km²

Example 2 — the modelled flood against the observed one

from rasterio.enums import Resampling
from rasterio.vrt import WarpedVRT
from sar_io import search, passes, grid, read_pass, to_db
from sar_filters import lee_filter

g = grid(bbox, "EPSG:25830")                            # the SAR grid, 10 m
P = passes(search(bbox, "2024-10-20/2024-11-01", orbit=110))
pre, post = (to_db(lee_filter(read_pass(P[k], "vv", g))) for k in P)
observed = (post < -18) & (pre >= -18) & (post - pre < -3)

with rasterio.open("flood/floodmap_EFAS_RP100_C.tif") as src, WarpedVRT(
        src, crs=g["crs"], transform=g["transform"], width=g["width"], height=g["height"],
        resampling=Resampling.bilinear) as vrt:
    rp100 = vrt.read(1, masked=True).filled(np.nan) > 0

print(f"observed new water (Sentinel-1, 1 Nov 2024): {observed.sum() / 1e4:5.1f} km²")
print(f"modelled 1-in-100 fluvial flood:             {rp100.sum() / 1e4:5.1f} km²")
print(f"  observed water inside the modelled zone:   {np.mean(rp100[observed]):5.1%}")
print(f"  modelled zone that was observed wet:       {np.mean(observed[rp100]):5.1%}")
observed new water (Sentinel-1, 1 Nov 2024):  31.6 km²
modelled 1-in-100 fluvial flood:              61.4 km²
  observed water inside the modelled zone:   46.9%
  modelled zone that was observed wet:       24.2%

Example 3 — what the depth grid adds over the outline

with rasterio.open("flood/floodmap_EFAS_RP100_C.tif") as src:
    win = from_bounds(*transform_bounds(4326, src.crs, *bbox), src.transform)
    d = src.read(1, window=win, masked=True).filled(np.nan)
wet = d > 0
bands = [(0, 0.25), (0.25, 0.5), (0.5, 1), (1, 2), (2, 100)]
for lo, hi in bands:
    m = (d > lo) & (d <= hi)
    print(f"  {lo:4.2f}–{hi:<5.2f} m {m.sum() * 0.01:5.1f} km²  ({m.sum() / wet.sum():5.1%} of the flooded area)")
  0.00–0.25  m   7.2 km²  ( 9.1% of the flooded area)
  0.25–0.50  m   7.9 km²  (10.0% of the flooded area)
  0.50–1.00  m  36.1 km²  (45.8% of the flooded area)
  1.00–2.00  m  19.7 km²  (25.0% of the flooded area)
  2.00–100.00 m   8.0 km²  (10.1% of the flooded area)

Half a metre is the difference between wet carpets and a write-off, and it is invisible on an extent map.

Why modelled and observed floods differ

Why modelled and observed floods differ

The hazard map is fluvial: it routes a 1-in-100-year river flow down the channel network over a 100 m terrain model, with no rain falling on the land in between. The October 2024 event was a convective storm that dropped extreme rain across the catchment and the plain at once, so water arrived where no river reached it, and the radar also caught rice paddies that had been flooded deliberately. Neither map is wrong; they are answers to different questions.

Why depth grids beat extent polygons

An extent is a threshold applied to a depth grid, usually at zero. Every user then applies their own threshold anyway — a road floods at 20 cm, a house at 60 — and cannot, from the polygon. Publishing depth lets each user set their own limit and makes the damage calculation possible at all.

Why resolution decides the detail

At 100 m, a pixel is a hectare. Embankments, raised roads, flood walls and small channels are all thinner than that, so the model cannot hold water back where they run. That is why continental maps are for screening and local models for decisions on individual properties.

The modelled 1-in-100 fluvial flood zone compared with the flood Sentinel-1 observed on 1 November 2024. modelled 1-in-10061.4 km² on the SAR gridobserved by radar31.6 km² of new wateroverlap47% of observed insideA fluvial scenario and a convective storm: the disagreement is the information spatialworkflow.io
Model and observation overlap and disagree, because they answer different questions.

Climate scenarios, pluvial floods and nodata

  • Climate scenarios shift the return period; a 1-in-100 map for 2050 is a different map.
  • Pluvial flooding in cities is often the bigger risk and is missing from fluvial maps.
  • Defence failure scenarios exist separately from defended and undefended maps.
  • Depth is above ground, not above sea level; do not confuse it with a water-surface elevation.
  • Nodata is not zero. A nodata pixel is unmodelled, not dry — see the nodata fix.
  • Insurance maps may use different return periods and definitions again.

FAQ

What does a flood hazard map show?

The depth and extent of a modelled flood of a stated return period, from a stated source, under stated defences.

Why does the map not match the flood we had?

Different question: the map is one scenario from one source; the real flood had its own rain, duration and route.

Is a 1-in-100 flood map the worst case?

No. 1-in-200, 1-in-500 and 1-in-1000 maps exist and are larger.

Extent or depth?

Depth. Extent is a threshold on depth, and every user needs a different threshold.

Does the map include flood defences?

Only if it says so. "Undefended" maps deliberately ignore them.

Can I use a continental map for a single property?

For screening only. Property decisions need local models at metre resolution.