How to Read an OpenStreetMap PBF File in Python
On this page
Problem statement
The fastest way to get OpenStreetMap data for a city, a state or a country is not an API but a file: an .osm.pbf extract from Geofabrik, which holds every node, way and relation for the area in a compact binary format. Reading one into Python takes a library that understands the format and can assemble geometries, because a PBF stores roads as lists of node IDs and buildings as closed ways or multipolygon relations, not as ready-made shapes.
This page reads the Geofabrik extract of Washington, D.C. dated 1 January 2026 in two ways. pyosmium filters features by tag and builds a GeoDataFrame in one pass. DuckDB's spatial extension queries the raw elements with SQL. The page also counts what the file contains, finds the most common tags, and reads the file's timestamp.
Quick answer
With pyosmium 4, filter by tag and turn the result straight into a GeoDataFrame:
import osmium
fp = (osmium.FileProcessor(str(PBF)).with_areas()
.with_filter(osmium.filter.KeyFilter("amenity"))
.with_filter(osmium.filter.GeoInterfaceFilter(tags=["amenity", "name"])))
A = gpd.GeoDataFrame.from_features(fp, crs=4326)
print(f"{len(A):,} amenity features; geometry types {A.geom_type.value_counts().to_dict()}")
24,122 amenity features; geometry types {'Point': 8748, 'MultiPolygon': 8054, 'LineString': 7320}
pyosmium returns 24,122 features with an amenity tag: 8,748 points from nodes, 8,054 polygons, and 7,320 lines. The lines are mostly the outlines of closed ways that also came back as polygons, a duplication covered in its own fix.
What is in the file, common tags, filters and geometry
1. What is in the file
DuckDB reads the PBF as a table of raw elements, one row per node, way or relation, with tags as a map:
con = duckdb.connect()
con.sql("INSTALL spatial; LOAD spatial")
print(con.sql(f"""SELECT kind, count(*) AS elements, count(*) FILTER (WHERE cardinality(tags) > 0) AS tagged
FROM ST_ReadOSM('{PBF}') GROUP BY kind ORDER BY elements DESC""").df().to_string(index=False))
kind elements tagged
node 1917699 100645
way 271679 267121
relation 5211 5211
The file holds 1,917,699 nodes, 271,679 ways and 5,211 relations. Only 100,645 nodes, about one in nineteen, carry tags of their own; the rest exist to give ways their shape. Nearly every way and every relation is tagged.
2. The most common tags
Tags are free-form key=value pairs. Unnest the maps to see which keys dominate:
top = con.sql(f"""SELECT k AS key, count(*) AS n FROM (SELECT unnest(map_keys(tags)) AS k FROM ST_ReadOSM('{PBF}'))
GROUP BY k ORDER BY n DESC LIMIT 10""").df()
print(top.to_string(index=False))
key n
building 162241
addr:street 139356
addr:housenumber 139093
dcgis:gis_id 132966
highway 116347
source 110253
dcgis:captureyear 90764
dataset 83546
addr:postcode 74805
dcgis:square 63865
building is the most common key, on 162,241 elements, followed by addresses, dcgis:gis_id and highway. Several of the top ten, dcgis:gis_id, dcgis:captureyear and dcgis:square, are not OSM conventions but tags carried over from the District's own GIS data, a sign of how much of D.C.'s map was imported.
3. Filters and geometry
pyosmium's filters run before geometry is built, so only matching objects are processed. KeyFilter keeps any value of a key, TagFilter exact key=value pairs, and with_areas() assembles polygons from closed ways and multipolygon relations:
fp = (osmium.FileProcessor(str(PBF)).with_areas()
.with_filter(osmium.filter.TagFilter(("building", "yes"), ("building", "house")))
.with_filter(osmium.filter.GeoInterfaceFilter(tags=["building"])))
B = gpd.GeoDataFrame.from_features(fp, crs=4326)
print(f"building=yes or house: {len(B):,}; types {B.geom_type.value_counts().to_dict()}")
print(B.building.value_counts().to_dict())
building=yes or house: 219,163; types {'MultiPolygon': 109524, 'LineString': 109352, 'Point': 287}
{'house': 118618, 'yes': 100545}
TagFilter keeps 219,163 features tagged building=yes or building=house: 118,618 houses and 100,545 generic buildings. 109,524 are polygons and 109,352 lines, almost one line per polygon, plus 287 buildings mapped only as points.
When the D.C. extract was made
The data used on this page
The Geofabrik extract of OpenStreetMap for the District of Columbia, the dated file district-of-columbia-260101.osm.pbf, so the numbers on this page can be reproduced. OpenStreetMap data is © OpenStreetMap contributors, under the ODbL. pyosmium 4 is installed with pip install osmium.
from pathlib import Path
import duckdb
import geopandas as gpd
import osmium
import requests
Path("data").mkdir(exist_ok=True)
PBF = Path("data/district-of-columbia-260101.osm.pbf")
if not PBF.exists():
r = requests.get("https://download.geofabrik.de/north-america/us/district-of-columbia-260101.osm.pbf", timeout=600)
r.raise_for_status()
PBF.write_bytes(r.content)
print(f"{PBF.name}: {PBF.stat().st_size / 1e6:.1f} MB")
district-of-columbia-260101.osm.pbf: 20.3 MB
The whole of Washington, D.C., with a margin into Maryland and Virginia, fits in a 20.3 MB file.
Example 1: when the data was extracted
A Geofabrik extract records the moment of its source data in the file header. Read it, and the bounding box, without reading any elements:
r = osmium.io.Reader(str(PBF), osmium.osm.osm_entity_bits.NOTHING)
h = r.header()
print(f"replication timestamp: {h.get('osmosis_replication_timestamp')}")
print(f"bounding box: {h.box()}")
r.close()
replication timestamp: 2026-01-01T21:21:30Z
bounding box: (-77.1201000/38.7909880 -76.9090600/38.9960300)
The extract reflects the map as of 21:21 UTC on 1 January 2026. Its bounding box runs from 77.12° W, 38.79° N to 76.91° W, 39.00° N.
Nodes, ways and relations
OpenStreetMap has three element types. Nodes are points with coordinates. Ways are ordered lists of node IDs, a road, or a building outline when the first and last nodes are the same. Relations group other elements, most importantly multipolygons, which describe lakes, parks and buildings with holes, and administrative boundaries. A PBF file stores them in that order in compressed blocks, as Protocol Buffers with delta-encoded coordinates, which is why the whole of D.C. fits in about 21 MB. To get geometries, a reader must look up each way's node coordinates and assemble relations from their member ways. pyosmium does this in C++ and hands back only what passes the filters.
The two tools suit different jobs. pyosmium is a streaming reader: it builds geometry, applies tag filters early and scales to country extracts with modest memory. DuckDB treats the file as a table of raw elements. It is excellent for counting and exploring tags with SQL, but it returns node IDs rather than geometries for ways, so assembling lines and polygons takes joins. Either way, the file is a snapshot: Geofabrik publishes a new one every day and keeps dated copies, and the header timestamp says exactly what state of the map it holds.
Duplicate areas, extract edges and large countries
- Duplicates with with_areas(): a closed way that is also an area is returned twice, once as a line and once as a polygon; see features counted twice.
- Extract edges: features crossing the extract's border can be cut or incomplete, especially large relations such as state boundaries.
- Large countries: filter by tag first, and use
with_locations("flex_mem")or a disk-based index for big files. - Other readers: pyrosm, QuackOSM and osmium-tool (command line) read the same files.
Internal links
- The OpenStreetMap data model explained — nodes, ways and relations
- How to download OpenStreetMap data in Python with OSMnx — the API route
- OSM file formats explained — PBF, XML and the rest
FAQ
How do I read an .osm.pbf file in Python?
Use pyosmium: osmium.FileProcessor(path).with_areas().with_filter(...) and GeoDataFrame.from_features(), or DuckDB's ST_ReadOSM for SQL over the raw elements.
Where do I download OSM PBF files?
From Geofabrik (download.geofabrik.de), which publishes daily extracts by continent, country and region, and keeps dated copies.
Why do ways have no coordinates in DuckDB's ST_ReadOSM?
Ways store node IDs (refs); join them to the nodes' lat and lon to build lines, or use pyosmium, which assembles geometries itself.
How do I know how current a PBF file is?
Read the osmosis_replication_timestamp from the file header with osmium.io.Reader(...).header().