Washington DC on track for most volatile temperature year since 1959

An analysis of 85 years of daily weather data from Reagan National Airport

Author

Will Angel

Published

April 19, 2026

Overview

As of April 19th, 2026 has been an extraordinary year for temperature swings in Washinton DC! The range between our warmest and coldest days so far is the largest since the spring of 1959. As I write this, we have a freeze watch in effect Monday night, so the year is only getting started!

Highlights:

  • 2026’s daily max temperature (TMAX) standard deviation of 16.31°F is second only to 1945 (17.21°F). This reflects the enormous range: a low of 21°F on Jan 24 and a high of 87°F on Apr 1 for a 66°F envelope in just 100 days.

  • 2026 has historic warm episodes followed by cold swings The Mar 9–12 heat wave (peaking at 86°F, breaking daily records by 7°F) was followed by a 53°F collapse to a 33°F low: the 2nd-largest 2-day cooling swing in 85 years.

  • The changing climate: DC is warmer, with mean TMAX rising ~3°F since the 1940s.

Data source: NOAA National Centers for Environmental Information (NCEI) — Climate Data Online, Daily Summaries. Station: Washington Reagan National Airport (USW00013743), 1942–2026.

~ ~ ~

Note: Claude Code was used in the making of this analysis. All further text is mostly generated. At some point I will do a follow up once we have more data.

~ ~ ~

Setup
import duckdb
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import numpy as np

plt.rcParams.update({
    'figure.facecolor': 'white',
    'axes.facecolor': '#fafafa',
    'axes.grid': True,
    'grid.alpha': 0.3,
    'font.size': 11,
    'axes.titlesize': 13,
    'axes.labelsize': 11,
})

CSV = '4292157.csv'
STATION = 'USW00013743'  # Reagan National
JAN_APR = "MONTH(DATE) <= 4 AND (MONTH(DATE) < 4 OR DAY(DATE) <= 10)"

con = duckdb.connect()
raw = con.sql(f"SELECT * FROM read_csv_auto('{CSV}') WHERE STATION = '{STATION}'").df()

Definitions

  • Temperature spread (TMAX std dev): The standard deviation of daily high temperatures measures how spread out a year’s temperatures are. Higher values mean a wider range of warm and cold days.
  • Daily range variability: The standard deviation of the difference between daily high and low temperatures. Higher values mean the daily temperature range is inconsistent — some days have a tight spread, others a wide one.
  • Max single-day precipitation: The maximum amount of precipitation recorded in a single day.
  • Day-to-day temp swing: The average day-to-day change in high temperature. Higher values mean the high temperature bounces around more from one day to the next.
  • Precipitation variability: The standard deviation of daily precipitation.

At a Glance

2026 scorecard query
scorecard = con.sql(f"""
    WITH daily AS (
        SELECT YEAR(DATE) AS yr, TMAX::DOUBLE AS tmax, TMIN::DOUBLE AS tmin,
               PRCP::DOUBLE AS prcp, AWND::DOUBLE AS awnd,
               (TMAX::DOUBLE - TMIN::DOUBLE) AS daily_range,
               ABS(TMAX::DOUBLE - LAG(TMAX::DOUBLE) OVER (PARTITION BY YEAR(DATE) ORDER BY DATE)) AS tmax_delta
        FROM read_csv_auto('{CSV}')
        WHERE STATION = '{STATION}' AND TMAX IS NOT NULL AND TMIN IS NOT NULL
          AND {JAN_APR}
    ),
    yearly AS (
        SELECT yr, COUNT(*) AS days,
               STDDEV(tmax) AS std_tmax,
               AVG(tmax_delta) AS avg_tmax_delta,
               STDDEV(daily_range) AS std_dr,
               STDDEV(prcp) AS std_prcp,
               MAX(prcp) AS max_prcp
        FROM daily
        WHERE prcp IS NOT NULL
        GROUP BY yr HAVING COUNT(*) >= 80
    ),
    ranked AS (
        SELECT yr,
               RANK() OVER (ORDER BY std_tmax DESC) AS rk_temp_spread,
               RANK() OVER (ORDER BY avg_tmax_delta DESC) AS rk_temp_swing,
               RANK() OVER (ORDER BY std_dr DESC) AS rk_range_var,
               RANK() OVER (ORDER BY std_prcp DESC) AS rk_precip_var,
               RANK() OVER (ORDER BY max_prcp DESC) AS rk_max_precip,
               COUNT(*) OVER () AS total
        FROM yearly
    )
    SELECT * FROM ranked WHERE yr = 2026
""").df()

