Fixing OpenStreetMap Features Counted Twice as Lines and Polygons

On this page

Problem statement

You read buildings or amenities from an OpenStreetMap file with areas enabled, count them, and get far more than expected. The duplicates are not in the data: they come from how the reader assembles geometry. A closed way, a building outline or a car park, is both a way and an area. pyosmium's with_areas() emits it twice, once as a LineString for the way and once as a MultiPolygon for the area, and any count, map or join that does not drop one of them doubles those features.

This page reproduces the doubling on the Washington, D.C. OpenStreetMap extract of 1 January 2026, measures how many features it adds, and fixes it two ways: by filtering geometry types after reading, and by asking pyosmium for nodes and areas only.

Quick answer

Keep nodes and areas, and drop the way copies, with an entity filter:

fp = (osmium.FileProcessor(str(PBF)).with_areas()
      .with_filter(osmium.filter.EntityFilter(osmium.osm.NODE | osmium.osm.AREA))
      .with_filter(osmium.filter.KeyFilter("amenity"))
      .with_filter(osmium.filter.GeoInterfaceFilter(tags=["amenity"])))
fixed = gpd.GeoDataFrame.from_features(fp, crs=4326)
print(f"amenity features with nodes and areas only: {len(fixed):,}; {fixed.geom_type.value_counts().to_dict()}")
amenity features with nodes and areas only: 16,802; {'Point': 8748, 'MultiPolygon': 8054}

With the entity filter, 16,802 amenity features remain: 8,748 points and 8,054 areas, and no lines.

Map of a 1.4 km square of downtown D.C. with amenity features: 688 nodes as dots, 81 areas in pale blue, and 79 red lines tracing the outlines of nearly all of the areas. Map of a 1.4 km square of downtown D.C. with amenity features: 688 nodes as dots, 81 areas in pale blue, and 79 red lines tracing the outlines of nearly all of the areas.
Line copies on top of the areas.

Reproduce, check the duplicates, see which counts change

1. Reproduce the double count

fp = (osmium.FileProcessor(str(PBF)).with_areas()
      .with_filter(osmium.filter.KeyFilter("amenity"))
      .with_filter(osmium.filter.GeoInterfaceFilter(tags=["amenity"])))
raw = gpd.GeoDataFrame.from_features(fp, crs=4326)
lines = raw[raw.geom_type == "LineString"]
print(f"amenity features read: {len(raw):,}; {raw.geom_type.value_counts().to_dict()}")
print(f"lines that are closed rings: {lines.geometry.is_closed.sum():,} of {len(lines):,}")
amenity features read: 24,122; {'Point': 8748, 'MultiPolygon': 8054, 'LineString': 7320}
lines that are closed rings: 7,309 of 7,320

Without it, the same tag filter returns 24,122 features, including 7,320 lines. 7,309 of those lines are closed rings.

2. Check that the lines duplicate the polygons

Each closed line should have a polygon with the same outline. Compare their boundaries:

poly = raw[raw.geom_type == "MultiPolygon"]
closed = lines[lines.geometry.is_closed]
ring = gpd.GeoDataFrame(geometry=closed.geometry.apply(lambda g: Polygon(g.coords)), crs=4326)
match = gpd.sjoin(ring.to_crs(32618), poly.to_crs(32618)[["geometry"]], predicate="covered_by", how="left")
print(f"closed lines with an identical-outline polygon covering them: {match.index_right.notna().groupby(level=0).any().sum():,} of {len(closed):,}")
closed lines with an identical-outline polygon covering them: 7,309 of 7,309

Every one of the 7,309 closed lines lies on a polygon of the same outline: they are copies, and reading them inflates the count by 44%.

Amenity counts in the D.C. extract read with and without the line copies of closed ways. counts with and without duplicatesparking, duplicated11,396parking, fixed6,094worship, duplicated913worship, fixed58504000800012kAll amenities: 24,122 read, 16,802 after the fix spatialworkflow.io
What the duplicates do to counts.

3. Which counts change

Count the five most common amenity types both ways:

both = pd.DataFrame({"read with duplicates": raw.amenity.value_counts(), "nodes and areas only": fixed.amenity.value_counts()})
both = both.sort_values("read with duplicates", ascending=False).head(5)
print(both.to_string())
                  read with duplicates  nodes and areas only
