Why Are My Fire Perimeter Areas Wrong or Raising TopologyException?

On this page

Problem statement

Summed as published, CAL FIRE's 94 invalid fire perimeters from 2021–2025 come to 3,654,952 acres; the agency's own figure for the same fires is 3,564,641. The Dixie Fire alone measures 993,599 acres against an official 963,405, and the 2022 Mill Fire 16.5% too large, because unburned islands inside the outline were stored as extra shells on top of it instead of holes.

They are 94 of the 746 perimeters in CAL FIRE's last-five-years view, among them the Dixie, Park, Palisades and Eaton fires. This page finds them, measures what they do to areas and overlays, and shows why the common buffer(0) trick gives the wrong answer for these fires while make_valid() gives the right one.

Quick answer

Check validity, repair with make_valid(), and confirm against the published acreage:

bad = P[~P.is_valid]
fixed = bad.geometry.make_valid()
print(f"{len(bad)} of {len(P)} perimeters invalid; raw area {bad.area.sum() / ACRE_M2:,.0f} acres, "
      f"after make_valid {fixed.area.sum() / ACRE_M2:,.0f}, GIS_ACRES {bad.GIS_ACRES.sum():,.0f}")
94 of 746 perimeters invalid; raw area 3,654,952 acres, after make_valid 3,564,660, GIS_ACRES 3,564,641
Three versions of the 2022 Mill Fire perimeter: as published, two unburned islands are stored as extra parts on top of the outer shell, giving 4,591 acres; after buffer(0) the islands are filled in as burned, 4,265 acres; after make_valid() they are holes, 3,939 acres, matching the official GIS_ACRES. Three versions of the 2022 Mill Fire perimeter: as published, two unburned islands are stored as extra parts on top of the outer shell, giving 4,591 acres; after buffer(0) the islands are filled in as burned, 4,265 acres; after make_valid() they are holes, 3,939 acres, matching the official GIS_ACRES.
The Mill Fire's two unburned islands are stored as nested shells. Only make_valid() turns them into holes and reproduces the official 3,939 acres.

Find the invalid polygons, measure the error, repair properly

1. Find the invalid polygons and why

shapely.validation.explain_validity names the first problem in each geometry:

reason = bad.geometry.apply(lambda g: explain_validity(g).split("[")[0])
print(reason.value_counts().to_string())
geometry
Ring Self-intersection    52
Nested shells             42

A ring self-intersection is an outline that touches or crosses itself, usually where two mapped edges were joined. Nested shells are polygons stored inside other polygons as separate parts, when they should be holes. In fire perimeters they are almost always unburned islands.

2. Measure the area error

A nested shell adds its area instead of subtracting it, so the raw area includes every unburned island twice over: once in the outer shell and once as an extra part. Compare with make_valid() and with CAL FIRE's own GIS_ACRES:

cmp = bad.assign(raw=bad.area / ACRE_M2, valid=fixed.area / ACRE_M2)
cmp["over"] = cmp.raw / cmp.valid - 1
print(cmp.nlargest(6, "over")[["FIRE_NAME", "YEAR_", "GIS_ACRES", "raw", "valid", "over"]]
      .to_string(index=False, formatters={"GIS_ACRES": "{:,.0f}".format, "raw": "{:,.0f}".format, "valid": "{:,.0f}".format, "over": "{:+.1%}".format}))
    FIRE_NAME  YEAR_ GIS_ACRES     raw   valid   over
         MILL   2022     3,939   4,591   3,939 +16.5%
       PEARCH   2023    12,152  13,626  12,152 +12.1%
         LAKE   2024    38,610  42,592  38,610 +10.3%
     MOSQUITO   2023    34,188  37,498  34,188  +9.7%
        POINT   2024       462     496     462  +7.4%
RIVER COMPLEX   2021   199,354 213,686 199,354  +7.2%
Bar chart of the 14 invalid CAL FIRE perimeters whose raw area most exceeds the repaired area: Mill 2022 by 16.5 percent, Pearch 2023 by 12.1, Lake 2024 by 10.3, Mosquito 2023 by 9.7, down to Airport 2022 by 3.8; River Complex 2021 is over by the most acres, 14,332. Bar chart of the 14 invalid CAL FIRE perimeters whose raw area most exceeds the repaired area: Mill 2022 by 16.5 percent, Pearch 2023 by 12.1, Lake 2024 by 10.3, Mosquito 2023 by 9.7, down to Airport 2022 by 3.8; River Complex 2021 is over by the most acres, 14,332.
Unrepaired, the largest overstatements come from fires with large unburned islands.

