Normalizing OSM Yes/No Tag Values Jump to heading
The tag looks boolean and is not. access can be yes, no, private, permissive, destination, customers or designated, and a pipeline that maps everything not equal to yes onto false has just told a routing engine that a permissive path is closed.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Three distinctions have to survive normalization, and collapsing any of them loses real information.
Absent is not false. A way with no lit tag has not been surveyed for lighting; it has not been recorded as unlit. A pipeline that treats the absence as a negative is inventing data, and it will report that a city’s streets are overwhelmingly unlit when in fact they are overwhelmingly unsurveyed.
Some values are boolean in disguise. true, 1 and occasionally T appear as synonyms for yes, usually from imports or from editors that did not enforce the vocabulary. They are safe to fold.
Some values are not boolean at all. private, permissive, destination, customers, designated, limited and unknown each say something a boolean cannot carry. Folding them into true or false is where the real damage happens, because the result is plausible and the nuance is gone.
The right output is a tri-state — yes, no, unknown — plus a preserved original value, so a consumer that understands the richer vocabulary can still reach it.
Runnable solution Jump to heading
from __future__ import annotations
import logging
from enum import Enum
from dataclasses import dataclass
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.tags.boolean")
class Tri(str, Enum):
YES = "yes"
NO = "no"
UNKNOWN = "unknown"
AFFIRMATIVE = {"yes", "true", "1", "t", "y"}
NEGATIVE = {"no", "false", "0", "f", "n"}
# Values that resolve to a state but carry a condition worth keeping.
CONDITIONAL_YES = {"permissive", "destination", "designated", "official",
"customers", "permit", "agricultural", "forestry", "delivery"}
CONDITIONAL_NO = {"private", "no_entry", "restricted", "military"}
# Values that resolve to nothing: they say the surveyor did not know.
EXPLICIT_UNKNOWN = {"unknown", "unspecified", "fixme", ""}
@dataclass(frozen=True)
class Normalized:
state: Tri
original: str | None
conditional: bool
recognised: bool
@property
def is_definite(self) -> bool:
return self.state is not Tri.UNKNOWN and not self.conditional
def normalize(value: str | None) -> Normalized:
"""Map an OSM boolean-ish value to a tri-state, keeping what was lost."""
if value is None:
# Absent is NOT false: nobody recorded anything either way.
return Normalized(Tri.UNKNOWN, None, conditional=False, recognised=True)
raw = value.strip()
folded = raw.casefold()
if folded in EXPLICIT_UNKNOWN:
return Normalized(Tri.UNKNOWN, raw, False, True)
if folded in AFFIRMATIVE:
return Normalized(Tri.YES, raw, False, True)
if folded in NEGATIVE:
return Normalized(Tri.NO, raw, False, True)
if folded in CONDITIONAL_YES:
return Normalized(Tri.YES, raw, conditional=True, recognised=True)
if folded in CONDITIONAL_NO:
return Normalized(Tri.NO, raw, conditional=True, recognised=True)
# An unrecognised value is not a negative. Say so, and keep it.
logger.debug("unrecognised boolean-ish value %r", raw)
return Normalized(Tri.UNKNOWN, raw, conditional=False, recognised=False)
def normalize_key(tags: dict[str, str], key: str) -> Normalized:
return normalize(tags.get(key))
def audit(values: list[str | None]) -> dict[str, int]:
"""Count how a corpus distributes, so the vocabularies can be extended."""
counts = {"yes": 0, "no": 0, "unknown": 0,
"conditional": 0, "unrecognised": 0}
unrecognised: dict[str, int] = {}
for value in values:
result = normalize(value)
counts[result.state.value] += 1
if result.conditional:
counts["conditional"] += 1
if not result.recognised:
counts["unrecognised"] += 1
key = result.original or ""
unrecognised[key] = unrecognised.get(key, 0) + 1
top = sorted(unrecognised.items(), key=lambda kv: -kv[1])[:10]
logger.info("distribution %s", counts)
if top:
logger.info("most common unrecognised: %s", top)
return counts
if __name__ == "__main__":
sample = ["yes", "no", "private", "permissive", None, "1", "maybe", ""]
audit(sample)
Step-by-step walkthrough Jump to heading
- Separate absent from empty. A missing key and a key whose value is an empty string both mean unknown, but only the first is a legitimate state — the second is a data-quality signal worth keeping visible.
- Fold case, not meaning. Case folding handles the editor variants safely; folding a conditional value into a plain state does not.
- Keep conditional and definite apart. A path that is
permissiveis usable and a path that isyesis usable, and a routing engine that plans a route through private land on the strength of a permissive tag has made a real mistake. - Treat an unrecognised value as unknown. It is neither affirmative nor negative; defaulting it either way is guessing, and defaulting it to false is the guess that silently closes things.
- Preserve the original. The tri-state is for consumers that want simplicity; the original is for the ones that want the nuance, and keeping both costs one column.
- Audit the corpus. Counting the most common unrecognised values is how the vocabularies get extended from evidence rather than from memory of what the documentation says.
- Record whether the value was recognised. That flag is what lets a quality report distinguish “the surveyor did not know” from “we did not understand the answer”.
Verification Jump to heading
- Absent and negative differ in the output. Count features whose tri-state is unknown versus no; a corpus where unknown is zero means absence is being folded.
- Conditional values are flagged. Find a permissive feature and confirm it carries both the affirmative state and the conditional flag.
- Unrecognised values surface. The audit’s unrecognised list should be short and, where non-empty, should contain real values worth adding.
- Case variants fold.
Yes,YESandyesmust produce identical output. - Originals survive. Every normalised record should still carry the value it came from.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Most features reported negative | Absent folded into no | Return unknown for a missing key |
| Routing uses private land | Conditional folded into definite | Keep a conditional flag alongside the state |
| Unusual regions behave worst | Unrecognised defaulted to no | Return unknown for anything not in the vocabulary |
| Vocabulary never grows | Unrecognised values discarded | Audit and report the most common ones |
| Case variants treated as distinct | No case folding | Casefold before matching |
| Nuance irrecoverable downstream | Original value dropped | Keep the raw value alongside the tri-state |
| Empty string treated as a state | Empty and absent conflated | Map empty to unknown but flag it separately |
Specification reference Jump to heading
Keys such as
access,oneway,litandbridgeacceptyesandnoalongside a wider vocabulary, and several keys define conditional values such aspermissive,destinationandprivatewith distinct meanings. An absent key indicates that the property has not been recorded rather than that it is false. See the access tag documentation for the conditional vocabulary and Tag Taxonomy & Key-Value Standards for the wider key conventions.
Frequently Asked Questions Jump to heading
Why should an absent tag be unknown rather than no?
Because absence records that nobody surveyed the property, not that the property is false. Treating the two the same converts a coverage gap into a factual claim, and the resulting statistics are confidently wrong: a city whose streets are largely unsurveyed for lighting will be reported as largely unlit. The distinction also tells you where survey effort would be worth spending, which the collapsed version cannot.
Is permissive the same as yes?
For the question “can I pass”, nearly; for anything a consumer will act on, no. Permissive means the owner currently tolerates access and may withdraw it, which is a materially different statement from a public right of way. A routing engine planning a walking route can reasonably use permissive paths; one planning a delivery vehicle’s route probably should not, and it can only make that distinction if the value survived normalization.
What should happen to a value nobody recognises?
It becomes unknown, and it gets counted. Defaulting it to false is the worst available choice because the failure scales with how unusual the data is — regions with local tagging conventions, or features with genuinely unusual access arrangements, are exactly where the default does most damage. Counting the unrecognised values is how the vocabulary grows from evidence.
Is a tri-state enough, or should the full vocabulary survive?
Both, which is why the original is kept alongside. The tri-state serves the many consumers that want a simple answer and would otherwise invent one; the preserved original serves the few that need the nuance. Storing only the tri-state discards information irreversibly, and storing only the original pushes the same normalization decision onto every consumer separately.
Related Jump to heading
- Tag Taxonomy & Key-Value Standards — the parent topic and the key conventions.
- Splitting Semicolon-Separated OSM Tag Values — the other value-shape problem in the same namespace.
- Validating Oneway and Access Tags for Routing — where these values are consumed and where a collapse causes real harm.
- Value Standardization & Regex Cleaning — the wider normalization stage this belongs to.
- Handling Missing Tags in OSM Data Pipelines — the absence question across all keys.
Up one level: Tag Taxonomy & Key-Value Standards.