sc = scorecard.iloc[0]
total = int(sc['total'])

2026 (Jan 1 – Apr 10) at a glance across 85 years of records:

Metric Rank
Temperature spread (TMAX std dev) #2 of 85
Daily range variability #15 of 85
Max single-day precipitation #8 of 85
Day-to-day temp swing #39 of 85
Precipitation variability #21 of 85

The Temperature Story

TMAX Standard Deviation: 2026 is #2 All-Time

The standard deviation of daily high temperatures measures how spread out a year’s temperatures are. Higher values mean a wider range of warm and cold days.

TMAX std dev trend
df = con.sql(f"""
    SELECT YEAR(DATE) AS yr, ROUND(STDDEV(TMAX::DOUBLE), 2) AS std_tmax,
           COUNT(*) AS days
    FROM read_csv_auto('{CSV}')
    WHERE STATION = '{STATION}' AND TMAX IS NOT NULL AND {JAN_APR}
    GROUP BY yr HAVING COUNT(*) >= 80
    ORDER BY yr
""").df()

fig, ax = plt.subplots(figsize=(10, 4.5))
colors = ['#c0392b' if yr == 2026 else '#2c3e50' for yr in df['yr']]
ax.bar(df['yr'], df['std_tmax'], color=colors, width=0.8, alpha=0.85)

# Highlight top years with manual offsets to avoid overlap
label_offsets = {1945: (-20, 20), 1948: (30, 12), 2026: (0, 8)}
for _, row in df.nlargest(3, 'std_tmax').iterrows():
    yr = int(row['yr'])
    label = f"{yr}: {row['std_tmax']:.1f}°F"
    offset = label_offsets.get(yr, (0, 8))
    ax.annotate(label, (yr, row['std_tmax']), textcoords="offset points",
                xytext=offset, ha='center', fontsize=8.5, fontweight='bold',
                color='#c0392b' if yr == 2026 else '#2c3e50',
                arrowprops=dict(arrowstyle='->', color='gray', lw=0.8) if offset != (0, 8) else None)

# Add top margin so labels don't clip
ymax = df['std_tmax'].max()
ax.set_ylim(top=ymax * 1.15)

ax.set_xlabel('Year')
ax.set_ylabel('Std Dev of TMAX (°F)')
ax.set_title('Temperature Spread: Jan–Apr TMAX Standard Deviation')
ax.axhline(df['std_tmax'].mean(), color='gray', ls='--', lw=1, alpha=0.6, label=f"Mean: {df['std_tmax'].mean():.1f}°F")
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()
Figure 1: Annual TMAX standard deviation (Jan 1 – Apr 10 window). 2026 ranks #2 of 85 years.

2026’s std dev of 16.31°F is second only to 1945 (17.21°F). This reflects the enormous range: a low of 21°F on Jan 24 and a high of 87°F on Apr 1 — a 66°F envelope in just 100 days.

Daily Temperature Timeline: 2026’s Whiplash Pattern

2026 daily temperature timeline
d26 = con.sql(f"""
    SELECT DATE, TMAX::DOUBLE AS tmax, TMIN::DOUBLE AS tmin
    FROM read_csv_auto('{CSV}')
    WHERE STATION = '{STATION}' AND YEAR(DATE) = 2026
      AND TMAX IS NOT NULL AND TMIN IS NOT NULL
      AND {JAN_APR}
    ORDER BY DATE
""").df()
d26['DATE'] = d26['DATE'].astype('datetime64[ns]')

fig, ax = plt.subplots(figsize=(10, 5))
ax.fill_between(d26['DATE'], d26['tmin'], d26['tmax'], alpha=0.2, color='#e74c3c', label='Daily range')
ax.plot(d26['DATE'], d26['tmax'], color='#c0392b', lw=1.5, label='TMAX')
ax.plot(d26['DATE'], d26['tmin'], color='#2980b9', lw=1.5, label='TMIN')

