Return periods and annual exceedance probability explained

On this page

Problem statement

"A 100-year flood" is the most misread phrase in hazard mapping. It does not mean a flood that happens every hundred years, and it does not mean that having had one this year buys a century of safety. It means a flow or a level with a 1% chance of being exceeded in any given year β€” an annual exceedance probability, estimated by fitting a distribution to a record that is usually much shorter than the return period it is quoted for.

On the Delaware River at Trenton, 121 years of annual peak flows give a 1-in-100-year peak of about 280,000 cubic feet per second. Over a 30-year mortgage, the chance of at least one such year is 26%. Over the 121 years of record the expected number of such years is 1.2, and the gauge recorded two: 1903 and 1955.

Quick answer

Fit a distribution to annual maxima and read the return level off it; convert return period to annual probability with 1/T:

import io, urllib.request
import numpy as np, pandas as pd
from scipy.stats import genextreme

url = ("https://nwis.waterdata.usgs.gov/nwis/peak?site_no=01463500"
       "&agency_cd=USGS&format=rdb")                     # Delaware River at Trenton, NJ
req = urllib.request.Request(url, headers={"User-Agent": "example/1.0"})
text = urllib.request.urlopen(req, timeout=90).read().decode()
rows = [ln for ln in text.splitlines() if not ln.startswith("#")]
peaks = pd.read_csv(io.StringIO("\n".join(rows)), sep="\t").iloc[1:]
peaks["q"] = pd.to_numeric(peaks.peak_va, errors="coerce")
peaks["year"] = pd.to_datetime(peaks.peak_dt, errors="coerce", format="mixed").dt.year
peaks = peaks.dropna(subset=["q", "year"])

q = peaks.q.values
# Give the fit a starting guess: scipy's defaults can converge to a nonsense tail.
shape, loc, scale = genextreme.fit(q, 0.1, loc=np.median(q), scale=q.std())
def return_level(T):
    return genextreme.ppf(1 - 1 / T, shape, loc=loc, scale=scale)

print(f"{len(peaks)} annual peaks, {int(peaks.year.min())}–{int(peaks.year.max())}")
for T in (2, 10, 50, 100, 500):
    print(f"  1-in-{T:<4} year peak: {return_level(T):9,.0f} cfs  "
          f"(annual chance {100 / T:5.1f}%)")
121 annual peaks, 1897–2023
  1-in-2    year peak:    92,855 cfs  (annual chance  50.0%)
  1-in-10   year peak:   163,347 cfs  (annual chance  10.0%)
  1-in-50   year peak:   240,980 cfs  (annual chance   2.0%)
  1-in-100  year peak:   279,127 cfs  (annual chance   1.0%)
  1-in-500  year peak:   381,646 cfs  (annual chance   0.2%)

The return period is the reciprocal of the annual probability, nothing more. Everything else follows from that.

Return level against return period for the Delaware River at Trenton, from a GEV fit to 121 annual peaks. Delaware River at Trenton, 1897–2023100k200k300k400k1-in-21-in-101-in-501-in-1001-in-500largest peak on record: 329,000 cfs (1955)record lengthRight of the amber line the curve is extrapolation, and cfs is cubic feet per second spatialworkflow.io
The fitted curve beyond the record length is extrapolation, and the points thin out exactly where the decisions are made.

Annual maxima, a fitted distribution, return levels

1. Assemble annual maxima

One value per year β€” the largest peak flow, the highest water level, the deepest snow. Partial-duration series (all peaks over a threshold) are an alternative with different mathematics.

2. Fit a distribution

Generalised extreme value, Gumbel, or log-Pearson III, which is the US federal standard for flood frequency. The choice changes the tail, and the tail is the answer.

3. Read return levels off the fit

The T-year level is the quantile at probability 1 βˆ’ 1/T. It is an estimate with uncertainty that grows fast beyond the record length.

4. Convert to the probability people ask about

The chance of at least one exceedance in n years is 1 βˆ’ (1 βˆ’ 1/T)ⁿ. For T = 100 and n = 30 that is 26%.

