California has lost thousands of homes to wildfire in recent years. One way that state building code regulations, insurers, and grant programs have responded is by promoting home hardening: building or retrofitting homes so that embers and heat are less likely to ignite them. Some of these changes are expensive, like replacing roofs, while others are cheap, like covering attic vents. CAL FIRE has collected this Damage Inspection (DINS) data to document losses, but because they include construction details like roof material and vent screens, they can also be used to compare how hardened and unhardened homes fared. We’ll interpret their hand-collected data to determine which strategies are most effective, answering the question
Did homes with hardening features survive wildfires more often than homes without them, and which kinds of home hardening matter most?
This report breaks the research questions into three parts: how much the share of homes destroyed varies from fire to fire, how home hardening features are associated with home survival within the same fire, and which features made the most difference, studying whether small, inexpensive fixes hold up against larger ones.
CAL FIRE’s Office of the State Fire Marshal publishes the inspections as the CAL FIRE Damage Inspection (DINS) data layer on ArcGIS Online. The copy used here was downloaded on October 16th, 2026, and is the same file cleaned in Lab 2, with 132,522 structures from fires that started between 2013 and 2025. Each row is one structure inspected after the fire.
For every structure, inspectors fill in:
The construction and fuels fields describe the hardening features this report compares. I grouped them into four categories, plus year built as a proxy for the updated building code changes in 2008:
| Category | DINS field | Values inspectors choose from | Hardened if |
|---|---|---|---|
| Roof | Roof Construction | Asphalt, Wood, Tile, Metal, Concrete, Fire Resistant, Combustible, Other | Tile, metal, concrete, or fire resistant |
| Home materials | Exterior Siding | Wood, Vinyl, Stucco/Brick/Cement, Metal, Ignition Resistant, Combustible, Other | Not wood, vinyl, or combustible |
| Home materials | Window Pane | Single Pane, Multi Pane, No Windows | Multi-pane |
| Small mitigations | Vent Screen | Mesh Screen <= 1/8”, Mesh Screen > 1/8”, Unscreened, No Vents | Fine mesh, or no vents |
| Small mitigations | Eaves | Enclosed, Unenclosed, No Eaves | Enclosed |
| Defensible space | Deck/Porch (on grade and elevated) | Wood, Composite, Masonry/Concrete, Metal, No Deck/Porch | No wood deck or porch |
| Defensible space | Fence Attached to Structure | Combustible, Non Combustible, No Fence | No fence, or non-combustible |
| Defensible space | Patio Cover/Carport Attached | Combustible, Non Combustible, None | No cover, or non-combustible |
| Defensible space | Distance to Propane Tank | 0-10, 11-20, 21-30, >30 ft | 21 ft or more |
| Defensible space | Distance to Outbuilding | <30’, 30-50’, >50’ | 30 ft or more |
| Building code | Year Built (parcel) | Year | 2008 or later |
Every field also allows “Unknown”, and many are left blank.
Three limitations should be noted before any analysis:
Besides the Lab 2 packages, patchwork allows for the combination of several ggplot2 plots into one figure, and RColorBrewer provides color palettes designed for maps and charts.
library(tidyverse) # data wrangling
library(here) # builds file paths from project root
library(janitor) # clean_names() for tidy column names
library(patchwork) # combines ggplots into one figure
library(RColorBrewer) # color palettes
# set one theme for every plot in the report
theme_set(
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(hjust = 0.5), # center titles over the plot
plot.subtitle = element_text(hjust = 0.5)
)
)
dins <- read_csv(here("data", "dins_raw.csv"))
Before sorting each feature into hardened/not hardened, I counted the values inspectors entered. Vent screen is a good example of why:
dins |>
clean_names() |>
count(vent_screen, sort = TRUE)
## # A tibble: 11 × 2
## vent_screen n
## <chr> <int>
## 1 "Unknown" 40139
## 2 "Mesh Screen > 1/8\"\"" 34365
## 3 "No Vents" 26071
## 4 "Mesh Screen <= 1/8\"\"" 18226
## 5 "Screened" 5736
## 6 <NA> 5302
## 7 "Unscreened" 2679
## 8 "21-30" 1
## 9 ">30" 1
## 10 "Attached Fence" 1
## 11 "Deck Elevated" 1
The mesh labels have some stray quotation marks, so matching the full label could fail. I ran the same check on the other fields and found three more issues:
This wrangles each feature into “hardened”, “not_hardened”, or
missing (NA), following the table in the Data section
above. I chose to do this in a small function rather than a long series
of case_when(), since it seemed more efficient.
# Sort raw inspector categories into "hardened" / "not hardened"
# Values that are in neither list become NA
classify <- function(x, hardened, not_hardened) {
case_when(
x %in% hardened ~ "hardened",
x %in% not_hardened ~ "not_hardened"
)
}
no_wood_deck <- c("No Deck/Porch", "Masonry/Concrete", "Composite", "Metal")
homes <- dins |>
clean_names() |>
rename(
year_built = year_built_parcel,
propane_dist = starts_with("distance_propane"),
outbuilding_dist = starts_with("distance_residence")
) |>
mutate(
fire_year = as.integer(str_extract(incident_start_date, "\\d{4}")),
fire_id = paste(incident_name, fire_year), # Lab 2: fire names repeat across years
destroyed = damage == "Destroyed (>50%)"
) |>
# single homes in 2018+ fires since attachment fields were not recorded before 2018
filter(
structure_category == "Single Residence",
fire_year >= 2018,
damage != "Inaccessible"
) |>
mutate(
eaves_unknown = eaves == "Unknown", # kept to check recording quality later
# Roof
roof = classify(roof_construction,
c("Tile", "Metal", "Concrete", "Fire Resistant"),
c("Asphalt", "Wood", "Combustible")),
# Home materials
siding = classify(exterior_siding,
c("Stucco Brick Cement", "Stucco/Brick/Cement", "Metal",
"Fire Resistant", "Ignition Resistant"),
c("Wood", "Vinyl", "Combustible")),
windows = classify(window_pane, "Multi Pane", "Single Pane"),
# Small mitigations
vents = case_when(
str_detect(vent_screen, "<= 1/8") | vent_screen == "No Vents" ~ "hardened",
str_detect(vent_screen, "> 1/8") | vent_screen == "Unscreened" ~ "not_hardened"
),
eaves = classify(eaves, "Enclosed", "Unenclosed"),
# Defensible space (attached fuels and separation)
deck = case_when(
deck_porch_on_grade == "Wood" | deck_porch_elevated == "Wood" ~ "not_hardened",
deck_porch_on_grade %in% no_wood_deck &
deck_porch_elevated %in% no_wood_deck ~ "hardened"
),
fence = classify(fence_attached_to_structure,
c("No Fence", "Non Combustible"), "Combustible"),
patio = classify(patio_cover_carport_attached_to_structure,
c("No Patio Cover/Carport", "Non Combustible"), "Combustible"),
propane = classify(propane_dist, c("21-30", ">30"), c("0-10", "11-20")),
outbuilding = classify(outbuilding_dist, c("30-50'", ">50'"), "<30'"),
# Building code: California's WUI code (Chapter 7A) applies to homes built 2008 on.
# Placeholder zeros and impossible years (Lab 2) match neither line and become NA.
built_2008 = case_when(
year_built >= 2008 & year_built <= fire_year ~ "hardened",
year_built >= 1850 & year_built < 2008 ~ "not_hardened"
)
)
After filtering, the data holds 75,676 single-family homes from fires in 2018 or later, and 49.2% of them were destroyed.
To plot all eleven features with one ggplot() call, I
had to stack them in a single column. A lookup table gives each feature
a readable label and its category, and pivot_longer() does
the stacking.
features <- tribble(
~feature, ~label, ~category,
"roof", "Roof: tile, metal, or concrete", "Roof",
"siding", "Siding: non-combustible", "Home materials",
"windows", "Windows: multi-pane", "Home materials",
"vents", "Vents: fine mesh or none", "Small mitigations",
"eaves", "Eaves: enclosed", "Small mitigations",
"deck", "Deck/porch: no wood", "Defensible space",
"fence", "Attached fence: none or non-combustible", "Defensible space",
"patio", "Patio cover: none or non-combustible", "Defensible space",
"propane", "Propane tank: 21+ ft away", "Defensible space",
"outbuilding", "Outbuilding: 30+ ft away", "Defensible space",
"built_2008", "Built 2008 or later (WUI code)", "Building code"
)
homes_long <- homes |>
select(fire_id, destroyed, all_of(features$feature)) |>
pivot_longer(-c(fire_id, destroyed), names_to = "feature", values_to = "status") |>
drop_na(status) |> # drop unknowns per feature, not per home
left_join(features, by = "feature")
# one color per category, shared by Plots 2 and 3 so the legend means the same thing
category_colors <- setNames(brewer.pal(5, "Dark2"), unique(features$category))
Dropping missing values after pivoting was on purpose, because for example a home with unknown eaves still counts in the vent comparison. If I dropped every home with any unknown field first, I would keep about half the homes, and the ones kept would lean toward survivors, because destroyed homes are recorded less completely.
How complete is each feature once unknowns are removed?
homes_long |>
count(category, label, name = "n_homes") |>
mutate(pct_of_homes = n_homes / nrow(homes) * 100) |>
arrange(desc(pct_of_homes)) |>
knitr::kable(digits = 0, col.names = c("Category", "Feature", "Homes", "% of homes"))
| Category | Feature | Homes | % of homes |
|---|---|---|---|
| Defensible space | Attached fence: none or non-combustible | 63151 | 83 |
| Home materials | Siding: non-combustible | 62976 | 83 |
| Roof | Roof: tile, metal, or concrete | 62020 | 82 |
| Defensible space | Deck/porch: no wood | 60112 | 79 |
| Building code | Built 2008 or later (WUI code) | 59154 | 78 |
| Defensible space | Patio cover: none or non-combustible | 59140 | 78 |
| Home materials | Windows: multi-pane | 56798 | 75 |
| Small mitigations | Vents: fine mesh or none | 53717 | 71 |
| Small mitigations | Eaves: enclosed | 39605 | 52 |
| Defensible space | Propane tank: 21+ ft away | 12619 | 17 |
| Defensible space | Outbuilding: 30+ ft away | 12419 | 16 |
Most features are known for three-quarters or more of the homes. Eaves are known for about half, and the propane and outbuilding distances for less than a fifth, so results for those three are a bit weaker in their sample size.
Before comparing homes with and without a feature, I checked how much the incident itself matters, because if some fires destroyed nearly all homes and others almost none, grouping all together would mix up fires with features.
# one row per fire: homes inspected and proportion destroyed
fires <- homes |>
group_by(fire_id, fire_year) |>
summarize(n = n(), pct_destroyed = mean(destroyed) * 100, .groups = "drop") |>
filter(n >= 20) # small fires are less conclusive for percentages
# left panel: one point per fire, by year
p1 <- fires |>
ggplot(aes(x = factor(fire_year), y = pct_destroyed)) +
geom_jitter(aes(size = n), width = 0.2, alpha = 0.5, color = "#B2182B") +
scale_size_area(max_size = 8, breaks = c(100, 1000, 10000), labels = scales::comma) +
scale_y_continuous(labels = scales::label_percent(scale = 1), limits = c(0, 100)) +
labs(
x = "Fire year",
y = "Homes destroyed",
size = "Homes inspected"
)
# right panel: histogram of the same values on the same vertical scale
p1_hist <- fires |>
ggplot(aes(y = pct_destroyed)) +
geom_histogram(binwidth = 10, boundary = 0, fill = "#B2182B", alpha = 0.5, color = "white") +
scale_y_continuous(limits = c(0, 100)) +
labs(x = "Number of fires", y = NULL) +
theme(axis.text.y = element_blank()) # left panel already labels this axis
# combine side by side with patchwork; the histogram is narrower
p1 + p1_hist +
plot_layout(widths = c(3, 1), guides = "collect") +
plot_annotation(
title = "The share of homes destroyed varied widely from fire to fire",
theme = theme(plot.title = element_text(hjust = 0.5))
)
Outcomes vary far more between fires than I expected. The typical fire destroyed 26% of the homes inspected, but individual fires range from 0% to 96%. The histogram shows that 53 of the 95 fires destroyed less than 30% of homes, while 10 destroyed 70% or more. The 2018 fires, which include the Camp, Woolsey, and Carr fires, sit well above every other year, with a median of 81%. Hazard components like wind, terrain, and how quickly a fire reached town drive these differences, none of which are properties of the home.
In terms of the rest of the analysis, hardening features are not spread evenly across fires, as the various incidents in this dataset affected homes that had been built in wildly different eras, price points, and landscapes. The Camp Fire burned through an older town with mostly asphalt roofs, while the 2025 Los Angeles fires burned newer, more expensive homes. If I pooled every home, a feature that happens to be common in the worst fires would look harmful no matter what it does. As a result, from here on, I compare homes within the same fire only (the percent destroyed among homes with a feature against the percent among homes without it). As an added bonus, this also cancels out differences in how inspections were recorded from year to year, since both groups in a fire were inspected under the same rules.
Fire research describes three ways a home ignites: embers blown ahead of the fire that enter openings or land on something combustible, radiant heat that breaks windows, and direct flame from fuels next to the house. Embers cause the majority of losses. Each hardening feature examined here blocks one of these pathways, so I expect homes with each feature to be destroyed less often than their neighbors without it.
For each fire and each feature, I computed the percent destroyed with and without the feature. I kept only fires with at least 20 homes in each group, so that we don’t get cases where a handful of homes produced a 0% or 100% rate by chance.
# one row per fire per feature: % destroyed with and without the feature
by_fire <- homes_long |>
group_by(category, feature, label, fire_id, status) |>
summarize(n = n(), pct = mean(destroyed) * 100, .groups = "drop") |>
pivot_wider(names_from = status, values_from = c(n, pct)) |>
filter(n_hardened >= 20, n_not_hardened >= 20) |> # both groups big enough
mutate(gap = pct_not_hardened - pct_hardened) # positive = feature did better
# how many fires each feature was compared in, and how many it did better in
fire_counts <- by_fire |>
group_by(feature) |>
summarize(fires = n(), better = sum(gap > 0), pct_better = better / fires * 100)
pct_better <- setNames(round(fire_counts$pct_better), fire_counts$feature)
n_better <- setNames(fire_counts$better, fire_counts$feature)
n_compared <- setNames(fire_counts$fires, fire_counts$feature)
# triangle below the 1:1 line = fires where homes with the feature were destroyed less often
helped_zone <- tibble(x = c(0, 100, 100), y = c(0, 0, 100))
p2 <- by_fire |>
mutate(label = factor(label, levels = features$label)) |> # keep the category order
ggplot(aes(x = pct_not_hardened, y = pct_hardened)) +
geom_polygon(data = helped_zone, aes(x = x, y = y), fill = "grey92") +
geom_abline(slope = 1, intercept = 0, color = "grey60") +
annotate("text", x = 97, y = 4, label = "feature helped", hjust = 1,
size = 2.8, color = "grey45") +
geom_point(aes(size = n_hardened + n_not_hardened, color = category), alpha = 0.6) +
# axes = "all" repeats the axis numbers on every panel, not just the outer ones
facet_wrap(~ label, ncol = 4, labeller = label_wrap_gen(width = 24), axes = "all") +
scale_color_manual(values = category_colors) +
scale_size_area(max_size = 5, guide = "none") +
coord_equal(xlim = c(0, 100), ylim = c(0, 100)) +
labs(
title = "Within the same fire, windows, vents, eaves, and newer homes fared better",
subtitle = "Points in the shaded area are fires where homes with the feature were destroyed less often",
x = "% destroyed without the feature",
y = "% destroyed with the feature",
color = NULL
) +
theme(legend.position = "bottom")
p2
Several features match the expectation. Homes with multi-pane windows were destroyed less often than single-pane homes in 94% of the fires where both could be compared, and homes with fine-mesh vents or no vents did better in 62%. Homes built in 2008 or later did better in 13 of 16 fires. Their points fall in the shaded area in nearly every fire.
Three features appear to contradict it. Homes with no attached fence or a non-combustible one sit above the line in most fires: they did better in only 5% of fires. Non-combustible siding did better in 24% and homes without wood decks in 32%. If we were to take that at face value, this says a wood fence or wood siding protects a home, which contradicts fire research and basic ideas of combustion.
The Camp Fire, the largest point near the upper right of most panels, shows almost no difference for any single construction feature: nearly everything burned. Even there, year built still separates the homes, as the next chunk shows.
homes |>
filter(fire_id == "Camp 2018") |>
mutate(era = case_when(
year_built >= 1850 & year_built < 1960 ~ "Built before 1960",
year_built >= 2008 & year_built <= fire_year ~ "Built 2008 or later"
)) |>
drop_na(era) |>
group_by(era) |>
summarize(homes = n(), pct_destroyed = mean(destroyed) * 100) |>
knitr::kable(digits = 0, col.names = c("Camp Fire homes", "Homes", "% destroyed"))
| Camp Fire homes | Homes | % destroyed |
|---|---|---|
| Built 2008 or later | 412 | 47 |
| Built before 1960 | 4042 | 89 |
Fences, siding, and decks may be reversed because inspections happen after the fire. For a home that survived, the inspector can see the fence and the siding, but for a home that burned to its foundation, the inspector has to judge from debris, and a wood fence that burned along with the house could be recorded as “No Fence”.
The data supports this explanation–destroyed homes are recorded less completely: eaves were marked “Unknown” for 52% of destroyed homes but only 20% of survivors. Features that burn away with the house (fences, decks, wood siding) are the ones most susceptible to this problem. Windows, vents, and year built are less affected, because a window frame or vent opening leaves evidence and year built comes from county records rather than the inspection.
Within the same fire, homes with better windows, vents, and newer construction survived more often, and this data can’t quite answer the question for fences, siding, or decks.
Figure 2 shows direction, but not size. To rank the features, I reduced each panel to one number: the average difference across fires weighted by the number of homes so that large fires count more. The 95% interval shows how consistent the difference is from fire to fire. Each fire counts as one comparison, not each home, because homes in the same fire burned under the same conditions and are not independent of each other.
For comparison, I also computed the pooled difference that results from ignoring which fire a home was in. The distance between the two shows how much Figure 1’s warning matters for each feature.
# pooled difference: all homes together, ignoring which fire they were in
pooled <- homes_long |>
group_by(feature) |>
summarize(gap_pooled = 100 * (mean(destroyed[status == "not_hardened"]) -
mean(destroyed[status == "hardened"])))
# within-fire difference: weighted average of the per-fire differences in Figure 2
gaps <- by_fire |>
group_by(category, feature, label) |>
summarize(
fires = n(),
gap_within = weighted.mean(gap, n_hardened + n_not_hardened), # big fires count more
ci = 1.96 * sd(gap) / sqrt(fires), # fires as repeats
.groups = "drop"
) |>
left_join(pooled, by = "feature")
# named vectors for the inline numbers in the text below
gap_within <- setNames(round(gaps$gap_within, 1), gaps$feature)
gap_pooled <- setNames(round(gaps$gap_pooled, 1), gaps$feature)
gap_low <- setNames(round(gaps$gap_within - gaps$ci, 1), gaps$feature)
gaps |>
arrange(desc(gap_within)) |>
transmute(label, fires, gap_within, low = gap_within - ci, high = gap_within + ci, gap_pooled) |>
knitr::kable(digits = 1, col.names = c("Feature", "Fires", "Within-fire gap",
"95% low", "95% high", "Pooled gap"))
| Feature | Fires | Within-fire gap | 95% low | 95% high | Pooled gap |
|---|---|---|---|---|---|
| Built 2008 or later (WUI code) | 16 | 19.0 | 13.5 | 24.5 | 23.1 |
| Windows: multi-pane | 53 | 13.1 | 9.2 | 16.9 | 12.7 |
| Vents: fine mesh or none | 52 | 11.3 | 8.0 | 14.5 | 16.6 |
| Eaves: enclosed | 45 | 6.7 | 3.9 | 9.6 | 9.7 |
| Roof: tile, metal, or concrete | 61 | 3.8 | 1.1 | 6.5 | 15.3 |
| Propane tank: 21+ ft away | 29 | 3.1 | -0.3 | 6.5 | 8.1 |
| Outbuilding: 30+ ft away | 18 | 1.7 | -2.1 | 5.5 | 11.6 |
| Patio cover: none or non-combustible | 59 | -2.3 | -4.3 | -0.3 | -1.5 |
| Deck/porch: no wood | 62 | -7.0 | -10.2 | -3.9 | -4.4 |
| Siding: non-combustible | 54 | -15.3 | -18.8 | -11.8 | -7.2 |
| Attached fence: none or non-combustible | 38 | -15.6 | -21.0 | -10.2 | -19.0 |
p3 <- gaps |>
mutate(label = fct_reorder(label, gap_within)) |>
ggplot(aes(y = label)) +
geom_vline(xintercept = 0, color = "grey40") +
geom_segment(aes(x = gap_pooled, xend = gap_within, yend = label), color = "grey80") +
geom_point(aes(x = gap_pooled), shape = 21, fill = "white", color = "grey40", size = 2.5) +
geom_pointrange(aes(x = gap_within, xmin = gap_within - ci, xmax = gap_within + ci,
color = category)) +
scale_color_manual(values = category_colors) +
labs(
title = "Which hardening features were linked to more homes surviving?",
subtitle = "Filled: within the same fire, with 95% interval, hollow: all fires pooled",
x = "Percentage points fewer homes destroyed when the feature is present",
y = NULL,
color = NULL
)
p3
Since most homes are lost to embers, one might expect the small fixes that close openings (fine-mesh vent screens and enclosed eaves) to matter about as much as replacing a roof or siding, which the data supports. Among single features, multi-pane windows (13.1 points) and fine-mesh or no vents (11.3 points) rank highest, followed by enclosed eaves (6.7 points). All three rank above roof material (3.8 points). Vent screens are one of the cheapest retrofits on the list, which makes this a very actionable result.
The roof result is the main surprise. Pooled, a tile, metal, or concrete roof looks like one of the strongest features, at 15.3 points. Within fires, though, the difference falls to 3.8 points. Most of the pooled number comes from the Camp Fire, which is the problem Figure 1 warned about:
homes |>
filter(fire_id == "Camp 2018", !is.na(roof)) |>
summarize(
homes = n(),
pct_destroyed = mean(destroyed) * 100,
pct_hardened_roof = mean(roof == "hardened") * 100
) |>
knitr::kable(digits = 0, col.names = c("Camp Fire homes with known roof", "% destroyed",
"% tile, metal, or concrete roof"))
| Camp Fire homes with known roof | % destroyed | % tile, metal, or concrete roof |
|---|---|---|
| 14039 | 97 | 15 |
The Camp Fire contributed thousands of homes that almost all burned and almost all had asphalt roofs to the dataset. Pooled with other fires, it makes asphalt look deadly, when inside the Camp Fire itself roof material made little difference.
California’s wildland-urban interface (WUI) building code, Chapter 7A, has applied to new homes since 2008 and requires many of these features at once, so newer homes should do better than older ones in the same fire. This dataset supports that conclusion: homes built in 2008 or later had the largest advantage of any feature, 19 percentage points fewer destroyed than older homes in the same fire. This feature actually bundles several: the code requires ember-resistant vents, enclosed eaves, multi-pane windows, and Class A roofs, and newer homes also have newer materials overall. However, it rests on only 16 fires, because most fires had too few newer homes to compare.
Wood decks, attached fences, propane tanks, and sheds can carry flame directly to the house, so homes with fewer of them nearby should fare better. This data didn’t support a strong conclusion in that regard. Propane tanks 21 feet or more away (3.1 points) and outbuildings 30 feet or more away (1.7 points) point in the expected direction, but their intervals reach below zero (the propane interval starts at -0.3), and both are recorded for less than a fifth of homes. The attached fence, deck, and patio cover reverse sign for the recording-based reason described above.
In summary:
For a mitigation program, the data points to vent screens and window upgrades as the first priorities and enclosed eaves next, and it suggests that pooled statistics overstate the value of replacing a roof.
# save the ranking plot separately
dir.create(here("output"), showWarnings = FALSE)
ggsave(here("output", "hardening_ranking.png"), plot = p3,
width = 9, height = 6, dpi = 300, bg = "white")