# Annotate key events
events = [
    ('2026-01-07', 'tmax', '63°F\nJan warmth'),
    ('2026-01-24', 'tmax', '22°F\nArctic blast'),
    ('2026-03-11', 'tmax', '86°F\nRecord high'),
    ('2026-03-12', 'tmin', '33°F\n45°F daily range'),
    ('2026-04-01', 'tmax', '87°F\nSummer heat'),
]
for date_str, field, text in events:
    import datetime
    dt = datetime.datetime.strptime(date_str, '%Y-%m-%d')
    row = d26[d26['DATE'] == dt]
    if len(row) > 0:
        y = float(row[field].iloc[0])
        offset = (0, 12) if field == 'tmax' else (0, -18)
        ax.annotate(text, (dt, y), textcoords="offset points", xytext=offset,
                    ha='center', fontsize=8, fontstyle='italic',
                    arrowprops=dict(arrowstyle='->', color='gray', lw=0.8))

ax.set_ylabel('Temperature (°F)')
ax.set_title('2026 Daily Temperatures: A Year of Whiplash')
ax.legend(loc='upper left', fontsize=9)
fig.autofmt_xdate()
plt.tight_layout()
plt.show()
Figure 2: Daily TMAX and TMIN at Reagan National, Jan 1 – Apr 10, 2026. Shaded area shows the daily range.

Note to self and readers: add proper offsets to this chart so that the labels aren't on top of things. :)

The pattern is clear: warm episodes intrude and then crash. The Mar 9–12 heat wave (peaking at 86°F, breaking daily records by 7°F) was followed by a 53°F collapse to a 33°F low — the 2nd-largest 2-day cooling swing in 85 years.

2026 vs. Historical Envelope

Historical envelope comparison
hist = con.sql(f"""
    SELECT MONTH(DATE) AS m, DAY(DATE) AS d,
           PERCENTILE_CONT(0.1) WITHIN GROUP (ORDER BY TMAX::DOUBLE) AS p10,
           PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY TMAX::DOUBLE) AS p50,
           PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY TMAX::DOUBLE) AS p90
    FROM read_csv_auto('{CSV}')
    WHERE STATION = '{STATION}' AND YEAR(DATE) < 2026
      AND TMAX IS NOT NULL AND {JAN_APR}
    GROUP BY m, d ORDER BY m, d
""").df()

cur = con.sql(f"""
    SELECT MONTH(DATE) AS m, DAY(DATE) AS d, TMAX::DOUBLE AS tmax
    FROM read_csv_auto('{CSV}')
    WHERE STATION = '{STATION}' AND YEAR(DATE) = 2026
      AND TMAX IS NOT NULL AND {JAN_APR}
    ORDER BY m, d
""").df()

# Merge on calendar day
merged = hist.merge(cur, on=['m', 'd'], how='left')
merged['day_num'] = range(len(merged))

fig, ax = plt.subplots(figsize=(10, 5))
ax.fill_between(merged['day_num'], merged['p10'], merged['p90'],
                alpha=0.2, color='#3498db', label='Historical 10th–90th pctl')
ax.plot(merged['day_num'], merged['p50'], color='#3498db', lw=1.5, ls='--', alpha=0.7, label='Historical median')
ax.plot(merged['day_num'], merged['tmax'], color='#c0392b', lw=2, label='2026 TMAX')

# Month labels
month_starts = [0, 31, 59, 90]
month_names = ['Jan', 'Feb', 'Mar', 'Apr']
ax.set_xticks(month_starts)
ax.set_xticklabels(month_names)

ax.set_ylabel('TMAX (°F)')
ax.set_title('2026 vs. Historical Norms: Frequent Excursions Beyond the 90th Percentile')
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()
Figure 3: 2026 TMAX (red) against the historical envelope (10th–90th percentile band and median) for each calendar day.

2026 repeatedly breaks above the 90th percentile envelope, especially in March and early April. But it also dips into or below the 10th percentile in late January. This back-and-forth between extremes is the hallmark of 2026.

Multi-Day Temperature Swings

Beyond single-day metrics, we can measure how dramatically temperature changes over 2- and 3-day windows by tracking the swing from one day’s high to the next day’s low (cooling) or one day’s low to the next day’s high (warming).

2-Day Cooling Swings: 2026 is #2 All-Time

