Home / Blog / Parsing 'Mo-Sa 10:00-18:00; PH off' is a small compiler problem
May 12, 2026· Wine World Map
Parsing 'Mo-Sa 10:00-18:00; PH off' is a small compiler problem
The first time I looked at a winery's opening_hours tag in OSM I
assumed it was free text. It looked like free text. Mo-Fr 09:00-17:00
is the kind of string you'd find scrawled on a chalkboard outside a
boulangerie. Then I saw Apr-Oct: Mo-Sa 10:00-18:00; Su 11:00-17:00; PH off; Dec 24-26 off
and realised this was something else.
The OSM opening_hours tag is a formal grammar with a published EBNF,
a reference parser in JavaScript,
and a strict-mode validator. Wineries — about 9,400 of which have the
tag set in our import — use most of the corners of that grammar,
including ones I had to look up.
The grammar in five examples
Mo-Fr 09:00-17:00
Mo-Sa 10:00-18:00; Su 11:00-17:00
Apr-Oct: Mo-Sa 10:00-18:00; Nov-Mar: Sa-Su 11:00-16:00
"by appointment"
Mo-Fr 10:00-12:00, 14:00-18:00; Sa 10:00-16:00; PH off
That last one is a real cellar door in Saint-Émilion. Note the comma
inside a rule (two ranges on the same day), the semicolon between
rules, PH for public holidays, and the off keyword that overrides
an earlier rule. You also get month ranges, week numbers, sunset/sunrise
keywords (Mo-Sa sunrise-sunset, which a few biodynamic producers
genuinely use), and quoted comments as a fallback.
We don't run the full reference parser at import time. We run a restricted subset that handles maybe 80% of real strings and falls back to storing the raw value when it can't.
type Rule = {
months?: [number, number] // 4..10 for Apr-Oct
days: number[] // 0=Mo .. 6=Su
intervals: [string, string][] // [["10:00","18:00"]]
closed?: boolean
}
function parseOpeningHours(raw: string): Rule[] | null {
if (/^["']/.test(raw.trim())) return null // "by appointment"
const rules = raw.split(';').map(s => s.trim()).filter(Boolean)
return rules.map(parseRule).filter(Boolean) as Rule[]
}
The parser returns null for anything quoted, anything containing
sunrise/sunset, anything with week-number selectors, and anything where
the day token doesn't match Mo|Tu|We|Th|Fr|Sa|Su|PH. About 31% of
strings come back null. We store those raw and render them as-is —
"by appointment", "summer only", "see website".
What the data actually looks like
| Pattern | Wineries | % |
|---|---:|---:|
| Parsed cleanly | 6,480 | 69 |
| Quoted free text ("by appointment") | 1,210 | 13 |
| Has PH off or PH rule | 2,800 | 30 |
| Has seasonal range (Apr-Oct etc.) | 1,940 | 21 |
| Has sunrise/sunset | 42 | 0.4 |
| Failed strict-mode validation | 850 | 9 |
The 9% that fail strict-mode are usually missing a colon, using
Mon-Fri instead of Mo-Fr, or writing 10-18 without :00. The
reference parser will accept lots of these in non-strict mode. We do
too, after a normalisation pass that lowercases, fixes the common
abbreviation mistakes, and inserts missing colons.
The wine-specific quirk: "by appointment"
The single most common non-parseable string across our wineries is some variation of visite sur rendez-vous, nur nach Vereinbarung, solo su appuntamento, by appointment only. This is genuinely the business model: a small Burgundy domaine doesn't have a cellar door, they have a vigneron who'll walk you through the barrels if you email two weeks ahead.
We treat these as a separate state from "open" and "closed". The
InfoPanel renders a third badge — "By appointment" — and the booking
URL (if present) gets promoted. The cellar_door_visits boolean stays
true because the experience exists, it just doesn't have hours.
What "open now" means when the grammar gets loose
Computing "is this winery open right now" requires a timezone, the visitor's current time, and a fully-evaluated rule set. We do this client-side in the InfoPanel using a 2KB version of the rule evaluator, because doing it server-side would mean serializing the opening hours into every winery card and getting the timezone question wrong for travellers.
The fun edge case: a rule like Apr-Oct: Mo-Sa 10:00-18:00 says
nothing about November. Is the winery closed in November, or is the
information just incomplete? OSM convention says closed — anything
not stated is off — but in practice many mappers only write the
"interesting" months and assume the rest is implicit "open as usual".
We side with the convention and label out-of-range months as closed,
because the alternative is telling someone the cellar door is open
when it physically isn't.
Lesson
When a tag looks like prose, check whether it's actually a DSL.
opening_hours is the most-formalised free-form-looking string in
OSM, and treating it as text means you parse none of it; treating it
as code means you parse two-thirds of it cleanly and learn something
about the long tail of producers who don't really keep hours. The
grammar is the easy part. The hard part is deciding what "open" means
for a domaine that opens when the vigneron's car is in the driveway.
#openstreetmap#parsing#data-quality