Handling missing tags in OSM data pipelines Jump to heading
Resolve absent OSM keys — highway, surface, maxspeed, oneway, lanes — through deterministic fallback chains and route the unresolvable to quarantine, so a sparse contributor edit never silently downgrades a routing graph three stages downstream.
Prerequisites Jump to heading
Why tags go missing Jump to heading
OpenStreetMap’s schemaless model guarantees contributor flexibility, but that freedom means any key can be absent on any element. Critical keys go missing for three distinct reasons, and they must not be treated the same way: a key is legitimately absent (a footpath has no maxspeed), it is unmapped (a road that simply has not been surveyed for surface), or it is an extraction artifact (a value clipped to an empty string or coerced to NaN during a spatial join). The first justifies a documented default; the second and third must be inferred or quarantined, never guessed. Distinguishing them is the whole job of this stage, which sits inside Batch Attribute Mapping Strategies and receives the quarantine routing that page defines.
A naive .fillna() violates OSM tagging semantics by collapsing all three cases into one fabricated value. The correct approach is a priority-ordered chain: try the primary key, then ranked secondary keys that carry the same signal, then a region-appropriate default, and only if all fail, quarantine the row. This presupposes that values have already been trimmed and case-resolved — that cleaning belongs to Value Standardization & Regex Cleaning, and the diagnostic below treats a whitespace-only or "nan" string as missing precisely because uncleaned input would otherwise read as present.
The complete solution Jump to heading
Run a coverage diagnostic first, then resolve fallbacks, apply regional defaults, and split valid rows from a quarantine partition. The module is self-contained against pandas>=2.1.0 / geopandas>=1.0.0:
"""Detect and resolve missing OSM tags, quarantining the unresolvable.
Requires: pandas>=2.1.0, geopandas>=1.0.0, pyrosm>=0.6.2, Python 3.10+.
"""
import logging
import geopandas as gpd
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
# Strings that *look* present but are extraction artifacts, not real values.
SENTINELS = ["", "nan", "none", "NaN", "None"]
# Priority-ordered fallback chains: primary key -> ranked secondary keys.
FALLBACK_RULES: dict[str, list[str]] = {
"highway": ["route", "railway", "waterway"],
"surface": ["tracktype"],
"maxspeed": ["maxspeed:forward", "maxspeed:backward", "zone:maxspeed"],
}
# Defaults applied ONLY where absence has a documented meaning per region.
REGION_DEFAULTS: dict[str, dict[str, object]] = {
"EU": {"oneway": "no"},
"US": {"oneway": "no"},
}
def _missing_mask(col: pd.Series) -> pd.Series:
"""True where a value is null, empty, or a coercion sentinel."""
cleaned = col.astype("string").str.strip()
return cleaned.isna() | cleaned.str.lower().isin([s.lower() for s in SENTINELS])
def diagnose_tag_coverage(gdf: gpd.GeoDataFrame, keys: list[str]) -> pd.DataFrame:
"""Quantify present/missing counts per key before any imputation runs."""
total = max(len(gdf), 1)
rows = []
for key in keys:
col = gdf.get(key, pd.Series(dtype="object"))
missing = int(_missing_mask(col).sum()) if len(col) else total
present = total - missing
rows.append({
"key": key,
"present": present,
"missing": missing,
"coverage_pct": round(present / total * 100, 2),
})
report = pd.DataFrame(rows).set_index("key")
logger.info("tag coverage:\n%s", report)
return report
def resolve_missing_tags(
gdf: gpd.GeoDataFrame, rules: dict[str, list[str]] = FALLBACK_RULES
) -> gpd.GeoDataFrame:
"""Backfill each primary key from its ranked fallback chain, in place."""
gdf = gdf.copy()
for primary, chain in rules.items():
if primary not in gdf.columns:
gdf[primary] = pd.NA
mask = _missing_mask(gdf[primary])
for fallback_key in chain:
if fallback_key not in gdf.columns or not mask.any():
continue
donor_ok = ~_missing_mask(gdf[fallback_key])
fill_here = mask & donor_ok
gdf.loc[fill_here, primary] = gdf.loc[fill_here, fallback_key]
logger.debug("filled %d %r from %r", int(fill_here.sum()), primary, fallback_key)
mask = mask & ~fill_here # only still-missing rows need the next link
return gdf
def apply_regional_defaults(
gdf: gpd.GeoDataFrame, region_code: str
) -> gpd.GeoDataFrame:
"""Backfill documented defaults (e.g. oneway=no) for the given region."""
gdf = gdf.copy()
defaults = REGION_DEFAULTS.get(region_code, REGION_DEFAULTS["EU"])
for col, value in defaults.items():
if col not in gdf.columns:
gdf[col] = pd.NA
filled = _missing_mask(gdf[col])
gdf.loc[filled, col] = value
logger.info("region %s: defaulted %d rows of %r to %r",
region_code, int(filled.sum()), col, value)
return gdf
def split_quarantine(
gdf: gpd.GeoDataFrame, required: list[str]
) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
"""Send rows still missing a required key to a dead-letter partition."""
unresolved = pd.Series(False, index=gdf.index)
for key in required:
unresolved |= _missing_mask(gdf.get(key, pd.Series(index=gdf.index, dtype="object")))
keep_cols = [c for c in (*required, *FALLBACK_RULES) if c in gdf.columns]
quarantine = gdf.loc[unresolved, keep_cols].assign(
quarantine_reason="missing_required_after_fallback"
)
valid = gdf.loc[~unresolved]
logger.info("resolved %d valid, %d quarantined", len(valid), len(quarantine))
return valid, quarantine
A typical driver wires the stages together, reading the extract once and emitting two partitions:
from pyrosm import OSM
def process_extract(pbf_path: str, region: str = "EU"):
gdf = OSM(pbf_path).get_network(network_type="driving")
diagnose_tag_coverage(gdf, ["highway", "surface", "maxspeed", "oneway"])
gdf = resolve_missing_tags(gdf)
gdf = apply_regional_defaults(gdf, region)
valid, quarantine = split_quarantine(gdf, required=["highway"])
return valid, quarantine
Step-by-step walkthrough Jump to heading
_missing_maskdefines “missing” once. Every other function depends on it, so the policy that a whitespace-only or"None"string counts as absent lives in exactly one place. Casting to the nullable"string"dtype first avoids the object-array boxing that makes.stroperations slow on large extracts.diagnose_tag_coveragemeasures before it mutates. Run it on the raw extract and log the result. Ifhighwaycoverage on a driving network drops below ~95%, that is a survey gap or an extraction bug to investigate — not something to paper over with defaults.resolve_missing_tagswalks the chain in rank order. For each primary key it recomputes the still-missing mask after every donor, so a row is only ever filled by the highest-priority fallback that actually has a value. The order of the list inFALLBACK_RULESis the policy; reordering it changes results, which is why it is data, not control flow.apply_regional_defaultsis deliberately separate. Defaults are the one place data is invented, so they are isolated, logged with a count, and keyed by region.oneway=nois safe to default because its absence has a documented meaning in OSM;maxspeedis not, which is why it never appears here.split_quarantinerefuses to guess. Any row still missing a required key after fallbacks and defaults is routed to a dead-letter frame that retains its raw payload and a reason string, so a reviewer can diagnose it without re-joining the source extract. This is the quarantine partition that Error Handling in Large OSM Extracts triages.
For planetary or continental files that exceed RAM, drive the same functions over bounded slices rather than one monolithic frame, gating on psutil.virtual_memory().percent and flushing intermediate Parquet between chunks — the streaming and spill patterns are covered by Memory-Efficient Chunk Processing.
Verification Jump to heading
Confirm the stage behaved before handing the result to a graph builder:
- The coverage log shows
present + missing == len(gdf)for every key, andcoverage_pctforhighwayis near 100 on anetwork_type="driving"extract. - After
resolve_missing_tags, re-runningdiagnose_tag_coverageonmaxspeedshows higher coverage than before — themaxspeed:forward/backwarddonors filled real gaps. split_quarantinereturns avalidframe with zero missinghighwayvalues: assert_missing_mask(valid["highway"]).sum() == 0.- The quarantine frame’s row count is small and stable batch-to-batch. A sudden spike means a stale fallback table after a large import, not a code bug.
- Defaulted rows carry the region value:
(apply_regional_defaults(g, "EU")["oneway"] == "no").sum()equals the pre-default missing count foroneway.
Common errors and fixes Jump to heading
| Error / symptom | Root cause | One-line fix |
|---|---|---|
| Every row reads as “present” despite blanks | .notna() alone misses "" and "nan" strings |
Use _missing_mask, which strips and matches the sentinel set |
KeyError on a fallback key |
The donor column is absent in this regional extract | Guard with if fallback_key not in gdf.columns: continue |
| Routing graph treats all roads two-way | oneway left null, builder defaults to bidirectional |
Apply apply_regional_defaults before graph conversion |
maxspeed filled with imperial numbers |
Defaulted instead of cleaned/converted | Never default maxspeed; convert units in the cleaning stage |
| Quarantine count grows every run | Fallback table stale after an import | Audit recent changesets; add the new key variants to FALLBACK_RULES |
SettingWithCopyWarning on .loc writes |
Operating on a slice view | Call .copy() once at function entry (the snippet already does) |
Spec reference Jump to heading
OSM places no schema constraint on which keys an element carries — any key may be absent — so “missing” is a pipeline concept, not a format error. The authoritative meaning of each key and whether absence is significant is defined in the OpenStreetMap Map Features and Tags documentation; treat those as the source of truth for which defaults are legitimate. The pattern-matching used to detect sentinel values follows the Python
remodule and pandas nullablestringdtype semantics.
Related Jump to heading
- Batch Attribute Mapping Strategies — the mapping stage whose quarantine routing this page implements.
- Value Standardization & Regex Cleaning — the cleaning that must precede missing-value detection.
- Error Handling in Large OSM Extracts — triaging the dead-letter partition this stage emits.
- Memory-Efficient Chunk Processing — streaming the same logic over extracts larger than RAM.
- OSMnx Graph Conversion Techniques — where missing
oneway/lanessilently corrupt topology if not backfilled first. - Tag Taxonomy & Key-Value Standards — the controlled vocabulary that decides which absences are meaningful.
This how-to belongs to the Batch Attribute Mapping Strategies guide — head back there for the full mapping stage, or up to Parsing & Tag Normalization Workflows for the broader pipeline.