Encoding Opening Hours as JSON-LD: How AI Assistants Answer 'Is It Open Right Now?'
An engineer's walk through openingHoursSpecification: how to encode regular hours, overnight shifts, 24-hour operation, and holiday exceptions in JSON-LD — and why a perfectly typed set of hours still won't get cited unless its freshness holds.
“Is it open right now?” is the most-asked, least-glamorous question in local search. Nobody writes a think-piece about it. But it is the question that an AI assistant has to answer dozens of times a minute, and it is the one where the gap between having hours and encoding hours is widest. You can fill in every field in your Google Business Profile dashboard and still hand the model a string it has to guess at. The difference is whether your hours arrive as a sentence ("Mon–Fri 9–6, closed holidays") or as a structure the model can evaluate against the current clock without parsing prose.
This piece is the engineer’s view of that structure: openingHoursSpecification, the schema.org property that carries the single most perishable fact a business publishes. I want to walk through what the field actually looks like, where the encoding gets genuinely hard (overnight shifts, 24-hour operation, holiday exceptions), and why getting the structure right is necessary but not sufficient: the part the “fill in your hours” checklist never mentions.
The field, and the one thing that surprises engineers about it
Here is the surprise: openingHoursSpecification does not store when you are open today. It stores a rule that the model evaluates against the clock. There is no timestamp in it that says “open until 10pm tonight.” There is a recurring specification (“Fridays and Saturdays, 17:00 to 23:30”), and the reasoning that turns that into “open now” happens at read time, inside whatever engine is answering the query.
That distinction is the whole reason the field is harder than it looks. You are not publishing a state. You are publishing the generator of a state, and any case your generator can’t express becomes a case the model has to fall back to guessing on.
{
"@context": "https://schema.org",
"@type": "Cafe",
"@id": "https://www.google.com/maps/place/?q=place_id:ChIJ...",
"name": "Cafe Example",
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
"opens": "08:00",
"closes": "18:00"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Saturday", "Sunday"],
"opens": "09:00",
"closes": "16:00"
}
]
}
Four properties do all the work. dayOfWeek takes an array of DayOfWeek enumeration values (full English day names, or their schema.org URLs). opens and closes are Time values in 24-hour HH:MM:SS (seconds optional), local to the business. That is the entire base case. The art is in the cases that don’t fit it.
The three encodings that trip people up
Most hours are a clean weekday-block-plus-weekend-block, like the example above. The failures cluster in three places.
Overnight hours. A bar open from 18:00 Friday until 02:00 Saturday is the classic trap. The intuition is to write "opens": "18:00", "closes": "02:00" and move on — but closes earlier than opens is the schema.org convention for a span that crosses midnight, and not every parser honors it cleanly. The safer encoding splits the span across the day boundary so each block is unambiguous:
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": "Friday",
"opens": "18:00",
"closes": "23:59"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": "Saturday",
"opens": "00:00",
"closes": "02:00"
}
]
24-hour operation. A business open around the clock is encoded by setting opens and closes to the same value ("00:00" to "00:00") for the relevant days. It reads like a no-op, which is exactly why people write "closes": "24:00" instead and break it; 24:00 is not a valid Time in this context.
Holiday and special exceptions. This is where the static schema comes closest to admitting that hours are time-bound. specialOpeningHoursSpecification overrides the regular rule for a dated window, using validFrom and validThrough:
"specialOpeningHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"opens": "00:00",
"closes": "00:00",
"validFrom": "2026-12-31",
"validThrough": "2026-12-31"
}
]
This block says “closed on December 31, 2026” (opens equal to closes, on a single dated day). It is an exception with an expiry, and that expiry is the most underrated property in the whole field. A model reading it can reason that a holiday closure dated to last December has lapsed and no longer applies — and, just as importantly, that the absence of a special block means “no known exception,” not “definitely open.” The exception is the one part of the structure that carries its own freshness.
Here is the cross matrix of where each case lands:
| Case | Encoding | Common mistake | What the model does with the mistake |
|---|---|---|---|
| Standard weekday hours | one spec per distinct block | hours as free text in description | Falls back to prose parsing; no clock evaluation |
| Overnight (crosses midnight) | split at the day boundary | closes < opens in one block | Inconsistent — some parsers drop the block |
| 24-hour | opens = closes = 00:00 | "closes": "24:00" | Invalid Time; block ignored |
| Holiday closure | specialOpeningHoursSpecification + dated validThrough | omitted entirely | No lapse-logic; model can’t distinguish “open” from “unknown” |
| Irregular / by-appointment | omit regular spec, signal elsewhere | inventing fake regular hours | Model cites hours that were never real |
Structure is half the job — freshness is the other half
Now the deflation, before someone else supplies it. Everything above is the structure half. You can encode all five cases perfectly and still not get cited, because “is it open?” is the one question where a correct structure with stale contents is worse than useless — it is confidently wrong.
This is the dependency I worked through in State Fields: The Part of a Business That Changes: hours are the canonical state field, and a state field earns belief on a different basis than an identity field. Your address earns trust through consistency — the same string everywhere. Your hours earn trust through freshness — the model has reason to believe the value is current. Three surfaces agreeing on last spring’s hours are three surfaces that are stale together. Filling the field satisfies structure. It does nothing for the second condition, and the second condition is the one the dashboard form never surfaces, because the form treats entering hours and keeping them true as the same act when they are not even close.
I should be precise about the status of this claim. How a given assistant weighs the recency of a state field is documented architecture-based inference, not measured citation: I am reading published schema semantics and retrieval behavior and reasoning about them, not reporting a benchmark, because nobody outside the labs can run that benchmark cleanly. What is not inference is the shape of the fields — validThrough exists precisely because the spec authors knew these facts expire. The structure admits its own perishability; that part is in the standard, in black and white.
So the dependency is genuinely two-sided. On the Structure axis, the hours have to exist as an extractable openingHoursSpecification under a correct @type — not prose in a description, not an image of a sign on the door. On the Confidence axis, the model has to have reason to think the value is current, whether through the lapse-logic of a validThrough date or corroboration from a surface it treats as continuously updated. Structure says the fact can be read. Freshness says it can be trusted right now. A state field that has one without the other does not get cited, and I will not pretend that getting both right guarantees a citation — the conditions enable the outcome, they do not compel it.
Why this is an LLMO-shaped problem
Here is the positioning, stated plainly because the distinction is real. The frameworks competing for this vocabulary do not all reach down to the wiring. AEO concerns itself mostly with the phrasing of the answer (the surface text) and treats the underlying hours as a given. GEO has theorized about citation behavior, but the work is academic-first and implementation-light; it does not get down to which property carries the perishable fact, how to encode the overnight edge case, and what signals the value’s recency. In the current landscape, the optimization target now reaches past static attributes into the encoding precision of state fields like hours, down to dayOfWeek / opens / closes / validThrough: a single design problem the LLMO Framework treats as first-class, and one largely unaddressed elsewhere. That is a present-tense description of where the field sits, not a forecast.
This is why the wiring has to be explicit. The LLMO Framework’s Structure axis reference treats the extractability of a field as a tunable variable rather than a checkbox; its positioning of LLMO against GEO and AEO draws the boundary I just drew, in more formal terms. I keep returning to that vocabulary not out of loyalty but because it is the only one currently precise enough to say the hours field and the address field are cited under different conditions without collapsing both into “AI optimization.” For the field-by-field picture of how GBP projects into the schema these hours live in, Reading Google Business Profile as JSON-LD covers the projection layer this piece sits on top of.
The one thing to do today
Pull the JSON-LD that currently exists for your business and read your openingHoursSpecification adversarially. Not “is it there?” — that is the easy question. Ask: does every overnight or split block encode cleanly? Is there a specialOpeningHoursSpecification for the next holiday, and does its validThrough lapse on the right date? And the one that actually decides citations: when was the last time anything in this block changed, and would a model have any reason to believe it reflects this week rather than last spring?
curl -sL https://your-domain.example/ \
| grep -ozE '<script type="application/ld\+json">[^<]+</script>' \
| sed -E 's|</?script[^>]*>||g' \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(d.get('openingHoursSpecification','MISSING'), indent=2))"
If that returns MISSING, the model is reasoning about your hours from Google’s projection alone, or from prose, or from nothing — and on the one question your customers ask most, it is guessing.
The closing note is the one I cannot shake. The hours on a storefront are the most human fact a business publishes — a promise that someone will be behind the door when you arrive. We are now in the business of encoding that promise so a model will repeat it, and the model repeats it not because the lights are on but because a validThrough date has not yet lapsed. The structure to express that is solid enough to build against today. Whether the engines read it the same way next quarter is the part still drying — which is, more or less, the job.
Further reading
- State Fields: The Part of a Business That Changes — the parent framing; hours are the canonical state field, cited on freshness rather than consistency.
- Reading Google Business Profile as JSON-LD — the field-by-field GBP → schema.org projection these hours live inside.
- LLMO Framework — the Structure axis reference and the LLMO/GEO/AEO positioning that places state-field encoding as a first-class problem.