2-day cooling swing trend
cool2 = con.sql(f"""
    WITH daily AS (
        SELECT YEAR(DATE) AS yr, TMAX::DOUBLE AS tmax, TMIN::DOUBLE AS tmin,
               LAG(TMAX::DOUBLE) OVER (PARTITION BY YEAR(DATE) ORDER BY DATE) AS prev_tmax
        FROM read_csv_auto('{CSV}')
        WHERE STATION = '{STATION}' AND TMAX IS NOT NULL AND TMIN IS NOT NULL AND {JAN_APR}
    )
    SELECT yr, MAX(prev_tmax - tmin) AS max_cool_2d
    FROM daily WHERE prev_tmax IS NOT NULL
    GROUP BY yr HAVING COUNT(*) >= 80
    ORDER BY yr
""").df()

fig, ax = plt.subplots(figsize=(10, 4.5))
colors = ['#c0392b' if yr == 2026 else '#2c3e50' for yr in cool2['yr']]
ax.bar(cool2['yr'], cool2['max_cool_2d'], color=colors, width=0.8, alpha=0.85)

top3 = cool2.nlargest(3, 'max_cool_2d')
top_yrs = sorted(top3['yr'].tolist())
# Spread labels for closely spaced years; 2026 gets default offset
offsets = {}
for i, y in enumerate(top_yrs[:-1]):  # all except 2026 (latest)
    offsets[int(y)] = (-30 + i * 60, 20)
offsets[2026] = (0, 8)
for _, row in top3.iterrows():
    yr = int(row['yr'])
    offset = offsets.get(yr, (0, 8))
    ax.annotate(f"{yr}: {row['max_cool_2d']:.0f}°F", (yr, row['max_cool_2d']),
                textcoords="offset points", xytext=offset, ha='center', fontsize=8.5,
                fontweight='bold', color='#c0392b' if yr == 2026 else '#2c3e50',
                arrowprops=dict(arrowstyle='->', color='gray', lw=0.8) if offset != (0, 8) else None)

# Add top margin so labels don't clip
ymax = cool2['max_cool_2d'].max()
ax.set_ylim(top=ymax * 1.15)

ax.set_xlabel('Year')
ax.set_ylabel('Max 2-Day Cooling Swing (°F)')
ax.set_title('Largest 2-Day Cooling: Prior Day High → Current Day Low')
ax.axhline(cool2['max_cool_2d'].mean(), color='gray', ls='--', lw=1, alpha=0.6,
           label=f"Mean: {cool2['max_cool_2d'].mean():.0f}°F")
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()
Figure 4: Maximum 2-day cooling swing (prior day TMAX → current day TMIN) per year, Jan–Apr window.

On March 12, the high from the prior day (86°F on Mar 11) gave way to a low of 33°F — a 53°F crash in under 24 hours. Only January 1959 (67°F → 12°F, 55°F) produced a larger 2-day cooling swing in 85 years of Jan–Apr data.

3-Day Cooling Swings: 2026 is #3 All-Time

3-day cooling swing trend
cool3 = con.sql(f"""
    WITH daily AS (
        SELECT YEAR(DATE) AS yr, TMAX::DOUBLE AS tmax, TMIN::DOUBLE AS tmin,
               LAG(TMAX::DOUBLE, 1) OVER (PARTITION BY YEAR(DATE) ORDER BY DATE) AS p1max,
               LAG(TMAX::DOUBLE, 2) OVER (PARTITION BY YEAR(DATE) ORDER BY DATE) AS p2max
        FROM read_csv_auto('{CSV}')
        WHERE STATION = '{STATION}' AND TMAX IS NOT NULL AND TMIN IS NOT NULL AND {JAN_APR}
    )
    SELECT yr, MAX(GREATEST(p1max, COALESCE(p2max, p1max)) - tmin) AS max_cool_3d
    FROM daily WHERE p1max IS NOT NULL
    GROUP BY yr HAVING COUNT(*) >= 80
    ORDER BY yr
""").df()

fig, ax = plt.subplots(figsize=(10, 4.5))
colors = ['#c0392b' if yr == 2026 else '#2c3e50' for yr in cool3['yr']]
ax.bar(cool3['yr'], cool3['max_cool_3d'], color=colors, width=0.8, alpha=0.85)

top3_3d = cool3.nlargest(3, 'max_cool_3d')
top_yrs_3d = sorted(top3_3d['yr'].tolist())
offsets_3d = {}
for i, y in enumerate(top_yrs_3d[:-1]):
    offsets_3d[int(y)] = (-30 + i * 60, 20)
