Why Are Most Wildfires Missing From the Fire Perimeter Data?
On this page
Problem statement
Count the 2024 fires in CAL FIRE's perimeter database and you get 553, or 218 if you use its popular last-five-years view, which quietly keeps only fires over 50 acres. California's agencies reported more than 14,000 wildfire incidents that year. None of these numbers is wrong. A perimeter database records fires above a size threshold, mapped after the fact, while the incident record logs every reported ignition, most of them put out within an acre. Using perimeters to count fires, to study what starts them or to map where ignitions happen leaves out almost every fire. Using them for burned area loses almost nothing.
This page joins the 2024 California perimeters, from the full historic layer, to the national incident record (NIFC's WFIGS) by their shared IRWIN identifier. It measures what the perimeter data leaves out, by size class.
Quick answer
Join the two sources on the IRWIN ID and compare the share of fires with the share of area:
inc["perimeter"] = inc.irwin.isin(set(per.irwin.dropna()))
sized = inc.dropna(subset=["acres"])
print(f"{len(inc):,} wildfire incidents; {len(per)} perimeters; {inc.perimeter.sum()} incidents matched to a perimeter")
print(f"of {len(sized):,} incidents with a reported size, those with a perimeter are {sized.perimeter.mean():.1%} of fires "
f"and {sized.loc[sized.perimeter, 'acres'].sum() / sized.acres.sum():.1%} of reported acres")
14,065 wildfire incidents; 553 perimeters; 425 incidents matched to a perimeter
of 7,937 incidents with a reported size, those with a perimeter are 4.3% of fires and 96.3% of reported acres
Collection rules, size classes, unclassified fires
1. Look at the collection criteria
The perimeter layer states its rules. CAL FIRE and contract counties submit perimeters for fires of at least 10 acres in timber, 50 in brush or 300 in grass, or that damaged three structures or killed someone. Other agencies submit fires of 10 acres or more. Smaller fires are not required, though some are mapped anyway. A published view can add its own filter on top: the last-five-years view keeps only GIS_ACRES > 50, as its definition query, printed in the setup below, shows.
2. Compare by size class
Group the incidents by reported size and count how many have a perimeter:
bins = [0, 1, 10, 50, 300, 1000, 10000, np.inf]
labels = ["<1", "1β10", "10β50", "50β300", "300β1,000", "1,000β10,000", "10,000+"]
sized = sized.assign(size=pd.cut(sized.acres, bins, right=False, labels=labels))
t = sized.groupby("size", observed=False).agg(fires=("acres", "size"), with_perimeter=("perimeter", "sum"), acres=("acres", "sum"))
t["share of acres"] = t.acres / t.acres.sum()
print(t.to_string(formatters={"acres": "{:,.0f}".format, "share of acres": "{:.1%}".format}))
fires with_perimeter acres share of acres
size
<1 6243 35 797 0.1%
1β10 1199 49 3,419 0.4%
10β50 283 103 6,334 0.7%
50β300 113 65 12,928 1.3%
300β1,000 50 44 25,964 2.7%
1,000β10,000 34 30 112,101 11.7%
10,000+ 15 15 798,837 83.2%
3. Count what cannot be classified
Many incidents carry no size at all, so they cannot be placed in a class:
nosize = inc.acres.isna()
print(f"{nosize.sum():,} of {len(inc):,} incidents ({nosize.mean():.0%}) have no reported size")
by = inc.groupby("agency").acres.agg(incidents="size", no_size=lambda s: s.isna().mean())
print(by.sort_values("incidents", ascending=False).head(4).to_string(formatters={"no_size": "{:.0%}".format}))
6,128 of 14,065 incidents (44%) have no reported size
incidents no_size
agency
CAL FIRE 7848 15%
C&L 4834 100%
USFS 862 2%
BLM 259 2%
The contract counties and local agencies (C&L) report thousands of incidents without a size, so a size-class table covers mainly the fires that CAL FIRE and the federal agencies protect.
4. Check the join in both directions
Many perimeters have no IRWIN ID, mostly those from contract counties, so they cannot be joined by ID:
lost = per[~per.irwin.isin(set(inc.irwin))]
print(f"{len(lost)} of {len(per)} perimeters have no matching incident; {lost.irwin.isna().sum()} have no IRWIN ID at all; "
f"{lost.GIS_ACRES.sum():,.0f} acres")
print(lost.AGENCY.value_counts().to_string())
128 of 553 perimeters have no matching incident; 117 have no IRWIN ID at all; 25,691 acres
AGENCY
CCO 106
LRA 14
CDF 6
USF 1
FWS 1
Large fires without a perimeter
The data used on this page
WFIGS Incident Locations from the National Interagency Fire Center (wildfire incidents with a point of origin in California, discovered in 2024), and CAL FIRE fire perimeters for 2024 from the full historic layer (California_Historic_Fire_Perimeters), both public ArcGIS feature services. The incident service returns at most 2,000 records a request, so the download pages through resultOffset.
from pathlib import Path
import geopandas as gpd
import numpy as np
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
WFIGS = "https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/WFIGS_Incident_Locations/FeatureServer/0/query"
where = ("POOState = 'US-CA' AND IncidentTypeCategory = 'WF' AND FireDiscoveryDateTime >= TIMESTAMP '2024-01-01 00:00:00' "
"AND FireDiscoveryDateTime < TIMESTAMP '2025-01-01 00:00:00'")
rows, offset = [], 0
while True:
page = requests.get(WFIGS, params={"where": where, "outFields": "IrwinID,IncidentName,IncidentSize,FinalAcres,POOProtectingAgency",
"outSR": 4326, "resultOffset": offset, "resultRecordCount": 2000, "f": "json"}, timeout=120).json()
rows += [dict(f["attributes"], lon=f["geometry"]["x"], lat=f["geometry"]["y"]) for f in page["features"] if f.get("geometry")]
offset += len(page["features"])
if not page.get("exceededTransferLimit"):
break
inc = pd.DataFrame(rows)
inc["acres"] = inc.FinalAcres.fillna(inc.IncidentSize)
inc["irwin"] = inc.IrwinID.str.strip("{}").str.upper()
inc = inc.rename(columns={"POOProtectingAgency": "agency"})
SERVICES = "https://services1.arcgis.com/jUJYIo9tSA7EHvfZ/arcgis/rest/services/"
HIST = SERVICES + "California_Historic_Fire_Perimeters/FeatureServer/0"
VIEW = SERVICES + "California_Fire_Perimeters_last_5_years_View/FeatureServer/0"
GEOJSON = Path("data/calfire_2024_all.geojson")
if not GEOJSON.exists():
GEOJSON.write_bytes(requests.get(HIST + "/query", params={"where": "YEAR_ = 2024", "outFields": "*", "outSR": 4326, "f": "geojson"}, timeout=300).content)
per = gpd.read_file(GEOJSON)
per["irwin"] = per.IRWINID.str.strip("{}").str.upper()
view_filter = requests.get(VIEW, params={"f": "json"}, timeout=60).json().get("viewDefinitionQuery")
in_view = requests.get(VIEW + "/query", params={"where": "YEAR_ = 2024", "returnCountOnly": "true", "f": "json"}, timeout=60).json()["count"]
print(f"{offset:,} incident records read ({len(inc):,} with a location)")
print(f"2024 perimeters: {len(per)} in the historic layer, {in_view} in the last-five-years view ({view_filter})")
14,065 incident records read (14,065 with a location)
2024 perimeters: 553 in the historic layer, 218 in the last-five-years view (YEAR_ IN (2024,2025,2021,2022,2023) AND GIS_ACRES > 50)
Example 1: fires above the threshold without a perimeter
big = sized[(sized.acres >= 300) & ~sized.perimeter]
print(f"{len(big)} incidents of 300 acres or more have no matched perimeter, {big.acres.sum():,.0f} acres:")
print(big.nlargest(5, "acres")[["IncidentName", "acres", "agency"]].to_string(index=False))
10 incidents of 300 acres or more have no matched perimeter, 22,694 acres:
IncidentName acres agency
ROBERTS 7030.25 CAL FIRE
BOLT 3-2 6743.00 CAL FIRE
FLASH 3-3 3600.00 CAL FIRE
WEST 1575.00 C&L
HAY 869.00 CAL FIRE
Heavy tails: area captured, fires missed
Fire sizes follow a heavy-tailed distribution: a few fires burn almost all the area, and almost all fires stay tiny. A database with a size threshold therefore captures the area and misses the fires. That suits its purpose: perimeters exist to show what burned, for fire history, fuel planning and reburn analysis, and a fire of a tenth of an acre has nothing useful to map.
The trouble comes when the data is used for something else. Ignition counts, cause studies, the share of fires started by people, the density of fires along roads: all of these need the small fires that the perimeters leave out. The incident record has them, as points of origin with a reported size and cause. Use perimeters for area, incidents for counts, and say which you used.
Reported sizes, missing ids and prescribed burns
- Reported sizes in WFIGS are what the incident reported, sometimes before final mapping; they will not equal
GIS_ACRESfor the same fire. - No IRWIN ID: contract-county and local perimeters often lack one; match them by name, date and location instead, and expect some to stay unmatched.
- Older years: IRWIN started in 2014 and the perimeter layer has carried its ID only since 2022; earlier fires need a spatial and date match.
- Prescribed burns are a separate CAL FIRE layer; WFIGS marks them with
IncidentTypeCategory = 'RX'. - Complexes appear as one perimeter but several incidents; count them deliberately.
Internal links
- How to download CAL FIRE wildfire perimeters β the perimeter data
- How to map fire history and reburns β what perimeters are good for
FAQ
Why does the fire perimeter database have so few fires?
It only includes fires above a size threshold: 10 acres in timber, 50 in brush, 300 in grass for CAL FIRE, and 10 acres for other agencies. Most wildfires are much smaller.
Where can I get data on all wildfires, including small ones?
From incident records: NIFC's WFIGS Incident Locations for recent years, or the US Forest Service Fire Program Analysis fire-occurrence database (FPA FOD) for a long national record.
Can I use perimeters to count wildfires?
No. Use them for burned area; count fires from incident or occurrence records.
How do I join fire perimeters to incident records?
On the IRWIN ID where both have it. Otherwise, match by name, discovery date and location.