How to Get Building Heights and Levels From OpenStreetMap in Python
On this page
Problem statement
Building footprints are only half of a city: 3D maps, floor-area estimates, solar and shadow models all need heights. OpenStreetMap records them with two tags, height in metres and building:levels for the number of floors above ground, plus roof:levels and min_height for details. Neither is required. Their values come as strings, sometimes with units, and coverage varies from street to street, so using them takes parsing, checking and a fallback for the buildings that have neither.
This page extracts every building in Washington, D.C. from the OpenStreetMap extract of 1 January 2026. It parses height and building:levels, measures how many buildings have each, estimates metres per level where both are present, finds the tallest buildings, and estimates total floor area.
Quick answer
Read the buildings with their height tags and parse the strings to numbers:
fp = (osmium.FileProcessor(str(PBF)).with_areas()
.with_filter(osmium.filter.KeyFilter("building"))
.with_filter(osmium.filter.GeoInterfaceFilter(tags=["building", "name", "height", "building:levels", "roof:levels"])))
B = gpd.GeoDataFrame.from_features(fp, crs=4326)
B = B[B.geom_type.isin(["Polygon", "MultiPolygon"])]
B = B[B.representative_point().within(DC)].to_crs(32618).reset_index(drop=True)
B["h"] = pd.to_numeric(B.height.str.extract(r"^\s*([0-9.]+)\s*(?:m)?\s*$")[0], errors="coerce")
B["lv"] = pd.to_numeric(B["building:levels"], errors="coerce")
print(f"buildings inside D.C.: {len(B):,}; with height: {B.h.notna().mean():.1%}; with building:levels: {B.lv.notna().mean():.1%}; with either: {(B.h.notna() | B.lv.notna()).mean():.1%}")
buildings inside D.C.: 161,354; with height: 0.2%; with building:levels: 36.6%; with either: 36.6%
The District has 161,354 building polygons in OSM. Only 0.2% carry a height, while 36.6% have building:levels, so together 36.6% have one or the other.
Non-numeric values, metres per level, floor area
1. Values that are not plain numbers
height should be in metres without a unit, but other forms appear. Count the values the parser rejected:
raw = B.height.dropna()
bad = raw[B.loc[raw.index, "h"].isna()]
print(f"height values: {len(raw):,}; not a plain number in metres: {len(bad)}; examples {bad.value_counts().head(5).to_dict()}")
lv_raw = B["building:levels"].dropna()
print(f"building:levels values: {len(lv_raw):,}; not a number: {B.loc[lv_raw.index, 'lv'].isna().sum()}")
height values: 340; not a plain number in metres: 2; examples {"166.5'": 1, "130.0'": 1}
building:levels values: 59,044; not a number: 1
Of the 340 height values, only 2 are not plain metres — 166.5' and 130.0', feet written with a prime — and only one of the 59,044 building:levels values fails to parse. Values this clean are typical of imported data; the two in feet are the kind of edit that arrives later, one mapper at a time.
2. Metres per level
Where both tags exist, their ratio gives a storey height to fill in buildings that have only levels:
both = B[B.h.notna() & B.lv.notna() & (B.lv > 0)]
mpl = both.h / both.lv
print(f"buildings with both: {len(both):,}; metres per level: median {mpl.median():.2f}, middle half {mpl.quantile(0.25):.2f} to {mpl.quantile(0.75):.2f}")
print("levels distribution:", B.lv.value_counts().sort_index().head(8).astype(int).to_dict())
buildings with both: 248; metres per level: median 3.62, middle half 3.62 to 3.62
levels distribution: {0.0: 3, 1.0: 3782, 1.5: 2330, 2.0: 47953, 2.5: 1622, 3.0: 1674, 3.5: 12, 4.0: 407}
The 248 buildings with both tags give exactly 3.62 m per level across the whole middle half, which suggests their heights were computed from levels with a fixed factor rather than measured. Levels themselves cluster at 2 (47,953 buildings), with many half-levels such as 1.5 and 2.5.
3. Floor area
Footprint area times levels estimates gross floor area. Fill missing levels from height, then report how much is covered:
B["lv_est"] = B.lv.fillna((B.h / mpl.median()).round())
B["floor_m2"] = B.area * B.lv_est
covered = B.lv_est.notna()
print(f"footprint area: {B.area.sum() / 1e6:.1f} km²; floor area of buildings with levels: {B.floor_m2.sum() / 1e6:.1f} km² ({covered.mean():.0%} of buildings, {B.loc[covered].area.sum() / B.area.sum():.0%} of footprint area)")
footprint area: 25.7 km²; floor area of buildings with levels: 39.1 km² (37% of buildings, 32% of footprint area)
Buildings cover 25.7 km² of the District. The 37% of buildings with levels, 32% of the footprint area, add up to 39.1 km² of floor area.
The tallest buildings
The data used on this page
The Geofabrik OpenStreetMap extract for the District of Columbia dated 1 January 2026 (© OpenStreetMap contributors, ODbL), and the District's boundary from the US Census Bureau's 2023 cartographic boundary files at 1:500,000. Areas are in UTM zone 18N.
from pathlib import Path
import geopandas as gpd
import osmium
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
def cached(path, url):
p = Path(path)
if not p.exists():
r = requests.get(url, timeout=600)
r.raise_for_status()
p.write_bytes(r.content)
return p
PBF = cached("data/district-of-columbia-260101.osm.pbf", "https://download.geofabrik.de/north-america/us/district-of-columbia-260101.osm.pbf")
C = gpd.read_file(cached("data/cb_2023_us_county_500k.zip", "https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_500k.zip"))
DC = C[C.GEOID == "11001"].to_crs(4326).geometry.iloc[0]
print("boundary loaded")
boundary loaded
The District boundary is loaded for filtering.
Example 1: the tallest buildings
top = B.nlargest(6, "h")[["name", "building", "h", "lv"]]
print(top.to_string(index=False))
print(f"buildings taller than 40 m: {(B.h > 40).sum()}; median height of tagged buildings: {B.h.median():.1f} m")
name building h lv
Washington Monument civic 169.2940 NaN
United States Capitol government 87.6000 3.0
Healy Hall university 61.0000 NaN
NaN yes 60.5900 13.0
NaN yes 57.3024 15.0
Camden NoMa apartments 54.3800 15.0
buildings taller than 40 m: 95; median height of tagged buildings: 32.6 m
The tallest tagged building is the Washington Monument at 169.3 m, followed by the Capitol at 87.6 m and Georgetown's Healy Hall at 61.0 m. 95 buildings are tagged taller than 40 m, and the median tagged height is 32.6 m, which says more about which buildings got a height tag than about the city's buildings.
The Simple 3D Buildings scheme
OpenStreetMap's 3D building tags follow the Simple 3D Buildings scheme. height is the height from the ground to the top of the roof in metres. building:levels counts floors above ground, excluding roof levels, which go in roof:levels. min_height and building:min_level describe parts that start above the ground, such as bridges between buildings. Complex buildings are split into building:part features, each with its own height. Values are strings. Plain metres are the convention, but units, ranges and typos turn up, so a parser has to accept the clean form and report the rest rather than silently converting it.
Coverage is the real limit. Height tags arrive when a mapper adds them or when an import brings them. In D.C., building:levels is on more than a third of buildings, while height is on almost none, so levels carry nearly all the height information. Where they do not, a storey height derived from buildings with both tags fills in levels, and footprint area times levels gives floor area. Those are estimates, and they should be reported with the share of buildings or footprint area they cover. For complete height coverage, lidar-derived heights or Overture's buildings complement OSM.
Building parts, underground levels and feet
- building:part: a building with parts may carry the overall shape on the outline and heights on the parts; avoid counting both.
- Underground levels:
building:levels:undergroundcounts basements, not included inbuilding:levels. - Feet: values like
85'occur in the US; convert them explicitly rather than dropping them. - 3D maps: extrude by
height, or by levels times a storey height, and say which.
Internal links
- How to publish a 3D building layer to a web map — using the heights
- How to download building footprints from OSM and Overture in Python — footprints from two sources
- How to analyse OpenStreetMap history in Python with the ohsome API — when the heights arrived
FAQ
How do I get building heights from OpenStreetMap?
Read the height tag in metres and building:levels for floors; parse both from strings, since neither is guaranteed to be a clean number.
How many buildings in OSM have heights?
It varies by city; in D.C. a minority of buildings have either tag, so measure coverage before using them.
How do I convert building levels to height?
Multiply by a storey height, ideally derived from local buildings that have both tags; 3 to 4 m per level is typical, and the D.C. tags imply 3.62 m.
What is the difference between height and building:levels?
height is metres to the top of the roof; building:levels counts floors above ground, excluding the roof.