offsets_3d[2026] = (0, 8)
for _, row in top3_3d.iterrows():
    yr = int(row['yr'])
    offset = offsets_3d.get(yr, (0, 8))
    ax.annotate(f"{yr}: {row['max_cool_3d']:.0f}°F", (yr, row['max_cool_3d']),
                textcoords="offset points", xytext=offset, ha='center', fontsize=8.5,
                fontweight='bold', color='#c0392b' if yr == 2026 else '#2c3e50',
                arrowprops=dict(arrowstyle='->', color='gray', lw=0.8) if offset != (0, 8) else None)

ymax_3d = cool3['max_cool_3d'].max()
ax.set_ylim(top=ymax_3d * 1.15)

ax.set_xlabel('Year')
ax.set_ylabel('Max 3-Day Cooling Swing (°F)')
ax.set_title('Largest 3-Day Cooling: Max High in Window → Current Day Low')
ax.axhline(cool3['max_cool_3d'].mean(), color='gray', ls='--', lw=1, alpha=0.6,
           label=f"Mean: {cool3['max_cool_3d'].mean():.0f}°F")
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()
Figure 5: Maximum 3-day cooling swing (highest TMAX in 2 prior days → current TMIN) per year, Jan–Apr window.

Warming vs. Cooling Asymmetry

Warming vs cooling scatter
swings = con.sql(f"""
    WITH daily AS (
        SELECT YEAR(DATE) AS yr, TMAX::DOUBLE AS tmax, TMIN::DOUBLE AS tmin,
               LAG(TMIN::DOUBLE, 1) OVER (PARTITION BY YEAR(DATE) ORDER BY DATE) AS p1min,
               LAG(TMAX::DOUBLE, 1) OVER (PARTITION BY YEAR(DATE) ORDER BY DATE) AS p1max
        FROM read_csv_auto('{CSV}')
        WHERE STATION = '{STATION}' AND TMAX IS NOT NULL AND TMIN IS NOT NULL AND {JAN_APR}
    )
    SELECT yr,
           MAX(tmax - p1min) AS max_warm_2d,
           MAX(p1max - tmin) AS max_cool_2d
    FROM daily WHERE p1min IS NOT NULL
    GROUP BY yr HAVING COUNT(*) >= 80
""").df()

fig, ax = plt.subplots(figsize=(7, 6))
is2026 = swings['yr'] == 2026
ax.scatter(swings.loc[~is2026, 'max_warm_2d'], swings.loc[~is2026, 'max_cool_2d'],
           c='#2c3e50', alpha=0.5, s=40, label='Other years')
ax.scatter(swings.loc[is2026, 'max_warm_2d'], swings.loc[is2026, 'max_cool_2d'],
           c='#c0392b', s=120, zorder=5, edgecolors='black', label='2026')
ax.annotate('2026', (float(swings.loc[is2026, 'max_warm_2d'].iloc[0]),
            float(swings.loc[is2026, 'max_cool_2d'].iloc[0])),
            textcoords="offset points", xytext=(10, 5), fontsize=11, fontweight='bold', color='#c0392b')

# Reference lines at means
ax.axvline(swings['max_warm_2d'].mean(), color='gray', ls=':', alpha=0.5)
ax.axhline(swings['max_cool_2d'].mean(), color='gray', ls=':', alpha=0.5)
ax.text(swings['max_warm_2d'].mean() + 0.5, ax.get_ylim()[0] + 1, 'avg warming', fontsize=8, color='gray')
ax.text(ax.get_xlim()[0] + 0.5, swings['max_cool_2d'].mean() + 0.5, 'avg cooling', fontsize=8, color='gray')

ax.set_xlabel('Max 2-Day Warming Swing (°F)')
ax.set_ylabel('Max 2-Day Cooling Swing (°F)')
ax.set_title('Warming vs. Cooling: 2026 is Extreme Only on the Cool Side')
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()
Figure 6: 2026 is extreme for cooling swings but average for warming — a distinctive asymmetry.

2026 sits in the upper-left quadrant: below-average warming swings but near-record cooling swings. This asymmetry tells us the volatility is driven by warm air masses arriving and then being abruptly replaced — the “crash” after the warm spell is the extreme event, not the warm-up itself.

The March Heat Wave: A Closer Look

