How to work with fire perimeter data in Python
On this page
Problem statement
Fire perimeters look like the simplest hazard layer there is: a polygon per fire. In practice one fire has many perimeters β a new one after each mapping flight β several fires share a name, the area attribute is in acres and disagrees with the geometry, and the dates are epoch milliseconds in a field whose name is not obvious. Query the US interagency service for a fire called Park and you get thirteen features, from California, New Mexico, Texas, Wyoming and elsewhere, spread over seven years.
This guide pulls perimeters from the US interagency WFIGS service, picks the right feature for one fire, and reconciles its reported acreage with the polygon's own area.
Quick answer
Query the service with a where clause, then filter on state, size and date:
import json, urllib.parse, urllib.request
import geopandas as gpd, pandas as pd
SERVICE = ("https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/"
"WFIGS_Interagency_Perimeters/FeatureServer/0/query")
def perimeters(where, out_sr=4326):
url = SERVICE + "?" + urllib.parse.urlencode(
{"where": where, "outFields": "*", "f": "geojson", "outSR": out_sr})
req = urllib.request.Request(url, headers={"User-Agent": "example/1.0"})
gj = json.loads(urllib.request.urlopen(req, timeout=120).read())
gdf = gpd.GeoDataFrame.from_features(gj["features"], crs=out_sr)
for col in ("attr_FireDiscoveryDateTime", "poly_DateCurrent"):
if col in gdf:
gdf[col] = pd.to_datetime(gdf[col], unit="ms", utc=True)
return gdf
fires = perimeters("poly_IncidentName='PARK' OR poly_IncidentName='Park'")
print(f"{len(fires)} features named Park")
print(fires[["poly_IncidentName", "attr_POOState", "poly_GISAcres",
"attr_FireDiscoveryDateTime"]].sort_values("poly_GISAcres", ascending=False)
.head(5).to_string(index=False))
13 features named Park
poly_IncidentName attr_POOState poly_GISAcres attr_FireDiscoveryDateTime
PARK US-CA 429602.850915 2024-07-24 21:52:43+00:00
PARK US-CA 318.320000 2020-07-05 06:00:17+00:00
PARK US-NM 300.000000 2023-05-03 19:15:00+00:00
Park US-TX 50.689652 2026-02-27 18:45:37+00:00
Park US-WY 12.100000 2024-10-24 20:15:06+00:00
Names are not identifiers. attr_POOState, the discovery date and the size are what separate the fire you mean from the others.
Query, deduplicate, parse dates, reconcile area
1. Query with where, not with a download
The service supports SQL-ish filters and returns GeoJSON. Pulling the whole layer to filter locally is slow and unnecessary.
2. Expect duplicates and near-duplicates
Incident names repeat between states and years. Filter on state and discovery date, and check how many features remain.
3. Parse the dates
Date fields arrive as epoch milliseconds. pd.to_datetime(..., unit="ms", utc=True) is the conversion; without it, a 13-digit integer sorts fine and reads as nonsense.
4. Reconcile the area
poly_GISAcres is the mapper's figure. The polygon's own area in an equal-area projection is a check on it; small differences are normal, large ones mean the feature is not what you think.
5. Project before measuring
Areas and distances need an equal-area or local projected CRS. For California, EPSG:3310; for a continental summary, an equal-area projection covering it.
6. Keep the perimeter's date
A perimeter is a snapshot from a flight or a satellite pass. Its date is part of its meaning, particularly for a fire that burned for weeks.
From a fire's name to its perimeter
Example 1 β from a name to one fire
park = fires[(fires.attr_POOState == "US-CA") & (fires.poly_GISAcres > 1000)]
print(f"California features over 1,000 acres: {len(park)}")
row = park.sort_values("poly_GISAcres", ascending=False).iloc[0]
print(f"{row.poly_IncidentName}: {row.poly_GISAcres:,.0f} acres, "
f"discovered {row.attr_FireDiscoveryDateTime:%Y-%m-%d}, "
f"perimeter current {row.poly_DateCurrent:%Y-%m-%d}")
California features over 1,000 acres: 1
PARK: 429,603 acres, discovered 2024-07-24, perimeter current 2024-08-24
Example 2 β acres against geometry
one = park.sort_values("poly_GISAcres", ascending=False).head(1)
for name, crs in [("WGS 84 (degrees)", 4326), ("California Albers", 3310),
("UTM 10N", 32610), ("Web Mercator", 3857)]:
area = one.to_crs(crs).area.iloc[0]
acres = area * 0.000247105 if crs != 4326 else float("nan")
print(f"{name:20} area {area:14,.0f} {'degΒ²' if crs == 4326 else 'mΒ²':4} "
f"{'' if crs == 4326 else f'= {acres:9,.0f} acres'}")
print(f"reported poly_GISAcres: {one.poly_GISAcres.iloc[0]:,.0f}")
WGS 84 (degrees) area 0 degΒ²
California Albers area 1,744,389,978 mΒ² = 431,047 acres
UTM 10N area 1,743,449,312 mΒ² = 430,815 acres
Web Mercator area 2,986,239,063 mΒ² = 737,915 acres
reported poly_GISAcres: 429,603
Example 3 β several fires at once
big = perimeters("attr_POOState='US-CA' AND poly_GISAcres>50000 "
"AND attr_FireDiscoveryDateTime>TIMESTAMP '2024-01-01 00:00:00'")
big["km2"] = big.to_crs(3310).area / 1e6
print(big[["poly_IncidentName", "km2", "attr_FireDiscoveryDateTime"]]
.sort_values("km2", ascending=False).head(6).to_string(index=False, float_format="%.0f"))
poly_IncidentName km2 attr_FireDiscoveryDateTime
PARK 1744 2024-07-24 21:52:43+00:00
Gifford 533 2025-08-01 20:57:00+00:00
Bug 379 2026-08-08 22:30:49+00:00
Madre 327 2025-07-02 20:07:00+00:00
Biscar 281 2026-07-18 18:06:09+00:00
Garnet 249 2025-08-24 07:10:00+00:00
Why one fire has many perimeters
Why one fire has many perimeters
Perimeters are mapped repeatedly while a fire burns β by infrared flights, by satellite, by hand. Some services keep every version; WFIGS keeps current and historic layers separately. A query that returns several polygons for one incident is usually returning its history.
Why acres and geometry disagree
The acreage attribute is recorded by the incident management team, often from a different mapping of the fire and rounded. The polygon's area is whatever the geometry says in whatever CRS you project it to. Web Mercator inflates area badly away from the equator, so a check that uses it will always disagree.
Why the date fields matter more than usual
A fire perimeter without a date cannot be matched to imagery. Burn severity needs a pre-fire scene before the discovery date and a post-fire scene after containment; a perimeter dated mid-fire will not contain everything the fire eventually burned.
Other countries, MTBS and complexes
- Other countries: EFFIS publishes European perimeters; each country has its own service too.
- Historic layers hold fires from previous years; the current layer is only the active season.
- MTBS in the US publishes mapped perimeters and severity for large fires, several years behind.
- Complexes are several fires managed as one incident and may share a perimeter.
- Holes inside a perimeter are unburned islands; keep them rather than filling the polygon.
- Rate limits:
resultRecordCountandresultOffsetpage a large query.
Internal links
- How to map burn severity with dNBR in Python β what a perimeter is usually for
- Burn severity explained β perimeter against severity
- How to count buildings and people exposed to a hazard zone β perimeters as exposure zones
- Hazard, exposure and vulnerability explained β the wider frame
- API GeoJSON comes back empty or truncated β paged feature services
- How to calculate area and distance in GeoPandas β projections and area
FAQ
Where do US fire perimeters come from?
The WFIGS interagency service, published as an ArcGIS feature service that returns GeoJSON.
Why does my query return several fires?
Incident names are reused across states and years. Filter on state, date and size.
Why does the acreage not match the polygon?
The attribute is the incident team's figure; the geometry is yours to project. Check in an equal-area CRS.
What date should I use to choose imagery?
Discovery date for the pre-fire scene; the perimeter's current date, or containment, for the post-fire one.
Are perimeters final?
Only after the season; during a fire they are updated repeatedly.
Can I get perimeters outside the US?
Yes β EFFIS covers Europe, and many countries publish their own.