Encoding Opening Hours as JSON-LD: How AI Assistants Answer 'Is It Open Right Now?'
When an AI answers 'Is it open right now?', openingHoursSpecification freshness decides the citation. Overnight, 24-hour, split-shift, and holiday encoding edges.
“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 four encodings that trip people up
Most hours are a clean weekday-block-plus-weekend-block, like the example above. The failures cluster in four 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"
}
]
Split shifts and multiple time-bands. Restaurants with distinct breakfast, lunch, and dinner services are the case that never quite makes it into the base example, and it is where the “one block per day” instinct produces a lie. Consider a restaurant open 07:00–10:30 for breakfast, 11:30–14:30 for lunch, and 17:00–22:00 for dinner. Merging that into a single 07:00–22:00 block would tell a model the door is open at 15:00 when it is not, and the “is it open right now?” answer at that timestamp would be confidently wrong. The right encoding is one block per contiguous band on the same day — three blocks, same dayOfWeek, three distinct opens/closes pairs — so the clock evaluation lands in the correct band or none of them:
"openingHoursSpecification": [
{"@type": "OpeningHoursSpecification", "dayOfWeek": "Monday", "opens": "07:00", "closes": "10:30"},
{"@type": "OpeningHoursSpecification", "dayOfWeek": "Monday", "opens": "11:30", "closes": "14:30"},
{"@type": "OpeningHoursSpecification", "dayOfWeek": "Monday", "opens": "17:00", "closes": "22:00"}
]
The same pattern covers midday closures for prep, siesta-style afternoon breaks, and any business where the door is genuinely locked for a stretch of the day. The invariant is that the union of the blocks equals the actual open hours, and every gap between blocks is a real closed period — not a rounding convenience.
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 |
| Split shifts / multi-band (breakfast, lunch, dinner) | one block per contiguous band, same dayOfWeek | merging into a single wide block | Reports “open” during real closed gaps; “is it open now?” answered wrong |
| 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 six 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. And that is even before considering the prose-versus-schema trust order — when the page copy and the JSON-LD disagree about the same hours, the schema tends to win the fact, which only sharpens the pressure on the schema to be current.
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 reconciled everywhere the entity is named. 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 — organized around the three axes of Structure, Confidence, and Provenance — 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 and freshness (why encoded hours still need to be current):
- State Fields: The Part of a Business That Changes — the parent framing; hours are the canonical state field, cited on freshness rather than consistency.
- Reviews & aggregateRating in JSON-LD — the other state-shaped signal that has to survive freshness scrutiny, engineered field by field.
Identity and consistency (the other half of the trust order):
- NAP Consistency & Entity Reconciliation — how the identity fields hours attach to earn belief through the same string appearing everywhere.
- Knowledge Graph Entity Linking — how AI assistants stitch the entity together across surfaces before hours are even evaluated against a clock.
JSON-LD engineering pair (adjacent extractable fields):
- Reading Google Business Profile as JSON-LD — the field-by-field GBP → schema.org projection these hours live inside.
- Service-area Businesses in JSON-LD — the areaServed encoding pair for businesses whose service footprint is a region, not an address.
- FAQPage vs QAPage for Local Business — the JSON-LD engineering choice for adjacent question-answer content on the same domain.
Framework and query dispatch:
- The Three Axes of AI-Native MEO — the Structure / Confidence / Provenance framework this piece sits inside; hours load on all three.
- Structured Data vs Prose: Which Does AI Cite First? — the trust order when the paragraph above the hours and the JSON-LD block disagree about the same fact.
- 5 Superlative Query Signals AI Uses to Rank Local Businesses — the query-side interpretation axis; opening hours arrives as a time-band filter to that ranking pass.
- LLMO Framework — the Structure axis reference and the LLMO/GEO/AEO positioning that places state-field encoding as a first-class problem.
Frequently asked questions
- How do I encode opening hours that cross midnight, like 18:00 Friday to 02:00 Saturday?
- Splitting the span across the day boundary is the safer encoding. Writing `"opens": "18:00"` and `"closes": "02:00"` in a single block is the schema.org convention for a span that crosses midnight, but not every parser honors it cleanly. Two blocks — Friday 18:00–23:59 and Saturday 00:00–02:00 — leave each block unambiguous, so a parser cannot silently drop the overnight half.
- Is `"closes": "24:00"` a valid way to encode 24-hour operation?
- No — `24:00` is not a valid `Time` in this context, and a block using it will be ignored. The correct encoding for a business open around the clock is `opens` and `closes` set to the same value, `"00:00"` to `"00:00"`, for the relevant days. It reads like a no-op, which is exactly why the `24:00` mistake is common.
- Does `specialOpeningHoursSpecification` override the regular `openingHoursSpecification`?
- Yes, for a dated window. `specialOpeningHoursSpecification` uses `validFrom` and `validThrough` to override the recurring rule for a specific date range — a Dec 31 closure, an extended holiday span, or a one-off. The `validThrough` date is the important part: a model reading it can reason that a special block dated to last December has lapsed and no longer applies, and that the absence of a special block means 'no known exception,' not 'definitely open.'
- What if the business has multiple time-bands like breakfast, lunch, and dinner?
- Encode each contiguous band as its own `OpeningHoursSpecification` block on the same day. A restaurant open 07:00–10:30 for breakfast, 11:30–14:30 for lunch, and 17:00–22:00 for dinner needs three blocks with the same `dayOfWeek` value and three distinct `opens`/`closes` pairs. Merging them into a single 07:00–22:00 block would be a false claim during the midday and afternoon closures, and a model evaluating 'is it open right now?' at 15:00 would answer wrong.
- Why isn't a correctly encoded `openingHoursSpecification` enough to get cited?
- Because hours are a state field, and state fields earn trust on freshness rather than consistency. Address earns belief through the same string appearing everywhere; hours earn belief through the model having reason to think the value is current — through `validThrough` lapse-logic on a special block, or corroboration from a surface treated as continuously updated. Three surfaces agreeing on last spring's hours are three surfaces that are stale together. Structure says the fact can be read; freshness says it can be trusted right now, and a state field with one but not the other does not get cited.