COVID Lockdowns and the Natural Gas Storage Anomaly
In spring 2020, the EIA weekly natural gas storage report started printing numbers that broke every seasonal model. Injections ran 20–40% above the five-year average for weeks straight — a demand collapse hiding in plain sight. The price didn't catch up for weeks. Here's how to read the signal.
The Setup
Every Thursday at 10:30 AM Eastern, the EIA publishes the Weekly Natural Gas Storage Report. It's the single most-watched number in natural gas markets — the net change in working gas inventories held in underground storage across the Lower 48 states.
Traders compare the reported injection or withdrawal against a consensus estimate. A larger-than-expected injection is bearish (more gas than the market needs); a smaller-than-expected injection is bullish. But the real analytical edge comes from tracking storage levels against the five-year average and the five-year range.
In March 2020, U.S. lockdowns crushed commercial and industrial gas demand almost overnight. The weekly storage reports started telling the story immediately — injections were running far above seasonal norms. But Henry Hub front-month futures didn't fully price the surplus until storage levels had visibly breached the five-year range ceiling in late April. That lag between the data signal and the price reaction is what makes this case study worth examining.
The Data
The Commodity Fundamentals API provides EIA natural gas storage data as a weekly time series. Here's how to pull it:
import requests
API_KEY = "your_api_key_here"
BASE = "https://commodityfundamentals.com/api/v1"
# Fetch EIA natural gas storage data
response = requests.get(
f"{BASE}/commodities/natural-gas/series",
headers={"Authorization": f"Bearer {API_KEY}"},
params={
"source": "eia_storage",
"start": "2015-01-01",
"end": "2021-01-01"
}
)
data = response.json()
print(f"Retrieved {data['meta']['total']} weekly observations")
Each observation includes the total working gas in storage (in billion cubic feet) for that report week:
{
"data": [
{
"date": "2020-03-27",
"value": 1986.0,
"commodity": "natural_gas",
"source": "eia_storage",
"unit": "bcf"
},
{
"date": "2020-04-03",
"value": 2029.0,
"commodity": "natural_gas",
"source": "eia_storage",
"unit": "bcf"
}
],
"meta": {
"total": 2,
"request_id": "req_def456"
}
}
The weekly net change (injection or withdrawal) is implicit: subtract the prior week's level from the current week. But the more useful metric is the deviation from the five-year average level for the same calendar week.
The Analysis
The key insight is that absolute storage levels are meaningless without seasonal context. 2,000 Bcf in October (heading into winter) is tight; 2,000 Bcf in March (after winter) is comfortable. The signal comes from comparing the current level to where storage typically sits at that point in the seasonal cycle.
import pandas as pd
df = pd.DataFrame(data["data"])
df["date"] = pd.to_datetime(df["date"])
df["year"] = df["date"].dt.year
df["week"] = df["date"].dt.isocalendar().week.astype(int)
# Calculate 5-year average storage level by calendar week
# Using 2015-2019 as the "normal" pre-COVID baseline
baseline = df[df["year"].between(2015, 2019)]
avg_by_week = baseline.groupby("week")["value"].agg(["mean", "min", "max"])
avg_by_week.columns = ["avg_5yr", "min_5yr", "max_5yr"]
# 2020 deviation from the 5-year average
year_2020 = df[df["year"] == 2020].set_index("week")["value"]
deviation = year_2020 - avg_by_week["avg_5yr"]
print("2020 Storage Deviation from 5-Year Average (Bcf)")
print("-" * 50)
for week in range(10, 30):
if week in deviation.index:
dev = deviation[week]
flag = " *** ANOMALY" if abs(dev) > 200 else ""
print(f"Week {week:>2}: {dev:>+8.0f} Bcf{flag}")
Here's what the 2020 data shows. By early April (week 14), storage was already running +250 Bcf above the five-year average — a massive surplus building in real time. By late May, the surplus had ballooned past +400 Bcf. This wasn't a subtle signal. The EIA data was screaming that demand had collapsed.
# Week-over-week injection comparison
df_sorted = df.sort_values("date")
df_sorted["injection"] = df_sorted.groupby("year")["value"].diff()
# Compare 2020 injection sizes to historical average
inj_avg = (df_sorted[df_sorted["year"].between(2015, 2019)]
.groupby("week")["injection"].mean())
inj_2020 = (df_sorted[df_sorted["year"] == 2020]
.set_index("week")["injection"])
inj_deviation = inj_2020 - inj_avg
# Weeks 13-20 (late March through mid-May 2020):
# injections consistently 20-40 Bcf ABOVE normal
# This is the demand destruction signal
The injection deviation is even more actionable than the level deviation. A single oversized injection could be weather-driven. But six consecutive weeks of injections 20–40 Bcf above the seasonal norm is a structural demand shift. That pattern was visible by mid-April 2020 — before the front-month contract made its final leg down to $1.50/MMBtu in late June.
CFTC positioning confirmed the signal. The Commitments of Traders report showed managed money net short positions in natural gas expanding throughout April and May 2020. The speculative community was reading the same storage data and pressing the bearish bet. Combining EIA storage anomalies with CFTC positioning data creates a higher-confidence signal.
Try It Yourself
Here's a complete script that pulls EIA natural gas storage data, calculates the deviation from the five-year seasonal average, and flags anomalous weeks where the surplus or deficit exceeds a configurable threshold:
"""
Natural Gas Storage Anomaly Detector
Compares current storage levels and injection sizes against
the 5-year seasonal average to identify demand anomalies.
Requires: pip install requests pandas
API key: https://commodityfundamentals.com/registration/new
"""
import requests
import pandas as pd
API_KEY = "your_api_key_here"
BASE = "https://commodityfundamentals.com/api/v1"
THRESHOLD_BCF = 200 # flag deviations exceeding this
def fetch_storage_data(start="2015-01-01"):
response = requests.get(
f"{BASE}/commodities/natural-gas/series",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"source": "eia_storage", "start": start}
)
response.raise_for_status()
return pd.DataFrame(response.json()["data"])
def analyze_storage(df):
df["date"] = pd.to_datetime(df["date"])
df["year"] = df["date"].dt.year
df["week"] = df["date"].dt.isocalendar().week.astype(int)
current_year = df["year"].max()
baseline_years = range(current_year - 5, current_year)
# 5-year average storage level by week
baseline = df[df["year"].isin(baseline_years)]
avg_storage = baseline.groupby("week")["value"].mean()
# Current year deviation
current = df[df["year"] == current_year].set_index("week")["value"]
level_deviation = current - avg_storage
# Injection sizes (week-over-week change)
df_sorted = df.sort_values("date")
df_sorted["injection"] = df_sorted.groupby("year")["value"].diff()
avg_injection = (baseline.sort_values("date")
.assign(injection=lambda x: x.groupby("year")["value"].diff())
.groupby("week")["injection"].mean())
current_inj = (df_sorted[df_sorted["year"] == current_year]
.set_index("week")["injection"])
inj_deviation = current_inj - avg_injection
return level_deviation, inj_deviation, current_year
def main():
df = fetch_storage_data()
level_dev, inj_dev, year = analyze_storage(df)
print(f"\nNatural Gas Storage Analysis — {year}")
print(f"{'Week':<6} {'Level Dev':>10} {'Inj Dev':>10} {'Signal':>10}")
print("-" * 40)
for week in level_dev.index:
ldev = level_dev.get(week, 0)
idev = inj_dev.get(week, 0)
signal = ""
if ldev > THRESHOLD_BCF:
signal = "SURPLUS"
elif ldev < -THRESHOLD_BCF:
signal = "DEFICIT"
print(f"{week:<6} {ldev:>+10.0f} {idev:>+10.1f} {signal:>10}")
if __name__ == "__main__":
main()
Get your free API key to run this analysis — sign up here. The free tier includes 1,000 API calls per day, which is more than enough for weekly storage monitoring.
For detailed field documentation, see the EIA Natural Gas Storage API reference.