Event Schema Framework
Use conditional-logic schema generation to keep Event markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralEvent documentationUse on the leaf page of a single event that is bookable by the general public and takes place at a physical location. Virtual-only events are not supported.
Key Implementation Documentation Highlights
- Required properties: name, startDate, location. location must be a Place with a PostalAddress in location.address; virtual-only events (a VirtualLocation with no physical venue) are not supported.
- Recommended properties: description, endDate, eventStatus, image, offers, organizer, performer, previousStartDate, plus location.name and every address part (streetAddress, addressLocality, addressRegion, postalCode, and addressCountry as a 2-letter ISO 3166-1 code). Give a full street address, not just a city.
- startDate and endDate are ISO 8601 with a UTC/GMT offset (without one, Google uses the venue's timezone). Use a date only for all-day events, never a placeholder midnight start, and keep endDate on or after startDate. Multi-day events need both dates.
- eventStatus is EventScheduled (the default), EventCancelled, EventPostponed or EventRescheduled. Keep startDate when the status changes; previousStartDate is only valid with EventRescheduled.
- offers: one Offer per ticket type. price is the lowest available price including fees (0 for free events), priceCurrency is a 3-letter ISO 4217 code, availability is InStock, SoldOut or PreOrder, and url must be a crawlable ticket page linked from the event page.
- image: crawlable, indexable images of the event, at least 720px wide (1920px recommended), 16x9, 4x3 or 1x1, and at least 50,000 pixels.
- name is the event title only: not the venue name, and no URLs or prices. Several performances with separate tickets need one Event each.
- Availability: Australia, Brazil, Canada, Germany, India, Latin America, Spain, the UK and the US, in English, Portuguese, German and Spanish. Don't mark up products, coupons, business hours or other non-events.
- Google expects content parity: every marked-up value must match what users can see on the page.
- Validate with the Rich Results Test and URL Inspection, then monitor the related Search Console report for errors and warnings after deployment.
Template Approach
const pageData = {
title: "Harbor Lights Quartet: Winter Standards Night",
startDate: "2026-11-14T19:30:00-05:00",
venueName: "Riverside Music Hall",
street: "120 Moody Street",
city: "Waltham",
region: "MA",
postalCode: "02453",
image: "https://www.example.com/images/harbor-lights-16x9.jpg"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "Event",
"name": pageData.title,
"startDate": pageData.startDate,
"eventStatus": "https://schema.org/EventScheduled",
"location": {
"@type": "Place",
"name": pageData.venueName,
"address": {
"@type": "PostalAddress",
"streetAddress": pageData.street,
"addressLocality": pageData.city,
"addressRegion": pageData.region,
"postalCode": pageData.postalCode,
"addressCountry": "US"
}
},
"image": [pageData.image]
};
{
"@context": "https://schema.org",
"@type": "Event",
"name": "Harbor Lights Quartet: Winter Standards Night",
"startDate": "2026-11-14T19:30:00-05:00",
"eventStatus": "https://schema.org/EventScheduled",
"location": {
"@type": "Place",
"name": "Riverside Music Hall",
"address": {
"@type": "PostalAddress",
"streetAddress": "120 Moody Street",
"addressLocality": "Waltham",
"addressRegion": "MA",
"postalCode": "02453",
"addressCountry": "US"
}
},
"image": [
"https://www.example.com/images/harbor-lights-16x9.jpg"
]
}
Conditional-logic Framework
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:\d{2})?)?$/;
function isAbsoluteUrl(value) {
return typeof value === "string" && /^https?:\/\/[^\s/?#]+\.[^\s/?#]+(?:[/?#]\S*)?$/i.test(value);
}
// Returns the ISO 8601 string unchanged (timezone offset kept), or null if unparseable.
function toIsoDateTime(value) {
if (typeof value !== "string") return null;
const text = value.trim();
const m = text.match(ISO_DATE_TIME);
if (!m) return null;
const [, y, mo, d, h = "00", mi = "00", s = "00"] = m;
const date = new Date(Date.UTC(+y, +mo - 1, +d));
if (date.getUTCMonth() !== +mo - 1 || date.getUTCDate() !== +d) return null;
if (+h > 23 || +mi > 59 || +s > 59) return null;
return text;
}
function toIsoDate(value) {
const iso = toIsoDateTime(value);
return iso ? iso.slice(0, 10) : null;
}
// Drops null, undefined, "", [] and {} recursively.
function compact(value) {
if (Array.isArray(value)) {
const items = value.map(compact).filter((v) => v !== undefined);
return items.length ? items : undefined;
}
if (value && typeof value === "object") {
const out = {};
for (const [key, v] of Object.entries(value)) {
const c = compact(v);
if (c !== undefined) out[key] = c;
}
return Object.keys(out).length ? out : undefined;
}
return value === null || value === undefined || value === "" ? undefined : value;
}
function passesPageGates(source) {
if (source.indexable === false) return false;
if (source.canonicalUrl && source.url && source.canonicalUrl !== source.url) return false;
return source.contentVisible !== false;
}
const EVENT_STATUS = {
scheduled: "https://schema.org/EventScheduled",
cancelled: "https://schema.org/EventCancelled",
postponed: "https://schema.org/EventPostponed",
rescheduled: "https://schema.org/EventRescheduled"
};
const TICKET_AVAILABILITY = {
in_stock: "https://schema.org/InStock",
sold_out: "https://schema.org/SoldOut",
pre_order: "https://schema.org/PreOrder"
};
function buildPostalAddress(address = {}) {
const country = String(address.country || "").toUpperCase();
return {
"@type": "PostalAddress",
streetAddress: address.street,
addressLocality: address.locality,
addressRegion: address.region,
postalCode: address.postalCode,
addressCountry: /^[A-Z]{2}$/.test(country) ? country : null
};
}
function buildOffer(ticket) {
const price = Number(ticket.price);
const currency = String(ticket.currency || "").toUpperCase();
// Price includes fees; 0 means free. Currency is a 3-letter ISO 4217 code.
if (ticket.price == null || !Number.isFinite(price) || price < 0 || !/^[A-Z]{3}$/.test(currency)) return null;
return {
"@type": "Offer",
name: ticket.name,
price,
priceCurrency: currency,
availability: TICKET_AVAILABILITY[ticket.availability] || null,
validFrom: toIsoDateTime(ticket.onSaleFrom),
url: isAbsoluteUrl(ticket.url) ? ticket.url : null
};
}
function buildEventSchema(source) {
// (a) Page gates: indexable, self-canonical, event visible on this leaf page.
if (!passesPageGates(source)) return null;
// (b) Required: name, startDate, location (a physical Place with a PostalAddress).
const venue = source.venue || {};
const toDate = source.allDay ? toIsoDate : toIsoDateTime; // all-day events: date only, no fake midnight
const startDate = toDate(source.startDate);
if (!source.name || !startDate) return null;
if (source.attendanceMode === "online" || !venue.address) return null; // virtual-only events are not supported
if (venue.name && venue.name === source.name) return null; // the event title must not be the venue name
// (d) Custom rules: status enum, endDate not before startDate, previousStartDate only when rescheduled.
const status = EVENT_STATUS[source.status] || EVENT_STATUS.scheduled;
let endDate = toDate(source.endDate);
if (endDate && Date.parse(endDate) < Date.parse(startDate)) endDate = null;
const previousStartDate = status === EVENT_STATUS.rescheduled
? (source.previousStartDates || []).map(toDate)
: null;
// (c) Required + recommended properties, only when the source has them.
const schema = {
"@context": "https://schema.org",
"@type": "Event",
name: source.name,
description: source.description,
startDate, // keep startDate even when the event is cancelled or postponed
endDate,
eventStatus: status,
previousStartDate,
location: {
"@type": "Place",
name: venue.name,
address: buildPostalAddress(venue.address)
},
image: (source.images || []).filter(isAbsoluteUrl),
offers: (source.tickets || []).map(buildOffer), // one Offer per ticket type
organizer: source.organizer && {
"@type": source.organizer.type === "Person" ? "Person" : "Organization",
name: source.organizer.name,
url: isAbsoluteUrl(source.organizer.url) ? source.organizer.url : null
},
performer: (source.performers || []).map((p) => ({
"@type": p.type === "Person" ? "Person" : "PerformingGroup",
name: p.name
}))
};
// (e) Strip empty values.
return compact(schema);
}
// Usage:
// const source = {
// "url": "https://www.example.com/events/harbor-lights-quartet-boston",
// "canonicalUrl": "https://www.example.com/events/harbor-lights-quartet-boston",
// "indexable": true,
// "contentVisible": true,
// "name": "Harbor Lights Quartet: Winter Standards Night",
// "description": "An evening of jazz standards from the Harbor Lights Quartet, with a guest vocalist for the second set.",
// "startDate": "2026-11-14T19:30:00-05:00",
// "endDate": "2026-11-14T22:00:00-05:00",
// "status": "scheduled",
// "attendanceMode": "offline",
// "venue": {
// "name": "Riverside Music Hall",
// "address": {
// "street": "120 Moody Street",
// "locality": "Waltham",
// "region": "MA",
// "postalCode": "02453",
// "country": "US"
// }
// },
// "images": [
// "https://www.example.com/images/harbor-lights-16x9.jpg",
// "https://www.example.com/images/harbor-lights-4x3.jpg",
// "https://www.example.com/images/harbor-lights-1x1.jpg"
// ],
// "tickets": [
// {
// "name": "General admission",
// "price": 35,
// "currency": "usd",
// "availability": "in_stock",
// "onSaleFrom": "2026-09-01T10:00:00-04:00",
// "url": "https://www.example.com/events/harbor-lights-quartet-boston/tickets"
// }
// ],
// "organizer": {
// "type": "Organization",
// "name": "Riverside Music Hall",
// "url": "https://www.example.com"
// },
// "performers": [
// {
// "type": "PerformingGroup",
// "name": "Harbor Lights Quartet"
// }
// ]
// };
// const schema = buildEventSchema(source); // null when a gate or required property fails
import re
from datetime import date, datetime, timezone
ISO_DATE_TIME = re.compile(r"^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:\d{2})?)?$")
ABSOLUTE_URL = re.compile(r"^https?://[^\s/?#]+\.[^\s/?#]+(?:[/?#]\S*)?$", re.I)
def is_absolute_url(value):
return isinstance(value, str) and bool(ABSOLUTE_URL.match(value))
# Returns the ISO 8601 string unchanged (timezone offset kept), or None if unparseable.
def to_iso_date_time(value):
if not isinstance(value, str):
return None
text = value.strip()
m = ISO_DATE_TIME.match(text)
if not m:
return None
y, mo, d = int(m[1]), int(m[2]), int(m[3])
try:
date(y, mo, d)
except ValueError:
return None
if int(m[4] or 0) > 23 or int(m[5] or 0) > 59 or int(m[6] or 0) > 59:
return None
return text
def to_iso_date(value):
iso = to_iso_date_time(value)
return iso[:10] if iso else None
# Drops None, "", [] and {} recursively.
def compact(value):
if isinstance(value, list):
items = [c for c in (compact(v) for v in value) if c is not None]
return items or None
if isinstance(value, dict):
out = {k: c for k, c in ((k, compact(v)) for k, v in value.items()) if c is not None}
return out or None
return None if value is None or value == "" else value
def passes_page_gates(source):
if source.get("indexable") is False:
return False
if source.get("canonicalUrl") and source.get("url") and source["canonicalUrl"] != source["url"]:
return False
return source.get("contentVisible") is not False
EVENT_STATUS = {
"scheduled": "https://schema.org/EventScheduled",
"cancelled": "https://schema.org/EventCancelled",
"postponed": "https://schema.org/EventPostponed",
"rescheduled": "https://schema.org/EventRescheduled",
}
TICKET_AVAILABILITY = {
"in_stock": "https://schema.org/InStock",
"sold_out": "https://schema.org/SoldOut",
"pre_order": "https://schema.org/PreOrder",
}
def parse_instant(value):
text = value.replace("Z", "+00:00") if "T" in value else value + "T00:00:00+00:00"
parsed = datetime.fromisoformat(text)
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
def build_postal_address(address):
address = address or {}
country = str(address.get("country") or "").upper()
return {
"@type": "PostalAddress",
"streetAddress": address.get("street"),
"addressLocality": address.get("locality"),
"addressRegion": address.get("region"),
"postalCode": address.get("postalCode"),
"addressCountry": country if re.fullmatch(r"[A-Z]{2}", country) else None,
}
def build_offer(ticket):
currency = str(ticket.get("currency") or "").upper()
price = ticket.get("price")
# Price includes fees; 0 means free. Currency is a 3-letter ISO 4217 code.
if isinstance(price, bool) or not isinstance(price, (int, float)) or price < 0:
return None
if not re.fullmatch(r"[A-Z]{3}", currency):
return None
return {
"@type": "Offer",
"name": ticket.get("name"),
"price": price,
"priceCurrency": currency,
"availability": TICKET_AVAILABILITY.get(ticket.get("availability")),
"validFrom": to_iso_date_time(ticket.get("onSaleFrom")),
"url": ticket.get("url") if is_absolute_url(ticket.get("url")) else None,
}
def build_event_schema(source):
# (a) Page gates: indexable, self-canonical, event visible on this leaf page.
if not passes_page_gates(source):
return None
# (b) Required: name, startDate, location (a physical Place with a PostalAddress).
venue = source.get("venue") or {}
to_date = to_iso_date if source.get("allDay") else to_iso_date_time # all-day: date only
start_date = to_date(source.get("startDate"))
if not source.get("name") or not start_date:
return None
if source.get("attendanceMode") == "online" or not venue.get("address"):
return None # virtual-only events are not supported
if venue.get("name") and venue.get("name") == source["name"]:
return None # the event title must not be the venue name
# (d) Custom rules: status enum, endDate not before startDate, previousStartDate only when rescheduled.
status = EVENT_STATUS.get(source.get("status"), EVENT_STATUS["scheduled"])
end_date = to_date(source.get("endDate"))
if end_date and parse_instant(end_date) < parse_instant(start_date):
end_date = None
previous_start = (
[to_date(d) for d in source.get("previousStartDates") or []]
if status == EVENT_STATUS["rescheduled"] else None
)
organizer = source.get("organizer")
# (c) Required + recommended properties, only when the source has them.
schema = {
"@context": "https://schema.org",
"@type": "Event",
"name": source["name"],
"description": source.get("description"),
"startDate": start_date, # keep startDate even when cancelled or postponed
"endDate": end_date,
"eventStatus": status,
"previousStartDate": previous_start,
"location": {
"@type": "Place",
"name": venue.get("name"),
"address": build_postal_address(venue.get("address")),
},
"image": [u for u in source.get("images") or [] if is_absolute_url(u)],
"offers": [build_offer(t) for t in source.get("tickets") or []], # one Offer per ticket type
"organizer": organizer and {
"@type": "Person" if organizer.get("type") == "Person" else "Organization",
"name": organizer.get("name"),
"url": organizer.get("url") if is_absolute_url(organizer.get("url")) else None,
},
"performer": [
{"@type": "Person" if p.get("type") == "Person" else "PerformingGroup", "name": p.get("name")}
for p in source.get("performers") or []
],
}
# (e) Strip empty values.
return compact(schema)
{
"@context": "https://schema.org",
"@type": "Event",
"name": "Harbor Lights Quartet: Winter Standards Night",
"description": "An evening of jazz standards from the Harbor Lights Quartet, with a guest vocalist for the second set.",
"startDate": "2026-11-14T19:30:00-05:00",
"endDate": "2026-11-14T22:00:00-05:00",
"eventStatus": "https://schema.org/EventScheduled",
"location": {
"@type": "Place",
"name": "Riverside Music Hall",
"address": {
"@type": "PostalAddress",
"streetAddress": "120 Moody Street",
"addressLocality": "Waltham",
"addressRegion": "MA",
"postalCode": "02453",
"addressCountry": "US"
}
},
"image": [
"https://www.example.com/images/harbor-lights-16x9.jpg",
"https://www.example.com/images/harbor-lights-4x3.jpg",
"https://www.example.com/images/harbor-lights-1x1.jpg"
],
"offers": [
{
"@type": "Offer",
"name": "General admission",
"price": 35,
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"validFrom": "2026-09-01T10:00:00-04:00",
"url": "https://www.example.com/events/harbor-lights-quartet-boston/tickets"
}
],
"organizer": {
"@type": "Organization",
"name": "Riverside Music Hall",
"url": "https://www.example.com"
},
"performer": [
{
"@type": "PerformingGroup",
"name": "Harbor Lights Quartet"
}
]
}
The Output As An Entity Graph
The conditional-logic output above, parsed in your browser and drawn as the graph a crawler builds from it. Every nested object is a node and the property that holds it is the edge. Tap a node to inspect its properties, or drag to rearrange.
Why Conditional Logic Is Better Than Static Templates
- Prevents emitting empty, null, or stale fields that frequently trigger rich result warnings.
- Supports multiple page states (for example: no rating yet, no image yet, no offers yet) without duplicate templates.
- Lets engineering teams centralize schema policy checks and validation in reusable code paths.
- Makes large-scale schema maintenance safer when content models evolve over time.
Property Reference
What Google's Event documentation requires and recommends, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
| Property | Expects | Notes | |
|---|---|---|---|
| location | Required | Place | The location of the event. Set the @type to Place and add location.address and location.name. |
| location.address | Required | PostalAddress | The venue's detailed street address. |
| location.name | Recommended | Text | The detailed name of the place or venue where the event is being held. Don't put the title of the event in this field. |
| name | Required | Text | The full title of the event. Don't put the name of the event location. |
| startDate | Required | DateTime or Date | The start date and start time of the event in ISO 8601 format. |
| description | Recommended | Text | Description of the event. Describe all details of the event to make it easier for users to understand and attend. |
| endDate | Recommended | DateTime or Date | The end date and end time of the event in ISO 8601 format. |
| eventStatus | Recommended | Enum | The status of the event; defaults to EventScheduled. One of: EventScheduled, EventCancelled, EventPostponed, EventRescheduled. |
| image | Recommended | URL or ImageObject, can repeat | URL of an image or logo for the event or tour (1920px wide recommended, minimum 720px) Aspect ratios: 16x9, 4x3, 1x1. |
| offers | Recommended | Offer, can repeat | A nested Offer, one for each ticket type. |
| offers.availability | Recommended | Enum | One of InStock, SoldOut, PreOrder. |
| offers.price | Recommended | Number | The lowest available price for your tickets, including service charges and fees. |
| offers.priceCurrency | Recommended | Text | The 3-letter ISO 4217 currency code. |
| offers.validFrom | Recommended | DateTime | The date and time when tickets go on sale, in ISO 8601 format. |
| offers.url | Recommended | URL | The URL of a page providing the ability to buy tickets. |
| organizer | Recommended | Organization or Person | The person or organization that is hosting the event. |
| organizer.name | Recommended | Text | The name of the person or organization that's hosting the event. |
| organizer.url | Recommended | URL | The event host's domain URL. |
| performer | Recommended | Person or PerformingGroup, can repeat | The participants performing at the event, such as artists and comedians. |
| performer.name | Recommended | Text | The name of the participant performing at the event. |
| previousStartDate | Recommended | DateTime or Date, can repeat | The previously scheduled start date if the event has been rescheduled. |