5. Check for trend and for independence

Extreme-value fitting assumes the years are independent draws from one distribution. Climate change, dam construction and land-use change all break that.

6. Quote the uncertainty

Report a confidence interval on the return level, or at least the record length. "279,000 cfs" and "279,000 cfs from 121 years of record, GEV fit" are different statements.

Probability of at least one 1-in-100-year event over periods of 1 to 70 years. chance of at least one 1-in-100 eventin 1 year1.0%in 30 years (a mortgage)26.0%in 70 years (a lifetime)50.5%1-in-10 event, 30 years95.8%050%100%1 βˆ’ (1 βˆ’ 1/T)ⁿ: the annual chance is small, the lifetime chance is not spatialworkflow.io
The annual chance is small; over the life of a building it is not.

The question people actually ask about floods

Example 1 β€” the question people actually ask

for T in (10, 100, 500):
    for n in (1, 30, 70):
        p = 1 - (1 - 1 / T) ** n
        print(f"1-in-{T:<4} event, at least once in {n:>3} years: {p:5.1%}")
1-in-10   event, at least once in   1 years: 10.0%
1-in-10   event, at least once in  30 years: 95.8%
1-in-10   event, at least once in  70 years: 99.9%
1-in-100  event, at least once in   1 years:  1.0%
1-in-100  event, at least once in  30 years: 26.0%
1-in-100  event, at least once in  70 years: 50.5%
1-in-500  event, at least once in   1 years:  0.2%
1-in-500  event, at least once in  30 years:  5.8%
1-in-500  event, at least once in  70 years: 13.1%

Example 2 β€” how often the record exceeded the estimate

level_100 = return_level(100)
over = peaks[peaks.q >= level_100]
print(f"1-in-100 level: {level_100:,.0f} cfs")
print(f"years above it: {len(over)} of {len(peaks)} "
      f"(expected {len(peaks) / 100:.1f})")
print("  " + ", ".join(f"{int(y)}: {q:,.0f}" for y, q in zip(over.year, over.q)))
recent = peaks[peaks.year >= 1975]
print(f"since 1975: {(recent.q >= return_level(10)).sum()} years above the 1-in-10 level "
      f"in {len(recent)} years (expected {len(recent) / 10:.1f})")
1-in-100 level: 279,127 cfs
years above it: 2 of 121 (expected 1.2)
  1903: 295,000, 1955: 329,000
since 1975: 5 years above the 1-in-10 level in 50 years (expected 5.0)

Example 3 β€” the distribution choice, and the fit that blows up

from scipy.stats import gumbel_r, pearson3

g_loc, g_scale = gumbel_r.fit(q)
lp_skew, lp_loc, lp_scale = pearson3.fit(np.log10(q))       # log-Pearson III, Bulletin 17C
bad_shape, bad_loc, bad_scale = genextreme.fit(q)            # no starting guess
print(f"GEV (with a starting guess)  1-in-100: {return_level(100):12,.0f} cfs")
print(f"log-Pearson III              1-in-100: {10 ** pearson3.ppf(0.99, lp_skew, lp_loc, lp_scale):12,.0f} cfs")
print(f"Gumbel                       1-in-100: {gumbel_r.ppf(0.99, g_loc, g_scale):12,.0f} cfs")
print(f"GEV, scipy defaults          1-in-100: {genextreme.ppf(0.99, bad_shape, bad_loc, bad_scale):12,.0f} cfs")
print(f"largest peak on record:                {q.max():12,.0f} cfs")
GEV (with a starting guess)  1-in-100:      279,127 cfs
log-Pearson III              1-in-100:      272,413 cfs
Gumbel                       1-in-100:      236,587 cfs
GEV, scipy defaults          1-in-100: 2,679,230,381,050 cfs
largest peak on record:                     329,000 cfs

Three defensible distributions differ by 40,000 cfs β€” and the fourth line is why every fit needs checking against the record. genextreme.fit with scipy's default starting values converged on a shape parameter of βˆ’5.3, which puts the 1-in-100 flood at trillions of cubic feet per second.

