Fixing an OpenStreetMap Tag Filter That Misses Features

On this page

Problem statement

You filter OpenStreetMap for a kind of feature, schools, supermarkets, hospitals, and the count is lower than it should be. OSM has no fixed schema. The same real-world thing can be tagged in several accepted ways, on a node or on an area, with the defining tag on the site, on the building or on both. Values can be lists separated by semicolons, and older tagging schemes live on beside newer ones. A filter written for one key=value pair silently misses the rest.

This page finds the schools of Washington, D.C. in the OpenStreetMap extract of 1 January 2026 with a naive filter, then measures what it misses: schools tagged only on the building, schools mapped as areas when areas are not assembled, and values that only look like a match. It ends with a filter that catches them and a way to check it.

Quick answer

Combine the alternative tags, assemble areas, and reduce to one point per school:

fp = (osmium.FileProcessor(str(PBF)).with_areas()
      .with_filter(osmium.filter.EntityFilter(osmium.osm.NODE | osmium.osm.AREA))
      .with_filter(osmium.filter.TagFilter(("amenity", "school"), ("building", "school")))
      .with_filter(osmium.filter.GeoInterfaceFilter(tags=["amenity", "building", "name", "isced:level"])))
S = gpd.GeoDataFrame.from_features(fp, crs=4326)
S["geometry"] = S.to_crs(32618).representative_point().to_crs(4326)
S = S[S.within(DC)]
print(f"school features inside D.C.: {len(S):,}; amenity=school: {(S.amenity == 'school').sum():,}; building=school only: {((S.building == 'school') & (S.amenity != 'school')).sum():,}")
school features inside D.C.: 470; amenity=school: 342; building=school only: 128

The combined filter finds 470 school features inside the District: 342 tagged amenity=school and 128 tagged only building=school.

Map of schools in D.C.: 342 amenity=school points in dark blue, 81 building=school features inside school sites in orange, and 47 building=school features outside any site in red. Map of schools in D.C.: 342 amenity=school points in dark blue, 81 building=school features inside school sites in orange, and 47 building=school features outside any site in red.
Three ways a school is tagged.

The naive filter, what each fix adds, count sites not features

1. The naive filter

amenity=school on nodes and ways, without areas:

fp = (osmium.FileProcessor(str(PBF)).with_locations()
      .with_filter(osmium.filter.TagFilter(("amenity", "school")))
      .with_filter(osmium.filter.GeoInterfaceFilter(tags=["amenity", "name"])))
N0 = gpd.GeoDataFrame.from_features(fp, crs=4326)
print(f"naive: {len(N0):,} features; {N0.geom_type.value_counts().to_dict()}")
naive: 335 features; {'LineString': 196, 'Point': 139}

The naive filter finds 335 features, and 196 of them are lines, because sites drawn as closed ways come back as outlines without area assembly.

2. What each fix adds

rel = q("kind = 'relation' AND tags['amenity'] = 'school'")
bld = q("tags['building'] = 'school' AND (tags['amenity'] IS NULL OR tags['amenity'] <> 'school')")
lst = q("tags['amenity'] LIKE '%school%' AND tags['amenity'] <> 'school'")
print(f"relations with amenity=school (missed without areas): {rel}")
print(f"elements with building=school but no amenity=school: {bld}")
print(f"amenity values that contain 'school' but are not exactly school: {lst}")
relations with amenity=school (missed without areas): 7
elements with building=school but no amenity=school: 128
amenity values that contain 'school' but are not exactly school: 12

Seven school sites are multipolygon relations, which the naive filter cannot see at all, and 128 elements are tagged only as school buildings. A further 12 elements carry an amenity value that contains the word without being a school in the same sense β€” 4 prep_school, 4 language_school, 3 dancing_school and 1 music_school β€” which a pattern match on school would wrongly include.

Schools found in D.C. by three filters. schools foundnaive: amenity=school, no areas335 (196 outlines)amenity or building=school, areas470 featuresminus buildings inside sites389 schools02505008 school sites are relations the naive filter cannot see spatialworkflow.io
Schools found by three filters.

3. Sites and buildings: count schools, not features

A school can be mapped as a site polygon with amenity=school and its buildings with building=school. Count building features that sit inside a school site:

sites = S[S.amenity == "school"]
fp = (osmium.FileProcessor(str(PBF)).with_areas().with_filter(osmium.filter.EntityFilter(osmium.osm.AREA))
      .with_filter(osmium.filter.TagFilter(("amenity", "school"))).with_filter(osmium.filter.GeoInterfaceFilter(tags=["name"])))
site_poly = gpd.GeoDataFrame.from_features(fp, crs=4326)
bld_only = S[(S.building == "school") & (S.amenity != "school")]
inside = gpd.sjoin(bld_only, site_poly[["geometry"]], predicate="within").index.unique()
print(f"school sites mapped as areas: {len(site_poly):,}; building-only features inside a site: {len(inside)} of {len(bld_only)}")
print(f"schools after dropping buildings inside sites: {len(S) - len(inside):,}")
school sites mapped as areas: 203; building-only features inside a site: 81 of 128
schools after dropping buildings inside sites: 389