The most dramatic episode of 2026 was the March 9–12 heat wave and subsequent collapse.

March heat wave detail
march = con.sql(f"""
    SELECT DATE, TMAX::DOUBLE AS tmax, TMIN::DOUBLE AS tmin
    FROM read_csv_auto('{CSV}')
    WHERE STATION = '{STATION}' AND YEAR(DATE) = 2026
      AND MONTH(DATE) = 3 AND DAY(DATE) BETWEEN 1 AND 20
      AND TMAX IS NOT NULL
    ORDER BY DATE
""").df()
march['DATE'] = march['DATE'].astype('datetime64[ns]')

fig, ax = plt.subplots(figsize=(9, 5))
ax.fill_between(march['DATE'], march['tmin'], march['tmax'], alpha=0.3, color='#e74c3c')
ax.plot(march['DATE'], march['tmax'], 'o-', color='#c0392b', lw=2, markersize=6, label='TMAX')
ax.plot(march['DATE'], march['tmin'], 's-', color='#2980b9', lw=2, markersize=6, label='TMIN')

ax.annotate('86°F — Record\n(prev: 79°F)', xy=(march['DATE'].iloc[10], 86),
            textcoords="offset points", xytext=(-70, 10), fontsize=9, fontweight='bold',
            arrowprops=dict(arrowstyle='->', color='#c0392b'))
ax.annotate('33°F low\n45°F daily range', xy=(march['DATE'].iloc[11], 33),
            textcoords="offset points", xytext=(20, -20), fontsize=9, fontweight='bold',
            arrowprops=dict(arrowstyle='->', color='#2980b9'))

# Add margin so annotations don't clip
ax.set_ylim(bottom=march['tmin'].min() - 10, top=march['tmax'].max() + 12)

ax.set_ylabel('Temperature (°F)')
ax.set_title('March 2026: Record Heat Wave → Collapse')
ax.legend(fontsize=9)
fig.autofmt_xdate()
plt.tight_layout()
plt.show()
Figure 7: The March 2026 heat wave and collapse. Daily highs surged from the 50s to 86°F, then crashed. Mar 12 had a 45°F daily range.

March 10–11 broke daily high records by 5–7°F. The 86°F reading on Mar 11 was followed by a low of 33°F on Mar 12 — a 53°F plunge. This single episode is what makes 2026 feel historic.

Historical Context: Is Volatility Increasing?

Long-term trend: two panels
lt = con.sql(f"""
    WITH daily AS (
        SELECT YEAR(DATE) AS yr, TMAX::DOUBLE AS tmax, TMIN::DOUBLE AS tmin,
               ABS(TMAX::DOUBLE - LAG(TMAX::DOUBLE) OVER (PARTITION BY YEAR(DATE) ORDER BY DATE)) AS tmax_delta
        FROM read_csv_auto('{CSV}')
        WHERE STATION = '{STATION}' AND TMAX IS NOT NULL AND TMIN IS NOT NULL AND {JAN_APR}
    )
    SELECT yr, STDDEV(tmax) AS std_tmax, AVG(tmax_delta) AS avg_tmax_delta
    FROM daily WHERE yr >= 1942
    GROUP BY yr HAVING COUNT(*) >= 80
    ORDER BY yr
""").df()

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 8))

# Panel 1: TMAX std dev
ax1.scatter(lt['yr'], lt['std_tmax'], c=['#c0392b' if y == 2026 else '#2c3e50' for y in lt['yr']],
            s=[80 if y == 2026 else 25 for y in lt['yr']], alpha=0.7, zorder=3)
# 10-year rolling average
roll = lt.set_index('yr')['std_tmax'].rolling(10, center=True, min_periods=5).mean()
ax1.plot(roll.index, roll.values, color='#e67e22', lw=2.5, label='10-yr rolling avg')
ax1.set_xlabel('Year')
ax1.set_ylabel('Std Dev of TMAX (°F)')
ax1.set_title('Temperature Spread Over Time')
ax1.legend(fontsize=9)

# Panel 2: Day-to-day swing
ax2.scatter(lt['yr'], lt['avg_tmax_delta'], c=['#c0392b' if y == 2026 else '#2c3e50' for y in lt['yr']],
            s=[80 if y == 2026 else 25 for y in lt['yr']], alpha=0.7, zorder=3)