Example 4 β€” how much the record length matters

rng = np.random.default_rng(0)
for n in (20, 40, 80, len(peaks)):
    ests = []
    for _ in range(200):
        sample = rng.choice(peaks.q.values, size=n, replace=True)
        sh, lo, sc = genextreme.fit(sample, 0.1, loc=np.median(sample), scale=sample.std())
        ests.append(genextreme.ppf(0.99, sh, loc=lo, scale=sc))
    ests = np.array(ests)
    print(f"{n:3d} years of record: 1-in-100 estimate "
          f"{np.median(ests):8,.0f} cfs  (10th–90th: {np.percentile(ests, 10):,.0f}–"
          f"{np.percentile(ests, 90):,.0f})")
 20 years of record: 1-in-100 estimate  275,073 cfs  (10th–90th: 166,262–465,506)
 40 years of record: 1-in-100 estimate  273,799 cfs  (10th–90th: 203,868–375,292)
 80 years of record: 1-in-100 estimate  281,881 cfs  (10th–90th: 235,423–351,904)
121 years of record: 1-in-100 estimate  280,178 cfs  (10th–90th: 238,702–324,393)

From 20 years of record, the middle 80% of resampled estimates runs from 166,000 to 466,000 cfs β€” a factor of 2.8. With the full 121 years it is 239,000 to 324,000. Shorter records do not give smaller answers; they give vaguer ones, and that uncertainty is rarely printed on the map.

Why the phrase misleads, and where the assumptions live

Why the phrase misleads

"100-year flood" sounds like a schedule. It is a probability statement about each year separately, and the years are independent under the model: two such floods in successive years is unlikely but not contradictory, in the same way as two sixes in a row.

Why the tail is where the assumptions live

Inside the record, the fitted curve is constrained by data. Beyond it, the shape parameter of the distribution decides everything, and that parameter is estimated from the same short record. A 1-in-500-year level from 100 years of data is an extrapolation five times past the evidence.

Why non-stationarity breaks the whole frame

The method assumes every year is a draw from the same distribution. If the catchment has been urbanised, or the climate has shifted the distribution of extremes, then "1-in-100" is not a fixed level: it moves, and a map drawn with the old level will be wrong in a direction that is hard to predict.

Range of 1-in-100-year estimates from resampled records of 20, 40, 80 and 121 years. what the record length buysrecord length1-in-100 estimate, 10th–90thspread20 years166,000 – 466,000 cfs2.8x40 years204,000 – 375,000 cfs1.8x80 years235,000 – 352,000 cfs1.5x121 years (all)239,000 – 324,000 cfs1.4xthe median estimate is about 280,000 cfs at every record length200 resamples of the Delaware at Trenton; doubling the record barely halves the spread spatialworkflow.io
The same river, the same method, four record lengths: shorter records do not give smaller answers, they give vaguer ones.

Partial-duration series, regional analysis and paired events

  • Partial-duration series use all peaks over a threshold and give slightly different return periods for small T.
  • Log-Pearson III is the standard for US flood frequency (Bulletin 17C); GEV is common elsewhere.
  • Regional frequency analysis pools nearby gauges to stabilise the tail.
  • Historical floods before the gauge record can be included as censored observations.
  • Return period of a pair of correlated hazards β€” rain and storm surge β€” is not the product of the two.
  • "Design event" is the level a structure is built for; it is a decision, not a property of the river.

FAQ

What does a 100-year flood mean?

A flood with a 1% chance of being exceeded in any given year β€” not one that happens every hundred years.

Can two 100-year floods happen in consecutive years?

Yes. Each year is an independent 1% chance under the model.

What is annual exceedance probability?

The chance of exceeding a level in a year: 1/T for a T-year return period.

How long a record do I need?

Longer than the return period you want, ideally. From 20 years, a 1-in-100 estimate can be out by a factor of two.

Which distribution should I fit?

GEV or Gumbel generally; log-Pearson III for US flood frequency work. State the choice.

Does climate change break return periods?

It breaks the assumption that every year is drawn from the same distribution, which is what the method rests on.