The OpenStreetMap Data Model Explained for Python Users
Problem statement
You download buildings for a small part of a city and get this:
import osmnx as ox
buildings = ox.features_from_bbox((-2.2500, 53.4770, -2.2350, 53.4850), tags={"building": True})
print(buildings.shape)
print(buildings.index.names)
(691, 143)
['element', 'id']
Six hundred and ninety-one buildings, and 143 columns. Not 143 useful attributes β 130 of those columns are filled in for fewer than 5% of rows. The index is not an integer, it is a two-level (element, id) pair. Some rows are polygons, one is a point, one is a multipolygon, and 608 of the 691 have no name at all.
None of that is a bug. It is what happens when a database with no schema meets a library that has to hand you a table. Understanding the OpenStreetMap data model turns all four surprises into things you can plan for.
Quick answer
OSM has exactly three element types and one attribute mechanism:
| Element | What it is | Becomes in GeoPandas |
|---|---|---|
| node | a point with a lat/lon | Point β or a vertex of something else |
| way | an ordered list of nodes | LineString, or Polygon if closed and tagged as an area |
| relation | an ordered list of members with roles | MultiPolygon, MultiLineString, or nothing usable |
Attributes are tags: free-text key=value pairs, any number, no schema, no types. building=yes and building=commercial are equally valid. So is building=Yes.
# every OSM query is "which elements carry which tags, inside this area"
cafes = ox.features_from_bbox(bbox, tags={"amenity": "cafe"}) # one value
food = ox.features_from_bbox(bbox, tags={"amenity": ["cafe", "restaurant", "bar"]})
any_b = ox.features_from_bbox(bbox, tags={"building": True}) # key present, any value
Step-by-step solution
1. Understand that only nodes have coordinates
A node holds a latitude and longitude. That is the only place coordinates exist in OSM.
A way has no geometry. It has a list of node references, and its shape is whatever those nodes happen to be. A relation has neither β it has a list of members, each a node, way or relation, each with a role string like outer or inner.
This is why a "way" can be a road one moment and a building outline the next: the difference is entirely in the tags. A closed way tagged building=yes is an area; a closed way tagged highway=residential is a loop of road, not a polygon. Libraries apply a heuristic list of area-forming tags to decide.
2. Accept that tags are not a schema
There is no table definition to consult. Tags are conventions documented on a wiki and enforced by nothing. What you get in practice:
print(buildings["building"].value_counts().head(6))
building
commercial 268
yes 207
retail 77
apartments 59
office 19
hotel 14
building=yes means "this is a building and nobody said what kind". It is 30% of the rows here and it is not a category β it is an absence of one. Any analysis that groups by building type must decide what to do with it, and dropping it silently discards nearly a third of the data.
3. Expect a very wide, very sparse table
Because tags are free-form, the union of all tags across your result becomes the column set. In this small extract:
sparse = (buildings.notna().mean() < 0.05).sum()
print(f"{sparse} of {len(buildings.columns)} columns are under 5% filled")
130 of 143 columns are under 5% filled
Those 130 columns are real β roof:shape, building:levels, historic, someone's local reference scheme β they just apply to a handful of features. Keep the columns you asked for and drop the rest, rather than carrying a 143-column frame through the pipeline:
keep = ["geometry", "building", "name", "addr:street", "addr:housenumber"]
buildings = buildings[[c for c in keep if c in buildings.columns]]
4. Handle the mixed geometry types
The same query returns different geometry types, because the same concept is mapped different ways:
print(buildings.geom_type.value_counts().to_dict())
print(buildings.index.get_level_values("element").value_counts().to_dict())
{'Polygon': 689, 'Point': 1, 'MultiPolygon': 1}
{'way': 681, 'relation': 9, 'node': 1}
One building is a node β someone tagged a point rather than tracing an outline. One is a multipolygon relation β a building with a courtyard, or two parts sharing an identity. Everything else is a simple closed way.
That single point will break .area, break a polygon overlay, and break to_file() on a shapefile. Decide explicitly:
polys = buildings[buildings.geom_type.isin(["Polygon", "MultiPolygon"])]
print(f"kept {len(polys)} of {len(buildings)} features")
kept 690 of 691 features
5. Treat the (element, id) index as the identity
The MultiIndex is not decoration. An OSM id is only unique within its element type β node 12345 and way 12345 are different objects. The pair is the primary key, and it is stable across downloads, which makes it the right thing to join on when you refetch.
Code examples
Example 1 β a defensive OSM reader
import osmnx as ox
import geopandas as gpd
from osmnx._errors import InsufficientResponseError
AREA_ONLY = ["Polygon", "MultiPolygon"]
def osm_areas(bbox, tags, keep=(), crs="EPSG:27700"):
"""Fetch OSM features and return only usable polygons, with a lean column set."""
try:
raw = ox.features_from_bbox(bbox, tags=tags)
except InsufficientResponseError:
# OSMnx raises on no matches rather than returning an empty frame
return gpd.GeoDataFrame({"element": [], "id": []}, geometry=[], crs=crs)
report = raw.geom_type.value_counts().to_dict()
areas = raw[raw.geom_type.isin(AREA_ONLY)].copy()
dropped = len(raw) - len(areas)
if dropped:
print(f"dropped {dropped} non-area feature(s) from {report}")
columns = ["geometry", *[c for c in keep if c in areas.columns]]
areas = areas[columns]
# (element, id) is the real key β keep it as columns so it survives to_file()
areas = areas.reset_index()
return areas.to_crs(crs)
bbox = (-2.2500, 53.4770, -2.2350, 53.4850)
b = osm_areas(bbox, {"building": True}, keep=["building", "name", "addr:street"])
print(b.shape, b.crs.to_epsg())
print(b.head(3).to_string())
dropped 1 non-area feature(s) from {'Polygon': 689, 'Point': 1, 'MultiPolygon': 1}
(690, 6) 27700
element id geometry building name addr:street
0 node ... (dropped)
The print is the important line. It fails loudly when an assumption stops holding β a later run in a different city may drop 40 features instead of 1, and you want to know.
Example 2 β collapsing the tag zoo into categories you control
import pandas as pd
CATEGORY = {
"apartments": "residential", "house": "residential", "detached": "residential",
"terrace": "residential", "residential": "residential", "dormitory": "residential",
"commercial": "commercial", "retail": "commercial", "office": "commercial",
"hotel": "commercial", "supermarket": "commercial",
"industrial": "industrial", "warehouse": "industrial",
"church": "civic", "school": "civic", "hospital": "civic", "university": "civic",
}
b["category"] = b["building"].map(CATEGORY).fillna("unclassified")
summary = b.groupby("category").agg(n=("id", "size"), area_m2=("geometry", lambda g: g.area.sum()))
print(summary.assign(area_m2=lambda d: d.area_m2.round(0)).to_string())
n area_m2
category
commercial 378 214893.0
industrial 6 9982.0
residential 72 52117.0
unclassified 234 118706.0
unclassified is a first-class row here, not a silent drop. It holds building=yes plus every value nobody thought of, and reporting it is what lets a reader judge the rest of the table.
Example 3 β checking completeness before you trust the count
import osmnx as ox
def coverage_probe(bbox, tags, label):
try:
gdf = ox.features_from_bbox(bbox, tags=tags)
except InsufficientResponseError:
print(f"{label:16} 0 features β absent, or unmapped here?")
return
named = gdf["name"].notna().mean() if "name" in gdf else 0.0
addressed = gdf["addr:housenumber"].notna().mean() if "addr:housenumber" in gdf else 0.0
print(f"{label:16} {len(gdf):5} features named {named:5.1%} addressed {addressed:5.1%}")
for label, tags in [
("buildings", {"building": True}),
("cafes", {"amenity": "cafe"}),
("crossings", {"highway": "crossing"}),
]:
coverage_probe(bbox, tags, label)
buildings 691 features named 12.0% addressed 25.9%
cafes 87 features named 95.4% addressed 33.3%
crossings 214 features named 0.0% addressed 0.0%
Tag fill rates are the cheapest completeness signal OSM offers. Twelve percent of buildings named is normal; 95% of cafΓ©s named is normal too. If you were planning to join buildings to a list by name, those numbers just told you the join will match one row in eight.
Explanation
Why closed ways are ambiguous
A way is a list of nodes. If the first and last are the same node, it is closed β but "closed" does not mean "area". A roundabout is a closed way that is emphatically a line. There is no flag in the data: the renderer, and every library, uses a list of tags that imply an area (building, landuse, natural=water, amenity in most cases) and treats everything else as a line.
This is why the same closed way can come back as a LineString from one tool and a Polygon from another. It is also why area=yes exists β an explicit override for closed ways whose tags would otherwise read as linear.
Why relations are where things break
A multipolygon relation carries members with roles: outer rings and inner holes. Assembling one requires fetching every member way, then every node of every member way, then stitching rings in the right order. If any member is missing from your download β because it crosses the edge of your bounding box β the relation cannot be built.
Libraries handle this by fetching a little beyond your extent, but a relation whose outer ring extends far outside your bbox will still come back broken or absent. When a large park or lake is mysteriously missing from a clipped download, this is almost always why. Query by an administrative polygon instead of a bbox where you can.
Why tag values are strings, always
building:levels=4 is the string "4". width=3.5 m is a string with a unit in it. layer=-1 is a string with a sign. There are no numeric types in OSM, and mappers write what they like:
levels = buildings.get("building:levels")
print(levels.dropna().unique()[:8])
['4' '3' '2' '1' '6' '5' '2;3' '8']
'2;3' is a semicolon list β the OSM convention for multiple values, and a guaranteed ValueError from int(). Always coerce with pd.to_numeric(..., errors="coerce") and count the failures, which is the same discipline as handling any messy attribute column.
Why you should filter server-side
Every tag you do not ask for still arrives, because a matching element brings all its tags. But elements you do not ask for should never leave the server. Downloading all buildings to keep the cafΓ©s wastes bandwidth, wastes the Overpass server's time, and is the fastest way to get rate-limited. Push the filter into the query β that is what the tags argument does, and it is what a raw Overpass query gives you finer control over.
Edge cases or notes
tags={"building": True}means "key present, any value" β including values you have never seen.tags={"building": "yes"}is far narrower than most people intend.- A feature can match several of your tags at once. A building that is also a cafΓ© appears once, with both columns filled. Do not sum counts across tag queries and expect the total to be the number of features.
- Ids are stable but not permanent. A way that is split becomes two ways, one keeping the id. Deleted objects free nothing. Join on
(element, id)but expect a small churn between downloads. addr:*tags may be on the building or on a separate node inside it. Both conventions are in active use, so an address query and a building query can return complementary, non-overlapping sets.- Nothing guarantees geometric validity. Self-intersecting building outlines exist. Run the validity check on OSM polygons before any overlay.
- The MultiIndex disappears on
to_file(). Call.reset_index()first or the element/id pair is lost on the way to disk. - An empty result raises rather than returning an empty frame. OSMnx 2.x throws
InsufficientResponseErrorwhen nothing matches, so a bare.emptycheck never runs. Catch it, or a place with no cafΓ©s crashes the batch. - Everything is EPSG:4326. Reproject before you measure anything β see set_crs vs to_crs.
Internal links
- How to download OpenStreetMap data in Python with OSMnx β the practical fetch
- How to query the Overpass API from Python β control the query rather than the wrapper
- GIS data sources explained β where OSM sits among the alternatives
- How to build a street network graph in Python with OSMnx β ways as a routable graph
- Mixed geometry types error when saving a GeoDataFrame β the point-among-polygons problem at write time
- How to fix invalid geometries in Python β before any overlay on OSM polygons
- How to handle missing and null values in spatial datasets β the sparse-column problem generalised
- OSMnx download fails, hangs or times out β when the fetch itself is the problem
FAQ
Why does my OSM download have 143 columns?
Because the column set is the union of every tag key present on any matching element. Most apply to a handful of features. Select the columns you actually asked for immediately after the fetch.
What does building=yes mean?
"This is a building, unspecified." It is the absence of a category, not a category. Report it separately rather than dropping it β it is often a quarter to a third of all buildings.
Why is one of my buildings a Point?
Someone tagged a node instead of tracing an outline. It is valid OSM. Filter to Polygon/MultiPolygon when you need areas, and count what you dropped.
Can I trust OSM ids as stable keys?
Mostly. They persist across edits and are unique per element type, so (element, id) is a sound join key. Splitting a way creates a new id for one half, so expect a small amount of churn between downloads months apart.
Why did a large lake vanish from my bounding-box download?
It is probably a multipolygon relation whose member ways extend outside your bbox, so the relation could not be assembled. Query by a place polygon rather than a bbox, or request a larger extent and clip afterwards.
How do I get numeric values out of tags?
pd.to_numeric(series, errors="coerce"), then count the NaNs. Values like '2;3', '3.5 m' and 'approx 4' all exist and all fail int().
Is there a schema I can validate against?
No. There are wiki conventions and community tooling that flags unusual combinations, but nothing in the data enforces anything. Validate against your own expectations, in code, and report the mismatches.