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.

OpenStreetMap, Overture and 3D BAG building footprints compared by access, unit of a building, heights and update cycle. three open sources, three definitionsOSMOverture3D BAGaccessOverpass / osmnxGeoParquet on S3WFS / OGC APIa building iswhat was drawnconflated, OSM firsta registered pandheightsa minoritya minorityLiDAR, nearly allupdatescontinuousmonthly releaseyearlycoverageglobal, unevenglobalNetherlands onlyCompare footprint area, not only counts: the division differs more than the ground spatialworkflow.io
Three open sources, three definitions of "a building".

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.

The same terrace drawn as one outline and as one polygon per registered house. same ground, different countsone outline1 building, 2,400 mΒ²one polygon per house8 buildings, 2,400 mΒ²Counts, median size and shape indices all change; covered area does not spatialworkflow.io
Same ground, different counts: one outline or one polygon per house.

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.

Share of buildings with a height attribute in OSM, Overture and the 3D BAG for central Den Haag. buildings with a height, central Den HaagOSM (24,306)1%Overture (23,916)1% β€” every building from OSM3D BAG (23,929)100% β€” LiDAR050%100%Counts agree within 2%; heights decide the source spatialworkflow.io
Three sources agree on the ground and part company on height.

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.

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.