amenity                                                     
parking                          11396                  6094
bench                             2112                  2093
restaurant                        1160                  1087
parking_space                     1004                   503
place_of_worship                   913                   585

Parking nearly doubles, from 6,094 to 11,396, because car parks are mostly areas. Parking spaces and places of worship are similarly inflated, while benches, almost all nodes, barely change.

The open lines that remain

The data used on this page

The Geofabrik OpenStreetMap extract for the District of Columbia dated 1 January 2026 (Β© OpenStreetMap contributors, ODbL), read with pyosmium 4.

from pathlib import Path

import geopandas as gpd
import osmium
import pandas as pd
import requests
from shapely.geometry import Polygon

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} ready")
district-of-columbia-260101.osm.pbf ready

The dated D.C. extract is ready.

Example 1: the open lines that remain

A few amenity ways are genuinely lines, such as a bench drawn as a line or a row of parking. The geometry-type filter would drop them; the entity filter does too. List them before deciding:

open_lines = lines[~lines.geometry.is_closed]
print(f"open amenity lines: {len(open_lines)}; types {open_lines.amenity.value_counts().head(5).to_dict()}")
open amenity lines: 11; types {'bench': 11}

The only genuine lines are 11 benches drawn as lines, which either fix drops. Keep them separately if benches matter.

Why pyosmium returns a closed way twice when areas are enabled. one closed way, two featuresclosed wayamenity=parkingway handlerLineStringarea assemblerMultiPolygonGeoInterfaceFilterboth becomefeaturescounted twice7,309 copieswith_areas() keeps the way as well as the area spatialworkflow.io
One closed way, two features.

A closed way is ambiguous by design

Two fixes for duplicated OSM features. two fixesEntityFilter(NODE | AREA)βœ“drops ways before geometry is builtβœ“fasterβœ“loses 11 benches drawn as linesdrop LineString after readingβœ“simple to addβœ“builds the lines firstβœ“same result: 16,802D.C. amenities, OSM 1 January 2026 spatialworkflow.io
Two fixes.

In OpenStreetMap a closed way is ambiguous by design: the same ring of nodes may be a line, such as a roundabout or a fence, or the outline of an area, such as a building, depending on its tags. pyosmium does not guess. With with_areas() enabled, its area assembler builds an area from every closed way whose tags suggest one, and from every multipolygon relation, while the way itself still passes through the processor as a way. The GeoInterfaceFilter turns both into features, so each building or amenity outline arrives twice, once as a LineString and once as a MultiPolygon.

The clean fix is to choose entity types before geometry is built. EntityFilter(NODE | AREA) keeps point features and assembled areas and drops ways, which loses only the few amenities that really are open lines. Filtering on geometry type afterwards works too, but costs the time of building the lines first. Whichever is used, count before and after: a feature count that drops by a third, as here, is worth stating, because every analysis downstream inherits it.

Lines you need, area ids and members

Double-counting in OSM extracts: symptoms and fixes. double countsSymptomFixparking count doubledEntityFilter(NODE | AREA)lines on polygon outlinesdrop closed LineStringssite and building bothdrop buildings inside siteshighways missingread ways, no with_areas()44% more features with duplicates spatialworkflow.io
Double counts.
  • Lines you need: for highways, waterways and railways, keep WAY and do not use with_areas(); the doubling is a polygon-feature problem.
  • Area IDs: pyosmium gives areas their own IDs, twice the way ID for ways and twice plus one for relations, so IDs do not collide but do not match the way either.
  • Multipolygon members: old-style multipolygons with tags on the outer way can still produce both a way feature and an area; the entity filter handles these too.
  • Other readers: OSMnx, pyrosm and GDAL's OSM driver each decide differently; check geometry types whatever the tool.

FAQ

Why does pyosmium return each building twice?

With with_areas(), a closed way is returned as a way (a LineString) and as an assembled area (a MultiPolygon).

How do I stop OSM features being counted twice?

Add osmium.filter.EntityFilter(osmium.osm.NODE | osmium.osm.AREA) before the tag filter, or drop LineString rows after reading.

Will dropping LineStrings lose data?

Only genuinely linear features of that tag; list the open lines first to check.

Does this happen with highways?

Not if you read highways as ways without with_areas(); it affects features that are areas.