How to Download CAL FIRE Wildfire Perimeters in Python

On this page

Problem statement

CAL FIRE serves its fire perimeters through a public ArcGIS feature service: a query URL that takes a where clause and answers in GeoJSON, a cause field stored as numeric codes whose meanings live in the service's own domain, and an acreage the agency computed. Turning that into a table you can trust means one REST request, a lookup of the domain, areas recomputed in an equal-area projection and checked against the agency's, and a repair of the polygons that arrive invalid.

This page downloads CAL FIRE's perimeters for 2021–2025, checks the areas against the official acreage, and maps the 2024 season, dominated by the Park Fire.

Quick answer

Query the feature service as GeoJSON in WGS 84, read it with GeoPandas, and measure areas in California Albers (EPSG:3310):

fires = gpd.read_file(GEOJSON).to_crs(3310)
fires["geometry"] = fires.geometry.make_valid()
fires["acres_calc"] = fires.area / 4046.8564224
y24 = fires[fires.YEAR_ == 2024]
park = y24.loc[y24.FIRE_NAME == "PARK"].iloc[0]
print(f"{len(fires)} perimeters 2021–2025; 2024: {len(y24)} fires over 50 acres, {y24.acres_calc.sum():,.0f} acres")
print(f"Park Fire: {park.acres_calc:,.0f} acres computed, {park.GIS_ACRES:,.1f} official; cause {CAUSE[park.CAUSE]}, "
      f"alarm {pd.to_datetime(park.ALARM_DATE, unit='ms'):%d %b %Y}")
746 perimeters 2021–2025; 2024: 218 fires over 50 acres, 1,006,426 acres
Park Fire: 429,603 acres computed, 429,602.8 official; cause Arson, alarm 24 Jul 2024
Map of California with the 218 perimeters of 2024 fires over 50 acres in red: the Park Fire in the northern Sacramento Valley foothills is by far the largest at 429,603 acres, with the Borel, Bridge, Line and Lake fires in Southern California labelled. Map of California with the 218 perimeters of 2024 fires over 50 acres in red: the Park Fire in the northern Sacramento Valley foothills is by far the largest at 429,603 acres, with the Borel, Bridge, Line and Lake fires in Southern California labelled.
CAL FIRE's 2024 perimeters of fires over 50 acres. The Park Fire alone burned 43% of the 1,006,426 acres.

Query, decode, check areas, repair geometry

1. Query the service

ArcGIS feature services answer a query request with any where clause; outSR=4326 and f=geojson return standard GeoJSON. This view holds the last five years of perimeters, and only fires over 50 acres: its definition query says so. That keeps it well under the service's 2,000-record page size, so one request fetches it all:

print(f"{len(raw)} features; service page size {META['maxRecordCount']}; years {sorted(raw.YEAR_.unique().tolist())}")
print("view filter:", META.get("viewDefinitionQuery"))
print(raw[["YEAR_", "FIRE_NAME", "GIS_ACRES", "CAUSE", "ALARM_DATE"]].head(3).to_string(index=False))
746 features; service page size 2000; years [2021, 2022, 2023, 2024, 2025]
view filter: YEAR_ IN (2024,2025,2021,2022,2023) AND GIS_ACRES > 50
 YEAR_ FIRE_NAME  GIS_ACRES  CAUSE    ALARM_DATE
  2025   GIFFORD  131613.50     14 1754006400000
  2025     MADRE   80779.10     14 1751414400000
  2025    GARNET   60019.23      1 1755993600000

2. Decode the codes from the service

CAUSE, AGENCY and C_METHOD are numeric or short codes. Their meanings are published in the layer's metadata as coded-value domains. Read them from there instead of typing them in:

print(f"{len(CAUSE)} cause codes from the service, e.g. 7 = {CAUSE[7]!r}, 11 = {CAUSE[11]!r}")
by_cause = fires.groupby(fires.CAUSE.map(CAUSE)).acres_calc.sum().sort_values(ascending=False)
print((by_cause / by_cause.sum()).head(5).map("{:.0%}".format).to_string())
19 cause codes from the service, e.g. 7 = 'Arson', 11 = 'Electrical Power'
CAUSE
Unknown / Unidentified    32%
Lightning                 32%
Electrical Power          21%
Arson                     10%
Miscellaneous              2%

3. Check areas against the official acreage

Compute areas in an equal-area projection and compare with GIS_ACRES. For the large fires they should agree closely:

big = fires[fires.GIS_ACRES > 1000]
ratio = big.acres_calc / big.GIS_ACRES
print(f"{len(big)} fires over 1,000 acres: computed / official median {ratio.median():.4f}, range {ratio.min():.3f}–{ratio.max():.3f}")
176 fires over 1,000 acres: computed / official median 1.0000, range 0.942–1.000
Map of the Park Fire perimeter straddling the Tehama and Butte county line in Northern California, a large red shape with a long arm to the north-east and a small unburned island near its southern end. Map of the Park Fire perimeter straddling the Tehama and Butte county line in Northern California, a large red shape with a long arm to the north-east and a small unburned island near its southern end.
The Park Fire's perimeter is 63 separate polygons with 7 unburned islands inside: a single fire is rarely a single simple polygon.

4. Repair invalid geometry

Hand-edited perimeters often contain self-intersections. Shapely flags them, and area or overlay operations on them can fail or give wrong answers:

print(f"invalid geometries as downloaded: {(~raw.geometry.is_valid).sum()} of {len(raw)} ({(~raw.geometry.is_valid).mean():.0%})")
print(f"after make_valid: {(~fires.geometry.is_valid).sum()} invalid")
invalid geometries as downloaded: 94 of 746 (13%)
after make_valid: 0 invalid