3. Do not use buffer(0) here

buffer(0) is the old way to repair a polygon. It rebuilds the geometry as the union of its parts, so a nested shell disappears into the shell around it. The island is filled in, counted as burned:

for name, year in (("DIXIE", 2021), ("MILL", 2022), ("PALISADES", 2025)):
    r = P[(P.FIRE_NAME == name) & (P.YEAR_ == year)]
    structure = shapely.make_valid(r.geometry.values, method="structure")
    print(f"{name.title():10s} raw {r.area.sum() / ACRE_M2:>9,.0f}  buffer(0) {r.buffer(0).area.sum() / ACRE_M2:>9,.0f}  "
          f"structure {shapely.area(structure).sum() / ACRE_M2:>9,.0f}  make_valid {r.make_valid().area.sum() / ACRE_M2:>9,.0f}  "
          f"GIS_ACRES {r.GIS_ACRES.sum():>9,.0f}")
Dixie      raw   993,599  buffer(0)   978,467  structure   978,467  make_valid   963,406  GIS_ACRES   963,405
Mill       raw     4,591  buffer(0)     4,265  structure     4,265  make_valid     3,939  GIS_ACRES     3,939
Palisades  raw    24,054  buffer(0)    23,751  structure    23,751  make_valid    23,449  GIS_ACRES    23,449

Shapely 2.1's make_valid(..., method="structure") does the same. The default make_valid() keeps every part and resolves the overlap, so a part inside another becomes a hole. That matches how the fire was mapped.

4. See which operations fail and which hide it

Some operations raise on invalid input, some repair it, and some carry the error through silently:

dixie = P[(P.FIRE_NAME == "DIXIE") & (P.YEAR_ == 2021)]
try:
    gpd.clip(dixie, COUNTIES)
except Exception as e:
    print("clip:", type(e).__name__, str(e).split(" at ")[0])
print(f"overlay: {gpd.overlay(dixie, COUNTIES, how='intersection').area.sum() / ACRE_M2:,.0f} acres (validates first by default)")
mill = P[(P.FIRE_NAME == "MILL") & (P.YEAR_ == 2022)]
print(f"clip, Mill Fire: {gpd.clip(mill, COUNTIES).area.sum() / ACRE_M2:,.0f} acres, no error (GIS_ACRES {mill.GIS_ACRES.sum():,.0f})")
clip: GEOSException TopologyException: side location conflict
overlay: 963,406 acres (validates first by default)
clip, Mill Fire: 4,591 acres, no error (GIS_ACRES 3,939)

GeoPandas' overlay repairs its inputs (make_valid=True is the default), which is why it gets the area right. clip does not. Repair once, when the data is loaded, and every later step can rely on it.

Repairing on load and recording what changed

The data used on this page

CAL FIRE California Fire Perimeters, last-five-years public view (California_Fire_Perimeters_last_5_years_View, fires over 50 acres), projected to California Albers (EPSG:3310), and the 2023 county outlines from the Census Bureau's cartographic boundary files.

from pathlib import Path

import geopandas as gpd
import requests
import shapely
from shapely.validation import explain_validity

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)
if not Path("data/cb_2023_us_county_20m.zip").exists():
    Path("data/cb_2023_us_county_20m.zip").write_bytes(requests.get("https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_20m.zip", timeout=300).content)
P = gpd.read_file(GEOJSON).to_crs(3310)
COUNTIES = gpd.read_file("data/cb_2023_us_county_20m.zip").query("STATEFP == '06'").to_crs(3310)[["NAME", "geometry"]]
ACRE_M2 = 4046.8564224
print(f"{len(P)} perimeters, {len(COUNTIES)} counties")
746 perimeters, 58 counties

Example 1: the whole dataset

