How to Compare Forest Loss Inside and Outside Protected Areas in Python
On this page
Problem statement
Do protected areas and indigenous territories keep their forest? The first comparison anyone makes β the share of forest lost inside against outside β almost always shows much less loss inside. Part of that is protection working. Part of it is that protected areas are placed where clearing was less likely anyway: far from roads, on steep or poor land, in remote places. A fair comparison looks at land that was similarly exposed, typically a band just outside the boundary, and separates the boundary effect from the location effect.
Quick answer
Rasterise the protected polygons, build distance bands inside and outside the boundary, and compare the share of 2000 forest lost in each:
import numpy as np, pandas as pd, geopandas as gpd, osmnx as ox, rasterio
from rasterio import features
from rasterio.windows import from_bounds
from scipy import ndimage
V, TILE = "GFC-2024-v1.12", "10S_070W"
W, S, E, N = -63.5, -11.3, -62.8, -10.6 # the northern edge of Uru-Eu-Wau-Wau
def layer(name):
with rasterio.open(f"/vsicurl/https://storage.googleapis.com/earthenginepartners-hansen/"
f"{V}/Hansen_{V}_{name}_{TILE}.tif") as src:
win = from_bounds(W, S, E, N, src.transform)
return src.read(1, window=win), src.window_transform(win)
treecover, tf = layer("treecover2000")
lossyear, _ = layer("lossyear")
datamask, _ = layer("datamask")
shape = treecover.shape
px_m = abs(tf.a) * 111_320 * np.cos(np.radians((S + N) / 2))
px_ha = px_m * abs(tf.e) * 110_574 / 1e4
ox.settings.http_user_agent = "spatialworkflow-examples/1.0"
pa = ox.features_from_bbox((W, S, E, N), {"boundary": ["protected_area", "aboriginal_lands"]})
pa = pa[pa.geom_type.isin(["Polygon", "MultiPolygon"])]
print("protected polygons:", pa[["name", "boundary"]].dropna(subset=["name"]).drop_duplicates("name")
.head(6).to_dict("records"))
inside = features.rasterize(((g, 1) for g in pa.geometry), out_shape=shape, transform=tf) == 1
samp = (abs(tf.e) * 110_574, px_m)
d_out = ndimage.distance_transform_edt(~inside, sampling=samp) / 1000 # km outside
d_in = ndimage.distance_transform_edt(inside, sampling=samp) / 1000 # km inside
signed = np.where(inside, -d_in, d_out) # negative = inside
forest = (treecover > 30) & (datamask == 1)
df = pd.DataFrame({"d": signed[forest], "lost": lossyear[forest] > 0})
df["band"] = pd.cut(df.d, [-100, -10, -5, -1, 0, 1, 5, 10, 100])
out = df.groupby("band", observed=True).agg(forest_ha=("lost", "size"), share_lost=("lost", "mean"))
out["forest_ha"] = (out.forest_ha * px_ha).round(-2)
print(out.round({"share_lost": 3}).to_string())
protected polygons: [{'name': 'Parque Nacional de PacaΓ‘s Novos', 'boundary': 'protected_area'}, {'name': 'Terra IndΓgena Uru-Eu-Wau-Wau', 'boundary': 'aboriginal_lands'}]
forest_ha share_lost
band
(-100, -10] 330700.0 0.012
(-10, -5] 64900.0 0.001
(-5, -1] 47500.0 0.016
(-1, 0] 11300.0 0.077
(0, 1] 7000.0 0.579
(1, 5] 24500.0 0.655
(5, 10] 22000.0 0.625
(10, 100] 24300.0 0.537
Negative distances are inside the protected land, positive outside. The step at zero is the most direct evidence of protection: 7.7% of the forest lost in the kilometre just inside the boundary, 58% in the kilometre just outside. Deeper inside, loss falls to 1β2%; further outside it stays above half. The first kilometre inside is where the boundary is being nibbled.
Protected boundaries, one baseline, signed distance
1. Get the protected boundaries
National registries or the World Database on Protected Areas are authoritative; OSM maps many protected areas (boundary=protected_area) and indigenous territories (boundary=aboriginal_lands) with names and designations.
2. Use the same forest baseline inside and out
Forest in 2000 by a canopy threshold, water masked. Protected areas often contain non-forest vegetation that should not enter the denominator.
3. Build signed distance to the boundary
Distance outside and distance inside, combined into one signed layer. Bands of a few kilometres on each side compare land with similar access.
4. Compare shares, not areas
Share of the 2000 forest lost per band. Areas depend on how much forest each band contains.
5. Report the placement caveat
A lower loss rate inside does not by itself measure the effect of protection. Matching on distance to roads, slope and other covariates is the standard next step.
Inside against the outer band, over time
The data used on this page
The Hansen/UMD Global Forest Change layers (version GFC-2024-v1.12, tile 10S 070W, RondΓ΄nia, Brazil), read by window straight from Google Cloud Storage, and protected areas, indigenous lands and roads from OpenStreetMap, fetched with OSMnx by the page's own code. The setup points OSMnx at an Overpass mirror and unpacks its answers as saved on 9 October 2026, since OpenStreetMap is edited daily and a live query would move the counts.
import io
import os
import osmnx as ox
import requests
import zipfile
# OpenStreetMap as Overpass returned it on 9 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-forest-loss-protected-areas-python-2026-10-09.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"
ox.settings.overpass_rate_limit = False # its /status endpoint can hang
ox.settings.requests_timeout = 600
print("Overpass:", ox.settings.overpass_url)
Overpass: https://maps.mail.ru/osm/tools/overpass/api
Example 1 β loss over time, inside against the outer band
near_out = forest & (signed > 0) & (signed <= 5)
near_in = forest & (signed < 0) & (signed >= -5)
for y0, y1 in ((1, 5), (6, 10), (11, 15), (16, 20), (21, 24)):
lo = lambda m: (m & (lossyear >= y0) & (lossyear <= y1)).sum() / m.sum()
print(f"{2000 + y0}β{2000 + y1}: lost {lo(near_out):6.2%} of forest 0β5 km outside, "
f"{lo(near_in):6.2%} 0β5 km inside")
2001β2005: lost 26.04% of forest 0β5 km outside, 0.86% 0β5 km inside
2006β2010: lost 10.69% of forest 0β5 km outside, 0.41% 0β5 km inside
2011β2015: lost 6.02% of forest 0β5 km outside, 0.23% 0β5 km inside
2016β2020: lost 9.88% of forest 0β5 km outside, 0.50% 0β5 km inside
2021β2024: lost 11.19% of forest 0β5 km outside, 0.73% 0β5 km inside
The contrast between the two sides holds in every period: clearing just outside runs 15 to 30 times the rate just inside, though the ratio has narrowed since 2011. Both sides slowed to 2015 and rose again after; inside, the share cleared per period has tripled from 0.23% to 0.73%. A rising inside share is the pattern monitoring programmes look for.
Example 2 β are the two sides comparable?
roads = ox.features_from_bbox((W - 0.1, S - 0.1, E + 0.1, N + 0.1), {"highway": True})
roads = roads[roads.geom_type.isin(["LineString", "MultiLineString"])]
burn = features.rasterize(((g, 1) for g in roads.geometry), out_shape=shape, transform=tf,
all_touched=True)
dist_road = ndimage.distance_transform_edt(burn == 0, sampling=samp) / 1000
for label, m in (("0β5 km outside", near_out), ("0β5 km inside", near_in)):
print(f"{label:<15} median distance to a mapped road {np.median(dist_road[m]):5.1f} km")
0β5 km outside median distance to a mapped road 1.0 km
0β5 km inside median distance to a mapped road 3.5 km
Even within 5 km of the boundary, the two sides differ in access. Part of the difference in loss is roads, not protection β which is why careful studies match pixels on distance to roads before comparing.
Example 3 β a matched comparison on road distance
bins = [0, 1, 2, 5, 10, 20, 100]
rows = []
for lo, hi in zip(bins[:-1], bins[1:]):
band = (dist_road >= lo) & (dist_road < hi)
a, b = forest & band & near_out, forest & band & near_in
if a.sum() > 1000 and b.sum() > 1000:
rows.append((f"{lo}β{hi} km", (lossyear[a] > 0).mean(), (lossyear[b] > 0).mean()))
print(f"{'road distance':<14}{'outside':>9}{'inside':>9}")
for r in rows:
print(f"{r[0]:<14}{r[1]:>9.1%}{r[2]:>9.1%}")
road distance outside inside
0β1 km 64.2% 12.7%
1β2 km 66.2% 6.4%
2β5 km 57.9% 1.6%
Within the same road-distance class, the boundary still makes a large difference β 64% against 13% within a kilometre of a road β in the three classes where both sides have enough forest to compare. Beyond 5 km from a road one side or the other has too little forest near the boundary to compare. That residual gap is closer to the effect of protection than the raw comparison.
Why raw comparisons overstate protection
Why the raw inside/outside comparison overstates protection
Protected areas are rarely placed at random. Governments protect land that is remote, steep or otherwise unattractive to clear, partly because that is where protection is cheapest. Comparing them with all unprotected land compares remote with accessible land.
Why the boundary band helps
Land just outside a boundary is similar to land just inside in climate, soil and remoteness. A sharp change in clearing at the boundary is hard to explain by anything other than the boundary itself. It is the spatial equivalent of a regression discontinuity.
Why matching is the standard
Covariate matching pairs protected pixels with unprotected pixels of similar distance to roads, slope, distance to past clearing and so on. The difference in loss between matched pairs estimates avoided deforestation with far less placement bias than a simple comparison.
Leakage, designation dates and OSM completeness
- Leakage: protection can push clearing into neighbouring land, inflating loss just outside the boundary.
- Designation dates: areas protected after 2001 should be compared from their designation year.
- Different designations (strict reserves, sustainable-use areas, indigenous lands) have different rules and effects.
- OSM completeness for protected areas varies; use the national registry for official analyses.
- Loss inside protected areas includes fire and natural disturbance, not only illegal clearing.
Internal links
- How to calculate forest loss by year for a region β zonal loss tables
- How to measure deforestation near roads β the main confounder
- Protected area data explained β designations and sources
- How to find species in protected areas β the biodiversity side
- Deforestation rates explained β rates by period
- Global Forest Change explained β what counts as loss
FAQ
How do I measure deforestation inside protected areas?
Rasterise the protected polygons onto the forest grid and compute the share of baseline forest lost inside and in comparison bands outside.
Why compare with a band just outside the boundary?
Land on both sides of the boundary is similar in access and environment, so a difference there is more likely due to protection.
Does lower loss inside prove protection works?
No β protected areas are often placed where clearing was less likely. Match on covariates to estimate the effect.
What is leakage?
Clearing displaced from protected land into nearby unprotected land.
Where do I get protected area boundaries?
National registries, the World Database on Protected Areas, or OSM tags boundary=protected_area and boundary=aboriginal_lands.
How do I handle areas protected after 2001?
Compare loss only from the year of designation onward.