How to Download Building Footprints from OSM and Overture in Python
On this page
Problem statement
Every urban-form metric starts from building footprints, and there are now several open sources for them: OpenStreetMap, the Overture Maps Foundation's conflated buildings, national cadastres such as the Dutch BAG, and machine-learned footprints from imagery. They look interchangeable on a map. They differ in completeness, in how a building is divided into parts, in whether a height comes attached, and in how they are distributed β an Overpass query, a GeoParquet scan on S3, a WFS.
Choosing the source is the first morphology decision, and it is made by comparing them on the same area, not by reputation.
Quick answer
Fetch the same area from all three and compare counts, footprint area and heights:
import requests, duckdb, pandas as pd, geopandas as gpd, osmnx as ox
from pyproj import Transformer
W, S, E, N = 4.285, 52.065, 4.330, 52.095 # central Den Haag, about 3 Γ 3.3 km
RD = "EPSG:28992" # Dutch national grid, metres
ox.settings.http_user_agent = "spatialworkflow-examples/1.0"
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api" # mirror; overpass-api.de refused
ox.settings.overpass_rate_limit = False
osm = ox.features_from_bbox((W, S, E, N), {"building": True})
osm = osm[osm.geom_type.isin(["Polygon", "MultiPolygon"])].to_crs(RD)
con = duckdb.connect()
con.execute("INSTALL spatial; LOAD spatial; INSTALL httpfs; LOAD httpfs; SET s3_region='us-west-2';")
df = con.execute(f"""
SELECT id, height, num_floors, sources[1].dataset AS source, ST_AsWKB(geometry) AS wkb
FROM read_parquet('s3://overturemaps-us-west-2/release/2026-09-23.0/theme=buildings/type=building/*',
hive_partitioning = 1)
WHERE bbox.xmin > {W} AND bbox.xmax < {E} AND bbox.ymin > {S} AND bbox.ymax < {N}""").df()
overture = gpd.GeoDataFrame(df.drop(columns="wkb"),
geometry=gpd.GeoSeries.from_wkb(df.wkb.apply(bytes)), crs=4326).to_crs(RD)
x0, y0 = Transformer.from_crs(4326, RD, always_xy=True).transform(W, S)
x1, y1 = Transformer.from_crs(4326, RD, always_xy=True).transform(E, N)
feats, start = [], 0
while True:
fc = requests.get("https://data.3dbag.nl/api/BAG3D/wfs", params={
"service": "WFS", "version": "2.0.0", "request": "GetFeature", "typeNames": "BAG3D:lod12",
"count": 5000, "startIndex": start, "outputFormat": "application/json",
"bbox": f"{x0},{y0},{x1},{y1},EPSG:28992"}, timeout=300).json()
feats += fc["features"]
if len(fc["features"]) < 5000:
break
start += 5000
bag = gpd.GeoDataFrame.from_features(feats, crs=RD)
print(f"{'source':<10}{'buildings':>10}{'footprint kmΒ²':>15}{'median mΒ²':>11}{'with height':>13}")
for name, g, h in (("OSM", osm, osm.get("height", pd.Series(dtype=float)).notna().sum()),
("Overture", overture, overture.height.notna().sum()),
("3D BAG", bag, bag.b3_h_70p.notna().sum())):
print(f"{name:<10}{len(g):>10,}{g.area.sum() / 1e6:>15.3f}{g.area.median():>11.0f}{h / len(g):>13.0%}")
print("Overture sources:", overture.source.value_counts().to_dict())
source buildings footprint kmΒ² median mΒ² with height
OSM 24,306 3.465 67 1%
Overture 23,916 3.291 66 1%
3D BAG 23,929 3.356 68 100%
Overture sources: {'OpenStreetMap': 23916}
Here the three agree within 2% on count and about 5% on covered area, and that is a property of the Netherlands rather than of the sources: most Dutch buildings in OSM were imported from the same national register, and Overture takes every one of its buildings here from OSM. In much of the world the picture is very different β the missing-buildings guide compares the same sources in Nairobi. The heights column is where they part company even here.
One area, metric CRS, each source's definition, total area
1. Fix the area and a metric CRS
Compare sources on the same rectangle, in the national grid or a UTM zone. Footprint areas and everything derived from them need metres.
2. Understand what a "building" is in each source
The Dutch BAG counts registered buildings (panden), which in a row of terraced houses is each house. Where OSM was drawn by hand rather than imported, mappers often draw a whole block as one polygon, or split a building into parts. Overture conflates OSM with other sources and keeps the OSM division where it exists.
3. Compare total footprint area, not just counts
Counts depend on the division; covered area does not. A source that agrees on area and differs on count is dividing differently. One that is low on area is missing buildings.
4. Check what comes attached
Heights, floors, construction year, use. In the Netherlands the 3D BAG has measured heights for nearly every building; OSM and Overture have them for a minority. For density measures that need floor area, that decides the source.
5. Watch the query mechanics
Overpass returns everything intersecting the box. The Overture GeoParquet filter on bbox columns keeps buildings entirely inside it. A WFS pages its results. Clip all three to the same polygon before comparing.
6. Record the release
OSM changes daily, Overture monthly. Store the query date or release name with the data.
Clipping OSM, Overture and the cadastre to one polygon
The data used on this page
Three sources for the same 3 Γ 3.3 km of central Den Haag, 4.285β4.330Β° E, 52.065β52.095Β° N, all fetched by the quick answer: OpenStreetMap buildings through OSMnx and the maps.mail.ru Overpass mirror, the Overture Maps 2026-09-23.0 buildings release read from its public S3 bucket with DuckDB, and the 3D BAG's WFS for the Dutch cadastre with measured heights. The Overture release is fixed by its name; OpenStreetMap is not, so the setup unpacks its answer as saved on 9 October 2026 before the quick answer asks for it.
import io
import os
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-building-footprints-osm-overture-python-2026-10-09.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
Example 1 β clip all three to the same polygon
from shapely.geometry import box
aoi = box(x0, y0, x1, y1)
clipped = {name: g[g.representative_point().within(aoi)]
for name, g in (("OSM", osm), ("Overture", overture), ("3D BAG", bag))}
for name, g in clipped.items():
print(f"{name:<9} {len(g):>6,} buildings whose centre is inside, "
f"covering {g.area.sum() / aoi.area:.1%} of the area")
OSM 23,977 buildings whose centre is inside, covering 32.9% of the area
Overture 23,786 buildings whose centre is inside, covering 31.8% of the area
3D BAG 23,774 buildings whose centre is inside, covering 32.0% of the area
Counting by representative point avoids double-counting buildings that straddle the edge β and makes the three coverage ratios directly comparable.
Example 2 β how the sources divide the same ground
b = clipped["3D BAG"].reset_index(drop=True)
o = clipped["OSM"].reset_index(drop=True)
# count BAG building centres inside each OSM polygon ("intersects" would also count
# terraced neighbours that merely share a wall)
centres = gpd.GeoDataFrame(geometry=b.representative_point(), crs=b.crs).assign(bag_id=b.index)
pairs = gpd.sjoin(centres, o[["geometry"]].assign(osm_id=o.index), predicate="within")
per_osm = pairs.groupby("osm_id").bag_id.nunique()
print(f"OSM polygons covering one BAG building: {(per_osm == 1).mean():.0%}")
print(f"OSM polygons covering 2β10 BAG buildings: {per_osm.between(2, 10).mean():.0%}")
print(f"OSM polygons covering more than 10: {(per_osm > 10).mean():.0%} "
f"(largest covers {per_osm.max()})")
OSM polygons covering one BAG building: 100%
OSM polygons covering 2β10 BAG buildings: 0%
OSM polygons covering more than 10: 0% (largest covers 10)
Every OSM polygon here covers exactly one registered building (to the nearest percent), and even the largest outlier holds only ten. That is the BAG import showing through: in Den Haag, OSM is the register, re-drawn. Run the same test on a city mapped by hand and a sizeable share of polygons turn out to be whole terraces or blocks β and any per-building statistic, from median footprint to shape indices, changes with that division even where the covered area is identical.
Example 3 β heights where you need them
bag["height"] = bag.b3_h_70p - bag.b3_h_maaiveld
print(bag.height.describe(percentiles=[.1, .5, .9]).round(1).to_string())
bag[["identificatie", "b3_h_70p", "b3_h_maaiveld", "height", "b3_opp_grond", "geometry"]].to_parquet("denhaag_bag.parquet")
count 23929.0
mean 8.9
std 5.2
min 0.9
10% 2.5
50% 9.6
90% 13.6
max 143.9
The 70th percentile of the roof points above ground level is the 3D BAG's recommended single height for a building; the ground level comes from the same LiDAR. Saved as GeoParquet, this layer is the input for the coverage and floor-area guides.
Why the sources disagree
Why the counts disagree
A footprint dataset encodes a definition. Cadastres follow legal units; OSM follows what mappers see and choose to draw; machine-learned footprints follow whatever the model segments, often merging attached buildings. None is wrong, and statistics that depend on the unit β "number of buildings", "average building size" β inherit the definition.
Why Overture is worth the S3 scan
Overture publishes the whole world as partitioned GeoParquet with a bbox struct per feature. DuckDB can filter it remotely without downloading the files, which makes it the easiest way to get a consistent, conflated footprint layer for any city. The first query is slow because it reads the metadata of every file; for repeated work, save the extract.
Why a national cadastre wins where it exists
Registered buildings with measured heights, construction years and use classes are what morphology needs. Where a country publishes that β the Netherlands, Denmark, parts of Germany and France β it is the reference; OSM and Overture are how you get comparable data elsewhere.
Building tags, parts, releases and WFS limits
building=*tags in OSM include garages, sheds and roofs (building=roof) that some analyses exclude.- Building parts (
building:part) describe towers and wings of one building; do not add their areas to the building's own footprint. - Overture releases change schema occasionally; pin the release path in code.
- WFS limits: the 3D BAG WFS pages at 5,000 features; the OGC API (
api.3dbag.nl) is an alternative. - Machine-learned footprints (Microsoft, Google Open Buildings) fill gaps outside Europe and North America, with their own merge behaviour.
Internal links
- Urban morphology explained β what the footprints are for
- Buildings missing from OSM β the fix guide for incomplete footprints
- How to calculate building shape metrics β the next step
- Building height sources explained β heights in more depth
- How to query GeoParquet on S3 with DuckDB β the Overture mechanics
- How to download OpenStreetMap data in Python β the OSM route in general
FAQ
Where can I get building footprints for free?
OpenStreetMap, Overture Maps (GeoParquet on S3 and Azure), national cadastres such as the Dutch BAG, and machine-learned footprint releases from Microsoft and Google.
Why do different sources give different building counts?
They define a building differently β legal unit, mapped outline, or segmented roof β even where they cover the same ground.
How do I read Overture buildings in Python?
With DuckDB's spatial and httpfs extensions, filtering on the bbox columns of the GeoParquet release.
Which source has building heights?
National 3D datasets such as the 3D BAG have measured heights for nearly all buildings; OSM and Overture have them for a minority.
How do I compare sources fairly?
Same area, same CRS, clip by representative point, and compare total footprint area as well as counts.
Should I combine sources?
Overture already conflates several. Combining more yourself means resolving duplicates and conflicting divisions β do it only where one source is clearly incomplete.