Of the 128 building-only features, 81 lie inside one of the 203 school sites mapped as areas, so they are parts of a school already counted. Dropping them leaves 389 schools, against 335 features, many of them outlines, from the naive filter.

Tag variants across the file

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.

from pathlib import Path

import duckdb
import geopandas as gpd
import osmium
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]
con = duckdb.connect(); con.sql("INSTALL spatial; LOAD spatial")
def q(where):
    """Count the raw OSM elements matching a SQL condition on kind and tags."""
    return con.sql(f"SELECT count(*) FROM ST_ReadOSM('{PBF}') WHERE {where}").fetchone()[0]
print("boundary loaded; DuckDB spatial ready")
boundary loaded; DuckDB spatial ready

The boundary and DuckDB are ready.

Example 1: tag variants across the file

The same check for other common categories: how many features use the main tag, and how many an alternative:

pairs = [("hospital", "tags['amenity'] = 'hospital'", "tags['building'] = 'hospital' AND (tags['amenity'] IS NULL OR tags['amenity'] <> 'hospital')"),
         ("supermarket", "tags['shop'] = 'supermarket'", "tags['shop'] IN ('grocery', 'food')"),
         ("church", "tags['amenity'] = 'place_of_worship'", "tags['building'] IN ('church', 'cathedral') AND (tags['amenity'] IS NULL OR tags['amenity'] <> 'place_of_worship')")]
for name, main, alt in pairs:
    print(f"{name:>11}: main tag {q(main):>4}; alternative only {q(alt):>4}")
   hospital: main tag   15; alternative only   19
supermarket: main tag   83; alternative only    2
     church: main tag  586; alternative only   31

The pattern repeats elsewhere. 19 elements are tagged only building=hospital against 15 amenity=hospital, so a hospital count from the main tag alone misses more than half the hospital features. Supermarkets have 2 alternatives to 83, and churches 31 building-only features beside 586 places of worship.

Main tags and alternative tags for three kinds of feature in the D.C. extract. main tag and alternativesmain tagcountalternative onlycountamenity=hospital15building=hospital19shop=supermarket83shop=grocery or food2amenity=place_of_worship586building=church31raw elements, DuckDB ST_ReadOSMCheck the variants locally before filtering spatialworkflow.io
Main tag and alternatives.

Tags are conventions, not a schema

Steps to a complete OpenStreetMap feature query. a filter that finds them allmain tagamenity=schoolalternativesbuilding=schoolassemble areassites and relationsone per objectdrop buildings insitescheckagainst a register389 schools in D.C. spatialworkflow.io
A filter that finds them all.

OpenStreetMap's tags are conventions, not a schema. The wiki documents a preferred tag for most things, amenity=school for a school, but mappers also tag the building itself (building=school), map the school as a node in one place and as a site polygon in another, and occasionally list several values in one tag with semicolons. Imports add their own patterns, and old schemes such as shop=grocery survive next to newer ones. None of this is an error in the data. It is how a map built by many people and many tools looks, and the burden of matching it falls on the query.

A robust filter starts from the wiki's main tag and adds the documented alternatives. It must assemble areas so that sites mapped as polygons and relations are found, and it must reduce the result to one feature per real-world object, because a site and its buildings are several features for one school. Then it needs checking. Counts for each alternative show which patterns matter locally, and comparing the result with an official list, such as a school register, shows what is still missing.

Semicolon lists, case and lifecycle prefixes

Checks before trusting an OpenStreetMap feature count. before trusting a countwiki main tagand its alternativesareas assembledrelations includedexact valuesdancing_school is not schoolsemicolon listssplit on ;one per objectsites vs buildingsCount each variant before filtering spatialworkflow.io
Before trusting a count.
  • Semicolon lists: amenity=cafe;bar matches neither cafe nor bar exactly; split values on ; before matching.
  • Case and spaces: values are case-sensitive; School is not school, though such variants are rare.
  • Lifecycle prefixes: disused:amenity=school marks closed schools; exclude them deliberately.
  • Overpass: regular-expression queries such as ["amenity"~"school"] catch list values server-side.

FAQ

Why does my OSM query miss features?

The features are tagged differently from your filter, mapped as areas you did not assemble, or carry several values in one tag.

How do I find all schools in OpenStreetMap?

Combine amenity=school with building=school, assemble areas, and drop buildings that lie inside school sites.

What does a semicolon in an OSM tag value mean?

The feature has several values for that key, such as amenity=cafe;bar; split on ; to match each.

How do I check an OSM extract for completeness?

Count each tag variant and compare the result with an official list for the same area.