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.
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.
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.
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.
Internal links
- Hazard, exposure and vulnerability explained β where the probability fits in
- How to fit return periods to annual maxima in Python β the full workflow
- A 100-year flood happened twice in ten years β the fix guide
- Flood hazard maps explained β the maps these levels feed
- Height above nearest drainage explained β a quick flood proxy without frequency
- Margins of error explained β uncertainty in published estimates generally
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.