all_raw = P.area.sum() / ACRE_M2
all_valid = P.make_valid().area.sum() / ACRE_M2
print(f"fires over 50 acres, 2021–2025: raw {all_raw:,.0f} acres, valid {all_valid:,.0f}, GIS_ACRES {P.GIS_ACRES.sum():,.0f}; "
      f"raw overstates by {all_raw - all_valid:,.0f} acres ({all_raw / all_valid - 1:.1%})")
fires over 50 acres, 2021–2025: raw 4,769,922 acres, valid 4,679,630, GIS_ACRES 4,678,953; raw overstates by 90,292 acres (1.9%)

Example 2: repair on load and record what changed

clean = P.assign(was_valid=P.is_valid, geometry=P.make_valid())
clean = clean[clean.geom_type.isin(["Polygon", "MultiPolygon"])]
clean.to_file("data/calfire_last5_valid.gpkg", driver="GPKG")
print(f"{(~clean.was_valid).sum()} repaired, {clean.is_valid.all() and 'all valid now'}; types: {clean.geom_type.value_counts().to_dict()}")
94 repaired, all valid now; types: {'Polygon': 500, 'MultiPolygon': 246}

What the Simple Features rules require

The OGC Simple Features rules say a polygon is one outer ring with optional holes. The rings must not cross, and holes must lie inside the shell. A multipolygon's parts must not overlap. GEOS, the engine under Shapely and GeoPandas, assumes these rules hold and gives no guarantee when they do not. Some functions raise, some return nonsense, and .area simply adds up the areas of the parts it is given, overlapping or not.

The polygon validity rules, checked on 746 CAL FIRE perimeters from 2021 to 2025. valid polygon rules, on 746 perimetersrings do not cross or touch themselvesfirst problem in 52: ring self-intersectionparts do not lie inside other partsfirst problem in 42: nested shellsraw area matches GIS_ACRES3,654,952 against 3,564,641 acres on the 94all valid after make_valid()746 of 746repaired area matches GIS_ACRES3,564,660 against 3,564,641 acresRepair once, on load; every later step can rely on it spatialworkflow.io
The rules a valid polygon must follow, and how these perimeters fare before and after repair.

Fire perimeters break the rules for understandable reasons. An unburned island mapped as its own polygon and then merged with the fire becomes a nested shell. Two edges mapped on different days and joined at a point become a self-intersection. make_valid() interprets the geometry the way it was meant to be read: overlapping parts cancel, so an island becomes a hole. buffer(0) interprets it as a union, so an island becomes burned land. For fires the first reading is correct, as GIS_ACRES confirms.

Collections, reprojection and overlay

Invalid fire perimeter problems and fixes. invalid perimeter problemsSymptomFixarea above the official acresnested shells: make_valid() onloadTopologyException in clipmake_valid() first; overlay doesit for youunburned islands turned burnednot buffer(0) ormethod="structure"lines or points after repairkeep only Polygon and MultiPolygonCAL FIRE perimeters, 2021–2025: 94 of 746 invalid spatialworkflow.io
Invalid perimeter problems and fixes.
  • make_valid can return collections: a polygon that collapses in part can come back as a GeometryCollection with lines or points; keep only polygonal parts.
  • Validate after reprojecting: a valid polygon in one CRS can become invalid in another when vertices very close together snap.
  • overlay validates by default; sjoin, clip, union_all and dissolve do not.
  • method="structure" in Shapely 2.1 unions overlapping parts, like buffer(0), and fills the same islands (step 3). Keep the default "linework" method for fire perimeters.
  • Report it: if a published perimeter is invalid, the agency's own area field is the best check of your repair.

FAQ

What does TopologyException: side location conflict mean?

GEOS found a geometry whose rings cross or overlap where they should not. Repair the input with make_valid() before the operation.

Should I use buffer(0) or make_valid()?

make_valid(). buffer(0) unions the parts of a polygon, which fills in holes stored as nested shells; for fire perimeters that counts unburned islands as burned.

How do I find invalid geometries in GeoPandas?

gdf[~gdf.is_valid], and shapely.validation.explain_validity(geom) for the reason.

Why is the raw area larger than the official area?

A nested shell adds its area to the total instead of being subtracted as a hole, so unburned islands are counted as burned.