Splitting Semicolon-Separated OSM Tag Values Jump to heading
The semicolon is OSM’s conventional multi-value separator, and it is also an ordinary character that appears inside real values. A global split turns one opening-hours string into two meaningless fragments; not splitting at all leaves a cuisine tag nobody can filter on.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Three facts decide the design.
Splitting is per key, never global. cuisine=pizza;italian is two values. opening_hours=Mo-Fr 09:00-17:00; Sa 10:00-14:00 is one value whose syntax uses semicolons internally. Applying one rule to both is guaranteed to break one of them.
Some keys are multi-valued and some are not. cuisine, sport, ref, alt_name and several others conventionally hold lists. name, opening_hours, description and addr:street do not, and a semicolon inside one of them is either literal or a data error — but not a separator.
Order sometimes matters. For cuisine the order is arbitrary and deduplication is safe. For ref on a route it may reflect signage order. Sorting a list to make comparison easier can destroy information, so it should be a per-key decision too.
Runnable solution Jump to heading
from __future__ import annotations
import logging
from dataclasses import dataclass
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.tags.multivalue")
# Keys whose values are conventionally lists. Anything absent is NOT split.
MULTI_VALUED: dict[str, bool] = {
# key -> whether the order carries meaning
"cuisine": False, "sport": False, "religion": False, "diet": False,
"payment": False, "fuel": False, "recycling": False, "service": False,
"alt_name": True, "ref": True, "operator": True, "brand": True,
"route_ref": True, "network": True, "surface": False,
}
# Keys whose syntax uses semicolons internally. Splitting these is destructive.
NEVER_SPLIT = {
"opening_hours", "service_times", "collection_times", "name", "description",
"note", "fixme", "inscription", "addr:street", "addr:full", "conditional",
}
MAX_PARTS = 24 # a value with more parts than this is probably an error
@dataclass(frozen=True)
class MultiValue:
key: str
values: tuple[str, ...]
original: str
split: bool
suspicious: bool
def split_value(key: str, value: str) -> MultiValue:
"""Split only where the key's convention says a list is meant."""
base = key.split(":", 1)[0] # addr:street -> addr, name:en -> name
if key in NEVER_SPLIT or base in NEVER_SPLIT:
return MultiValue(key, (value,), value, split=False, suspicious=False)
if key not in MULTI_VALUED and base not in MULTI_VALUED:
# Unknown key: do NOT split. An unsplit list is recoverable; a split
# structured value is not.
suspicious = ";" in value
return MultiValue(key, (value,), value, split=False,
suspicious=suspicious)
parts = [p.strip() for p in value.split(";")]
parts = [p for p in parts if p]
ordered = MULTI_VALUED.get(key, MULTI_VALUED.get(base, True))
if not ordered:
# Order is arbitrary for this key: deduplicate and sort for comparison.
parts = sorted(set(parts))
else:
seen: set[str] = set()
parts = [p for p in parts if not (p in seen or seen.add(p))]
suspicious = len(parts) > MAX_PARTS
if suspicious:
logger.warning("%s has %d part(s); likely a data error", key, len(parts))
return MultiValue(key, tuple(parts), value, split=True, suspicious=suspicious)
def split_tags(tags: dict[str, str]) -> dict[str, MultiValue]:
return {k: split_value(k, v) for k, v in tags.items()}
def audit(tag_stream) -> dict[str, dict[str, int]]:
"""Which keys actually contain semicolons, and are we splitting them?"""
stats: dict[str, dict[str, int]] = {}
for tags in tag_stream:
for key, value in tags.items():
if ";" not in value:
continue
entry = stats.setdefault(key, {"seen": 0, "split": 0, "kept": 0})
entry["seen"] += 1
result = split_value(key, value)
entry["split" if result.split else "kept"] += 1
unsplit = {k: v for k, v in stats.items() if v["kept"] and v["seen"] > 20}
if unsplit:
logger.info("keys with semicolons that are NOT split (review these): %s",
sorted(unsplit, key=lambda k: -unsplit[k]["seen"])[:10])
return stats
if __name__ == "__main__":
tags = {
"cuisine": "pizza;italian;pizza",
"opening_hours": "Mo-Fr 09:00-17:00; Sa 10:00-14:00",
"name": "Smith; Jones and Co",
"ref": "A1;M25",
}
for key, result in split_tags(tags).items():
logger.info("%-14s split=%-5s -> %s", key, result.split, result.values)
Step-by-step walkthrough Jump to heading
- Default to not splitting. An unsplit list can be split later; a structured value split into fragments cannot be reassembled reliably. When the key is unknown, leaving it alone is the recoverable choice.
- Check the namespace base as well as the full key.
name:eninheritsname’s rule, and enumerating every language variant is not practical. - List the never-split keys explicitly. Keys whose grammar uses semicolons are few and well known, and naming them is what makes the default safe rather than merely cautious.
- Trim and drop empties. A trailing separator produces an empty part, which downstream becomes a mysterious blank category.
- Decide ordering per key. Sorting makes comparison easy and destroys signage order; doing it only where order is genuinely arbitrary keeps both properties where they belong.
- Deduplicate in both branches. A repeated value is noise in either case; the difference is only whether the surviving order is the original one.
- Flag implausible part counts. A tag with dozens of parts is almost always a data error rather than a genuine list, and the warning surfaces it rather than propagating it.
- Audit what is not being split. A key containing semicolons often and never split is either correctly excluded or a gap in the vocabulary, and the audit is what tells the two apart.
Verification Jump to heading
- Structured values survive intact. An opening-hours string with internal semicolons must come through as a single value.
- List keys are split. A cuisine value with two parts must produce two values.
- Unknown keys are not split. Introduce an unfamiliar key with a semicolon and confirm it passes through whole and flagged.
- Empty parts do not appear. A trailing separator must not produce a blank value.
- Order is preserved where it matters. A route reference list must keep its original sequence.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Opening hours in fragments | Global split applied | Split per key, with an explicit never-split list |
| Cuisine values unfilterable | Nothing split | Add the key to the multi-valued vocabulary |
| Blank categories downstream | Empty parts kept | Trim and drop empty parts after splitting |
| Route references reordered | Sorting applied to an ordered key | Sort only where order is genuinely arbitrary |
| Language variants not split | Only the full key checked | Check the namespace base as well |
| Dozens of parts on one tag | A data error propagated | Warn above a plausible maximum part count |
| Vocabulary never improves | Unsplit semicolon values unaudited | Report keys that frequently contain separators |
Specification reference Jump to heading
The semicolon is the conventional separator for multiple values in a single OSM tag, though its use is not universal and several keys define a syntax in which semicolons appear as part of the value rather than as a separator. Consumers are expected to apply the convention per key. See the semi-colon value separator documentation for the convention and its exceptions.
Frequently Asked Questions Jump to heading
Why not split every value containing a semicolon?
Because several keys use the semicolon inside their own grammar. An opening-hours value separates its rules with semicolons, and splitting it produces fragments that are individually meaningless and cannot be reassembled reliably. The same applies to conditional restrictions and to free-text keys where a semicolon is ordinary punctuation. Splitting has to be a per-key decision, driven by an explicit vocabulary.
What should an unknown key default to?
Not splitting. The asymmetry is the whole argument: a list left unsplit can be split later once the key is added to the vocabulary, while a structured value already split into fragments has lost information that is difficult to recover. Flagging unknown keys that contain semicolons, so they can be reviewed and classified, gives the benefit of the split without the risk.
Should the split values be sorted?
Only where the order is genuinely arbitrary. Sorting a cuisine list makes two features with the same cuisines compare equal regardless of how they were entered, which is useful. Sorting a route reference list destroys the signage order, which a consumer may depend on. The ordering decision belongs in the same vocabulary as the split decision, one flag per key.
How do I discover which keys need splitting?
Audit the corpus. Count, per key, how often its values contain a semicolon and whether the splitter currently splits it. A key that frequently contains separators and is never split is either a correct exclusion or a gap, and reviewing the top few by frequency covers almost all the value. Regional tagging conventions differ, so the audit is worth repeating on each new area.
Related Jump to heading
- Tag Taxonomy & Key-Value Standards — the parent topic and the key conventions.
- Normalizing OSM Yes/No Tag Values — the other value-shape problem in this namespace.
- Parsing OSM Opening Hours Values — the key this splitter must never touch.
- Value Standardization & Regex Cleaning — the normalization stage this belongs to.
- Fuzzy Name Matching for OSM POI Conflation — a consumer that depends on alternative names being split.
Up one level: Tag Taxonomy & Key-Value Standards.