Parsing OSM Opening Hours Values Jump to heading
Turn opening_hours strings into something a query can use, without pretending a small grammar is a regular expression.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
opening_hours is a specified grammar, not a convention. Values are ordered lists of rules, each combining an optional selector with an optional time span and an optional modifier, and later rules override earlier ones for the periods they cover.
The override semantics are what defeat regular-expression approaches. Mo-Fr 08:00-18:00; Su off is not two independent facts; the second rule modifies the state established by the first, and a parser that collects intervals into a set has already lost the information needed to get Sunday right.
Runnable solution Jump to heading
The pragmatic answer is to use a real parser for the grammar and reserve your own code for classification and reporting:
#!/usr/bin/env python3
"""Parse OSM opening_hours values, classifying what cannot be parsed."""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
class Quality(str, Enum):
CLEAN = "clean"
REPAIRED = "repaired" # normalised, then parsed
UNPARSEABLE = "unparseable"
@dataclass(frozen=True)
class ParsedHours:
raw: str
normalised: str | None
quality: Quality
note: str | None = None
#: Sloppiness that is safe to fix because it cannot change the meaning.
_REPAIRS: tuple[tuple[re.Pattern[str], str], ...] = (
(re.compile(r"\s*;\s*"), "; "), # spacing around rule separators
(re.compile(r"\s*,\s*"), ","), # spacing around time chaining
(re.compile(r"(\d)h(\d\d)"), r"\1:\2"), # 9h30 → 9:30
(re.compile(r"\b(\d):(\d\d)\b"), r"0\1:\2"), # 9:30 → 09:30
(re.compile(r"\b(\d{2})\.(\d{2})\b"), r"\1:\2"), # 09.30 → 09:30
(re.compile(r"\bmo\b", re.I), "Mo"), # weekday casing
(re.compile(r"\btu\b", re.I), "Tu"),
(re.compile(r"\bwe\b", re.I), "We"),
(re.compile(r"\bth\b", re.I), "Th"),
(re.compile(r"\bfr\b", re.I), "Fr"),
(re.compile(r"\bsa\b", re.I), "Sa"),
(re.compile(r"\bsu\b", re.I), "Su"),
(re.compile(r"\s+"), " "),
)
#: Values that mean "no information", not "closed".
_EMPTY = frozenset({"", "-", "?", "unknown", "n/a", "na", "tbd"})
def normalise(value: str) -> str:
out = value.strip()
for pattern, replacement in _REPAIRS:
out = pattern.sub(replacement, out)
return out.strip().rstrip(";").strip()
def parse(value: str) -> ParsedHours:
"""Parse with a grammar-aware library; classify anything that will not go.
The library does the grammar. This function's job is to decide what a failure
means and to make sure a failure is recorded rather than silently dropped.
"""
if value is None or value.strip().lower() in _EMPTY:
return ParsedHours(value or "", None, Quality.UNPARSEABLE, "no information")
try:
from opening_hours import OpeningHours # the Rust-backed binding
except ImportError as exc: # pragma: no cover
raise RuntimeError(
"install a grammar-aware parser (pip install opening-hours-py); "
"regular expressions cannot express rule-override semantics"
) from exc
for candidate, quality in ((value, Quality.CLEAN), (normalise(value), Quality.REPAIRED)):
try:
OpeningHours(candidate)
except Exception: # the binding raises on bad grammar
continue
return ParsedHours(value, candidate, quality)
return ParsedHours(value, None, Quality.UNPARSEABLE, "grammar not recognised")
def is_open_at(parsed: ParsedHours, when: datetime) -> bool | None:
"""Tri-state on purpose: None means unknown, which is not the same as closed."""
if parsed.normalised is None:
return None
from opening_hours import OpeningHours
return OpeningHours(parsed.normalised).is_open(when)
def summarise(values: list[str]) -> dict[Quality, int]:
counts: dict[Quality, int] = {q: 0 for q in Quality}
for value in values:
counts[parse(value).quality] += 1
total = sum(counts.values()) or 1
for quality, n in counts.items():
logger.info("%-12s %7d %5.1f%%", quality.value, n, 100 * n / total)
return counts
Step-by-step walkthrough Jump to heading
parse tries the raw value first and the normalised value second, and records which one worked. That distinction is worth keeping: a corpus where ten percent of values need repair is telling you something about your region’s tagging conventions that a single pass/fail flag does not.
_REPAIRS contains only transformations that cannot change meaning. Fixing spacing, zero-padding hours and correcting weekday capitalisation are all safe. Deliberately absent is anything that guesses — no expanding Mon to Mo, no interpreting 9-5, no translating Lunes. Those are judgement calls, and a repair table that makes judgement calls will eventually assert that a shop is open when it is not.
_EMPTY separates “no information” from “closed”, and the distinction matters downstream: a venue with no opening_hours tag is not a venue that never opens. This is the same three-way absence distinction as in Handling Missing Tags in OSM Data Pipelines.
is_open_at returns bool | None rather than defaulting to False. Returning False for an unparseable value produces a dataset where “closed” silently means two different things, and no consumer can separate them again.
The hard refusal to fall back to regular expressions is deliberate. A regex can extract time spans, and every regex-based opening_hours parser eventually ships a bug where Su off is ignored, because expressing “a later rule overrides an earlier one” is not something a regular language can do.
Verification Jump to heading
Test the four traps explicitly, because they are the ones that pass casual inspection:
from datetime import datetime
SUNDAY = datetime(2026, 8, 9, 12, 0) # a Sunday
LUNCHTIME = datetime(2026, 8, 12, 15, 0) # a Wednesday, between services
MIDNIGHT = datetime(2026, 8, 12, 23, 59)
def test_later_rule_overrides():
p = parse("Mo-Fr 08:00-18:00; Su off")
assert is_open_at(p, SUNDAY) is False
def test_24_hours():
p = parse("Mo-Su 00:00-24:00")
assert is_open_at(p, MIDNIGHT) is True
def test_split_shift():
p = parse("Mo-Fr 11:30-14:00,17:30-23:00")
assert is_open_at(p, LUNCHTIME) is False
def test_unknown_is_not_closed():
p = parse("by appointment")
assert p.quality is Quality.UNPARSEABLE
assert is_open_at(p, SUNDAY) is None
Then run summarise over a real extract’s values and compare the distribution against the chart above. A clean rate far below 85 percent usually means the values were mangled upstream — check that a whitespace or case normalisation step has not already run over them, as described in Automating Tag Case Normalization with pandas.
Common errors and fixes Jump to heading
| Symptom | Root cause | Fix |
|---|---|---|
Open on Sunday when the value says Su off |
Rules treated as an unordered set | Use a grammar parser; order is semantic |
00:00-24:00 reported as closed at 23:59 |
24:00 read as an invalid hour |
It is the end of the day, not hour 24 |
| Lunch breaks ignored | , treated as a rule separator |
; separates rules, , chains times |
| Everything unparseable | Case-folded before parsing | Weekday tokens are case-sensitive in the grammar |
| Unknown reported as closed | Boolean return with no third state | Return None for unknown |
| Public holidays treated as a weekday | PH matched by a weekday regex |
PH and SH are separate selectors |
Frequently Asked Questions Jump to heading
Can I get away with a regex for the common case?
For extraction, yes — pulling the time spans out of a well-formed value is a regex-sized job. For evaluation, no. The moment you need to answer “is this open now”, rule-override semantics are unavoidable, and that is not expressible as a regular language. Use a regex to survey a corpus; use a parser to answer questions.
What should unparseable values become in the output?
Null, with a reason code, and a count you watch over time. Not False, which conflates “closed” with “we do not know”, and not the raw string in a boolean column. The reason code lets you distinguish free text from a grammar error from an empty tag, which are three different data-quality problems with three different fixes.
How do I handle values in other languages?
Record them and leave them. Lunes a Viernes 9-17 is a real value with a clear meaning to a human and no standing in the grammar, and translating it in a pipeline means encoding a mapping per language that will be wrong at the edges. The better outcome is to surface these for correction upstream, which is what the validation approach in Authoring OSM Validation Rules is for.
Does the same grammar apply to other conditional tags?
Partly. The time-selector syntax is shared with conditional restrictions such as maxspeed:conditional=60 @ (22:00-06:00), so a parser for one gets much of the other. The rule-override and modifier semantics are specific to opening_hours; conditional values use an explicit value @ condition form instead.
Storing the result Jump to heading
A parsed opening_hours value can be stored three ways, and the choice constrains what queries are possible later.
Keeping the normalised string alone is the smallest option and defers all evaluation to read time. It is right when the value is displayed rather than queried, and it keeps the door open for a better parser later, because nothing has been baked in.
Materialising intervals — one row per open period per week — makes “what is open now” an indexed range query and is the right shape when that question is asked frequently. The cost is that public holidays, seasonal rules and fallback rules do not fit the model cleanly, so the materialised form is an approximation of a value the string represents exactly.
Storing both is usually correct: the string as the source of truth, the intervals as a derived index, rebuilt whenever the parser or the data changes. That mirrors the pattern used for spatial keys elsewhere on this site — keep the exact representation, derive the queryable one, and never let the derived form become the only copy.
Specification reference Jump to heading
An
opening_hoursvalue is a semicolon-separated sequence of rules evaluated in order, with later rules overriding earlier ones. A rule combines an optional wide-range selector, an optional small-range selector (weekdays, times) and an optional modifier fromopen,closed,offandunknown. Times use 24-hourHH:MM, where24:00denotes the end of the day; a comma chains multiple time spans within one rule.
Related Jump to heading
- Tag Taxonomy & Key-Value Standards — the topic this value belongs to.
- Value Standardization & Regex Cleaning — where the safe repairs belong.
- Handling Missing Tags in OSM Data Pipelines — the unknown-versus-absent distinction.
- Best Practices for OSM Tag Standardization Across Regions — why the non-English tail exists.
- Normalizing OSM Speed Limits and Units — the same discipline on a simpler value.
Up one level: Tag Taxonomy & Key-Value Standards.