roll2 = lt.set_index('yr')['avg_tmax_delta'].rolling(10, center=True, min_periods=5).mean()
ax2.plot(roll2.index, roll2.values, color='#e67e22', lw=2.5, label='10-yr rolling avg')
ax2.set_xlabel('Year')
ax2.set_ylabel('Avg |ΔTMAX| (°F)')
ax2.set_title('Day-to-Day Swings Over Time')
ax2.legend(fontsize=9)

plt.tight_layout()
plt.show()
Figure 8: Decadal smoothing of Jan–Apr temperature volatility metrics. No clear upward trend in day-to-day swings; temperature spread shows no consistent pattern.

The 10-year rolling average shows no clear upward trend in either metric. Day-to-day swings have actually decreased slightly since the 1950s. Temperature spread is noisy with no directional trend — the most spread-out years (1945, 2026, 1948) span the entire record. The data does not support a claim that DC weather is becoming systematically more volatile.

Conclusion

Summary ranking chart
metrics = [
    'Temp spread\n(TMAX σ)', '2-day\ncooling', '3-day\ncooling',
    'Max 3-day\nrange', 'Max single-day\nprecip', 'Wind\nvariability',
    'Day-to-day\nswing', 'Precip\nvariability'
]
ranks = [2, 2, 3, 6, 8, 11, 39, 21]
totals = [85, 85, 85, 85, 85, 43, 85, 85]
pcts = [1 - (r - 1) / t for r, t in zip(ranks, totals)]

fig, ax = plt.subplots(figsize=(10, 5))
bars = ax.barh(range(len(metrics)), pcts, color=['#c0392b' if p >= 0.88 else '#f39c12' if p >= 0.75 else '#95a5a6' for p in pcts],
               alpha=0.85, height=0.7)

for i, (r, t, p) in enumerate(zip(ranks, totals, pcts)):
    ax.text(p + 0.01, i, f"#{r} of {t}", va='center', fontsize=10, fontweight='bold')

ax.set_yticks(range(len(metrics)))
ax.set_yticklabels(metrics, fontsize=10)
ax.set_xlim(0, 1.15)
ax.set_xlabel('Percentile')
ax.xaxis.set_major_formatter(mticker.PercentFormatter(1.0))
ax.set_title('2026 Volatility Scorecard: Where It Ranks')
ax.invert_yaxis()
plt.tight_layout()
plt.show()
Figure 9: 2026 ranking across all volatility metrics. Red bars show where 2026 is in the top 10.

Is 2026 the most volatile year on record?

No — but the question is too simple for the data.

2026 is #2 all-time for temperature spread. It produced the 2nd-largest 2-day cooling swing in 85 years. Two daily high records were broken. An 87°F reading on April 1 and lows in the teens in late January gave it a 66°F temperature envelope in 100 days.

But “most volatile” implies across-the-board extremes. 2026’s precipitation was average. Its wind was modestly above normal. Its day-to-day temperature choppiness — how much the high bounced around from one day to the next — was squarely in the middle of the pack.

What makes 2026 feel exceptional is its asymmetric pattern of warm intrusions crashing back to winter. The Mar 11 record high of 86°F collapsing to a 33°F low the next day; the 87°F April 1 reading followed by a 23°F drop. These are the events people remember, and they are genuinely rare. But they represent a specific type of volatility — temperature whiplash driven by unseasonable warmth — not a uniformly chaotic year.

The most volatile Jan–Apr on record, by composite score, was 2014 — a year nobody remembers as exceptional, because its volatility was spread evenly across metrics rather than concentrated in dramatic headline events.

Is DC getting more volatile?

The data says no. The 10-year rolling averages for temperature spread and day-to-day swings show no upward trend across 85 years. The most volatile years are scattered throughout the record. Wind data (available since 1984) shows no trend. The composite index’s top 10 is dominated by recent years, but this is partly an artifact of data availability.

What is changing is average temperature: DC is warmer, with mean TMAX rising ~3°F since the 1940s. Warmer winters create more opportunities for the kind of warm intrusion that defined 2026. Whether this translates to more volatility is a different question — and this dataset, at least, doesn’t show it yet.


Data: NOAA NCEI Climate Data Online, Reagan National Airport (USW00013743), 1942–2026. Analysis limited to Jan 1 – Apr 10 for fair comparison with the 2026 partial year.