A season map of California fires

The data used on this page

CAL FIRE California Fire Perimeters, last-five-years public view (California_Fire_Perimeters_last_5_years_View on ArcGIS Online), and the 2023 state outline from the Census Bureau's cartographic boundary files.

from pathlib import Path

import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
LAYER = "https://services1.arcgis.com/jUJYIo9tSA7EHvfZ/arcgis/rest/services/California_Fire_Perimeters_last_5_years_View/FeatureServer/0"
GEOJSON = Path("data/calfire_last5.geojson")
if not GEOJSON.exists():
    r = requests.get(LAYER + "/query", params={"where": "1=1", "outFields": "*", "outSR": 4326, "f": "geojson"}, timeout=300)
    GEOJSON.write_bytes(r.content)
META = requests.get(LAYER, params={"f": "json"}, timeout=60).json()
DOMAINS = {f["name"]: {c["code"]: c["name"] for c in f["domain"]["codedValues"]} for f in META["fields"] if f.get("domain")}
CAUSE = DOMAINS["CAUSE"]
raw = gpd.read_file(GEOJSON)
if not Path("data/cb_2023_us_state_20m.zip").exists():
    Path("data/cb_2023_us_state_20m.zip").write_bytes(requests.get("https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_state_20m.zip", timeout=300).content)
CA = gpd.read_file("data/cb_2023_us_state_20m.zip").query("STUSPS == 'CA'").to_crs(3310)
print(f"{len(raw)} perimeters downloaded; {len(DOMAINS)} coded fields: {sorted(DOMAINS)}")
746 perimeters downloaded; 6 coded fields: ['AGENCY', 'CAUSE', 'C_METHOD', 'OBJECTIVE', 'STATE', 'UNIT_ID']

Example 1: a season map

fig, ax = plt.subplots(figsize=(6, 8))
CA.plot(ax=ax, color="#f2efe9", edgecolor="#888888", lw=0.5)
y24.plot(ax=ax, color="#c0392b", edgecolor="none")
y24.nlargest(5, "acres_calc").apply(lambda r: ax.annotate(r.FIRE_NAME.title(), r.geometry.representative_point().coords[0],
                                                          fontsize=7, ha="center"), axis=1)
ax.set_axis_off(); fig.savefig("data/ca_fires_2024.png", dpi=150)
print("largest 2024 fires:", ", ".join(f"{r.FIRE_NAME.title()} {r.acres_calc:,.0f}" for _, r in y24.nlargest(5, "acres_calc").iterrows()))
largest 2024 fires: Park 429,603, Borel 59,289, Bridge 55,724, Line 43,976, Lake 38,610

Three habits of perimeter data that catch people out

A perimeter dataset is a polygon layer with an attribute table, but three of its habits catch people out. Attributes are coded, with the codes explained elsewhere, in the service metadata or a PDF. Geometry is drawn by people under time pressure from GPS tracks, infrared flights and imagery, so self-intersections are common. And the official acreage is computed by the agency in its own projection, so your area should be computed in an equal-area projection to compare.

Share of burned area in CAL FIRE perimeters 2021–2025 by recorded cause. what burned the acres, 2021–2025Unknown / Unidentified32%Lightning32%Electrical Power21%Arson10%Miscellaneous2%020%40%Cause names read from the feature service’s coded-value domain spatialworkflow.io
Burned area by recorded cause, with names read from the service.

Once those are handled, a perimeter layer answers the first questions directly: how much burned, where, from what cause. Most later analysis is an overlay of the perimeter with another layer: earlier perimeters to find reburns, buildings to count exposure, satellite images to grade severity. That is why getting the perimeters clean is worth the effort.

Epoch dates, interim perimeters and complexes

Problems downloading and using wildfire perimeters, and fixes. perimeter problemsSymptomFixCAUSE = 7 means nothingread coded domains from ?f=jsonoverlay fails on 13% of firesmake_valid() before any overlayarea far from official acreagemeasure in EPSG:3310, not 4326 or3857dates like 1721779200000pd.to_datetime(..., unit="ms")CAL FIRE California Fire Perimeters, last five years spatialworkflow.io
Perimeter problems and fixes.
  • Dates are milliseconds since 1970 in UTC; convert with pd.to_datetime(..., unit="ms").
  • Older and smaller fires: the full CAL FIRE archive (California_Historic_Fire_Perimeters) goes back over a century and includes fires under 50 acres; this view covers only the last five years and drops them (see why most wildfires are missing from perimeter data).
  • Final vs interim: perimeters are revised after a fire; check the dates and C_METHOD (collection method).
  • Complexes: several fires managed together share COMPLEX_NAME; decide whether to count them separately.
  • Outside California: NIFC's WFIGS services publish perimeters nationally with different field names.

FAQ

Where can I download wildfire perimeters for California?

CAL FIRE publishes them through public ArcGIS feature services (all years and a last-five-years view) and as downloadable geodatabases; NIFC publishes national perimeters.

How do I query an ArcGIS feature service in Python?

Send a GET request to the layer's /query endpoint with where, outFields, outSR=4326 and f=geojson, then read the response with GeoPandas. Page with resultOffset if there are more features than the page size.

What do the CAUSE codes mean?

Read them from the layer metadata (?f=json), where each coded field lists its values: in CAL FIRE's perimeters 7 is arson, 11 electrical power, 1 lightning.

Why is my computed fire area different from the official acreage?

Usually because the area was computed in degrees or Web Mercator; use an equal-area projection such as California Albers (EPSG:3310).