The 1-in-100-year flood happened twice in ten years
On this page
Problem statement
A flood is mapped as the 1-in-100-year event, and then the river reaches that level twice in a decade. Someone concludes the map is wrong, or that the climate has changed, or that return periods are meaningless. Usually none of those is the reason.
"1-in-100" is a probability, not a schedule: a 1% chance in every year, independent of what happened last year. Over a thirty-year mortgage there is a 26% chance of at least one. And the number itself is an estimate from a short record with a wide interval β on 121 years of peaks at Trenton on the Delaware, the 1-in-100 discharge is somewhere between the 1-in-47 and the 1-in-218 level at ordinary confidence.
Quick answer
The arithmetic that answers most of the complaints is one line:
def at_least_one(annual_p, years):
return 1 - (1 - annual_p) ** years
for years in (1, 10, 30, 50, 100):
print(f"at least one 1-in-100 event in {years:3} years: {at_least_one(0.01, years):6.1%}")
p10 = 0.1 # a 1-in-10 event
two_or_more = 1 - (1 - p10) ** 10 - 10 * p10 * (1 - p10) ** 9
print(f"two or more 1-in-10 events in a decade: {two_or_more:6.1%}")
print(f"two or more 1-in-100 events in a decade: "
f"{1 - 0.99 ** 10 - 10 * 0.01 * 0.99 ** 9:6.2%}")
at least one 1-in-100 event in 1 years: 1.0%
at least one 1-in-100 event in 10 years: 9.6%
at least one 1-in-100 event in 30 years: 26.0%
at least one 1-in-100 event in 50 years: 39.5%
at least one 1-in-100 event in 100 years: 63.4%
two or more 1-in-10 events in a decade: 26.4%
two or more 1-in-100 events in a decade: 0.43%
Two 1-in-10 floods in a decade is the expected outcome, not a surprise. Two 1-in-100 floods in a decade is genuinely unusual β it is the case worth investigating.
Restate as a probability, check the record, add an interval
1. Restate the return period as an annual probability
1-in-100 is a 1% annual exceedance probability. Say it that way in captions and legends and most of the misreading disappears; several agencies have stopped printing return periods for exactly this reason.
2. Check the record before blaming the estimate
Count how many years in the gauge record actually exceeded the level. A fitted 1-in-100 that has been exceeded twice in 121 years is behaving as advertised.
3. Put an interval on the estimate
Bootstrap the fit. If the interval spans 1-in-50 to 1-in-200, then an event "at the 1-in-100 level" is within the noise of the estimate, and arguing about which side of it a flood fell on is arguing about the fit.
4. Separate clustering from trend
Floods cluster because weather clusters β a wet multi-year period produces several large peaks. A cluster is not evidence of a trend on its own.
5. Test for non-stationarity properly
Fit the halves of the record separately, or fit with a trend term, and compare. At Trenton the earlier half gives a higher 1-in-100 than the later half, which is the opposite of the expected story.
6. Say what the map is for
A 1-in-100 floodplain is a planning boundary, not a prediction of where water will be in any given year. Flooding outside it is expected β that is what the 500-year map is for.
Two exceedances in one gauge record, examined
Example 1 β what the record actually did
import io, urllib.request
import numpy as np, pandas as pd
from scipy.stats import genextreme
def annual_peaks(site):
url = (f"https://nwis.waterdata.usgs.gov/nwis/peak?site_no={site}"
"&agency_cd=USGS&format=rdb")
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("#")]
df = pd.read_csv(io.StringIO("\n".join(rows)), sep="\t").iloc[1:]
df["q"] = pd.to_numeric(df.peak_va, errors="coerce")
df["year"] = pd.to_datetime(df.peak_dt, errors="coerce", format="mixed").dt.year
return df.dropna(subset=["q", "year"])
peaks = annual_peaks("01463500") # Delaware River at Trenton, NJ
q, year = peaks.q.to_numpy(), peaks.year.astype(int).to_numpy()
params = genextreme.fit(q, 0.1, loc=np.median(q), scale=q.std())
level = lambda T: genextreme.ppf(1 - 1 / T, *params)
print(f"{len(q)} annual peaks, {year.min()}β{year.max()}")
for T in (10, 50, 100):
hit = year[q >= level(T)]
print(f" 1-in-{T:<4} = {level(T):8,.0f} cfs exceeded in {len(hit)} years: "
f"{', '.join(map(str, hit))}")
print(f" expected exceedances of the 1-in-100 in {len(q)} years: {len(q) / 100:.1f}")
121 annual peaks, 1897β2023
1-in-10 = 163,347 cfs exceeded in 9 years: 1902, 1903, 1936, 1955, 1996, 2004, 2005, 2006, 2011
1-in-50 = 240,980 cfs exceeded in 3 years: 1903, 1955, 2005
1-in-100 = 279,127 cfs exceeded in 2 years: 1903, 1955
expected exceedances of the 1-in-100 in 121 years: 1.2
Two exceedances of the 1-in-100 in 121 years, against 1.2 expected. The 1-in-10 was exceeded nine times against 12 expected. Nothing here needs explaining β but note 2004, 2005 and 2006, three consecutive years above the 1-in-10 level, which is exactly the pattern that starts the argument.
Example 2 β how wide the estimate is
rng = np.random.default_rng(0)
boot = []
for _ in range(400):
s = rng.choice(q, len(q), replace=True)
boot.append(genextreme.ppf(0.99, *genextreme.fit(s, 0.1, loc=np.median(s), scale=s.std())))
lo, hi = np.percentile(boot, [10, 90])
period = lambda x: 1 / (1 - genextreme.cdf(x, *params))
print(f"1-in-100 estimate {level(100):,.0f} cfs")
print(f" bootstrap 10thβ90th percentile: {lo:,.0f} β {hi:,.0f} cfs")
print(f" which are the 1-in-{period(lo):.0f} and 1-in-{period(hi):.0f} levels "
f"under the central fit")
1-in-100 estimate 279,127 cfs
bootstrap 10thβ90th percentile: 237,360 β 326,079 cfs
which are the 1-in-47 and 1-in-218 levels under the central fit
The estimate is not a line, it is a band, and the band is wide even on one of the longest records in the country. A flood 10% above or below the mapped level is not evidence about the map.
Example 3 β is there a trend, or just a cluster?
for name, sel in [("1897β1959", year < 1960), ("1960β2023", year >= 1960)]:
s = q[sel]
p = genextreme.fit(s, 0.1, loc=np.median(s), scale=s.std())
print(f"{name}: n={sel.sum():3} median peak {np.median(s):7,.0f} "
f"1-in-100 {genextreme.ppf(0.99, *p):9,.0f} cfs")
decades = pd.Series(q, index=(year // 10) * 10).groupby(level=0).max()
print("largest peak per decade:")
print(" " + " ".join(f"{d}s {v / 1000:5.0f}k" for d, v in decades.items()))
1897β1959: n= 56 median peak 101,750 1-in-100 304,867 cfs
1960β2023: n= 65 median peak 83,300 1-in-100 253,345 cfs
largest peak per decade:
1890s 73k 1900s 295k 1910s 160k 1920s 154k 1930s 227k 1940s 161k 1950s 329k 1960s 124k 1970s 135k 1980s 152k 1990s 179k 2000s 242k 2010s 195k 2020s 155k
The later half of this record gives a lower 1-in-100 estimate than the earlier half. That does not disprove a changing flood regime anywhere else; it shows that one gauge's split-half comparison is a weak test, dominated by whether a single very large peak fell in one half or the other β here 1955, when two hurricanes crossed in a week.
Independence, and why the phrase survives
Why independence is the assumption that gets forgotten
The binomial arithmetic above assumes each year is an independent trial. Real hydrology is not quite independent β wet years cluster through soil moisture, snowpack and ocean state β which makes runs of large floods more likely than the formula says, not less. So a cluster is even weaker evidence against the estimate than the plain arithmetic suggests.
Why "1-in-100" survives despite being misread
It is compact, it is in the regulations, and the alternative phrasings have their own problems: "1% annual chance" is correct but gets rounded to "rare" in a reader's head, and "base flood" says nothing at all. The practical answer is to print both the probability and what it means over a period of interest β 26% in thirty years is the sentence that lands.
Why the estimate is uncertain even with a long record
Fitting a 1% quantile means extrapolating past all but one or two observations. A century of data contains roughly one event near the level being estimated, so the tail is controlled by the assumed distribution shape as much as by the data, and different defensible distributions diverge exactly where the interest is.
Regulated rivers, ungauged sites and sea level
- Partial duration series (all peaks over a threshold) give different small return periods than annual maxima; below about 1-in-10 they are not comparable.
- Regulated rivers: a dam built mid-record breaks stationarity outright, and the pre-dam peaks may need dropping.
- Historic peaks flagged in the record extend it beyond the systematic gauge period and tighten the tail.
- Ungauged sites get regional regression estimates with much wider intervals than these.
- Sea level rise makes coastal return periods genuinely non-stationary on a decadal scale; river flood trends are harder to detect.
- "1-in-100" for a region is not the same as for a point β the chance that somewhere in a large area sees one is much higher.
Internal links
- Return periods and annual exceedance probability explained β the definition in full
- How to fit return periods to annual maxima in Python β the fitting workflow and its intervals
- Flood hazard maps explained β what the mapped scenario is for
- How to combine hazard, exposure and vulnerability into a risk score β integrating over probabilities
- Hazard, exposure and vulnerability explained β where the hazard term sits
- Choropleth classification explained β classes and legends that do not mislead
FAQ
Can a 1-in-100 flood happen twice in ten years?
Yes, with about a 0.4% chance if the estimate is right. Twice in a decade is unusual but not impossible.
What does 1-in-100 actually mean?
A 1% chance of being equalled or exceeded in any given year, with no memory of previous years.
What is the chance over 30 years?
About 26% for a 1-in-100 event, assuming independent years.
Does a cluster of floods mean the climate changed?
Not on its own. Test with split-half fits or a trend term, and expect a weak test from one gauge.
How accurate is a 1-in-100 estimate?
On a 121-year record, the 10thβ90th percentile band spans roughly the 1-in-50 to the 1-in-200 level.
Should I still map the 1-in-100 floodplain?
Yes β it is the regulatory boundary. Map the 1-in-500 alongside it so readers see what is outside.