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