Local Business Schema Framework
Use conditional-logic schema generation to keep Local Business markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralLocal Business documentationUse on the page for one physical business location, typed with the most specific LocalBusiness subtype and carrying the same name, address and hours users see.
Key Implementation Documentation Highlights
- Required properties: name, address. address is a PostalAddress; include as many of streetAddress, addressLocality, addressRegion, postalCode and addressCountry (2-letter ISO 3166-1) as possible.
- Recommended properties: url, telephone, geo, openingHoursSpecification, priceRange, menu, servesCuisine, department, aggregateRating, review. telephone is recommended, not required.
- Use the most specific LocalBusiness subtype (for example Restaurant, DaySpa, HealthClub) rather than LocalBusiness itself.
- telephone includes the country and area code; geo.latitude and geo.longitude carry at least 5 decimal places; url must be a working link; priceRange (for example $10-15 or $$$) must be under 100 characters or Google won't show it.
- openingHoursSpecification: dayOfWeek plus opens and closes in hh:mm or hh:mm:ss. Open 24 hours is 00:00 to 23:59, closed all day is 00:00 to 00:00, hours past midnight are one entry with closes earlier than opens, and seasonal hours add validFrom and validThrough (YYYY-MM-DD).
- department: a nested LocalBusiness per department, named "{store name} {department name}" unless the department name is explicitly branded.
- aggregateRating and review are only for sites that capture reviews about other local businesses, following the Review snippet guidelines. Self-serving reviews of the business on its own site are not eligible for stars.
- Restaurant carousel (limited): Google currently limits it to a small set of restaurant providers, who register interest through a Google form. It requires image (crawlable, 16x9, 4x3 or 1x1, at least 50,000 pixels) and name, recommends address and servesCuisine, and follows the Carousel guidelines (an ItemList on a summary page).
- 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 = {
name: "Moody Street Noodle Bar",
url: "https://www.example.com/locations/waltham",
telephone: "+1-781-555-0142",
street: "212 Moody Street",
city: "Waltham",
region: "MA",
postalCode: "02453"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "Restaurant",
"name": pageData.name,
"url": pageData.url,
"telephone": pageData.telephone,
"address": {
"@type": "PostalAddress",
"streetAddress": pageData.street,
"addressLocality": pageData.city,
"addressRegion": pageData.region,
"postalCode": pageData.postalCode,
"addressCountry": "US"
},
"priceRange": "$$"
};
{
"@context": "https://schema.org",
"@type": "Restaurant",
"name": "Moody Street Noodle Bar",
"url": "https://www.example.com/locations/waltham",
"telephone": "+1-781-555-0142",
"address": {
"@type": "PostalAddress",
"streetAddress": "212 Moody Street",
"addressLocality": "Waltham",
"addressRegion": "MA",
"postalCode": "02453",
"addressCountry": "US"
},
"priceRange": "$$"
}
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 LOCAL_BUSINESS_TYPES = {
restaurant: "Restaurant",
cafe: "CafeOrCoffeeShop",
bakery: "Bakery",
day_spa: "DaySpa",
gym: "HealthClub",
dentist: "Dentist",
auto_repair: "AutoRepair",
hardware_store: "HardwareStore"
};
const FOOD_TYPES = ["Restaurant", "CafeOrCoffeeShop", "Bakery"];
const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const TIME = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/;
// Use the most specific LocalBusiness subtype available.
function chooseLocalBusinessType(source) {
return LOCAL_BUSINESS_TYPES[source.category] || "LocalBusiness";
}
function hasFiveDecimals(value) {
return typeof value === "number" && /^-?\d+\.\d{5,}$/.test(String(value));
}
function buildHours(spec) {
const days = (spec.days || []).filter((d) => DAYS.includes(d));
if (!days.length || !TIME.test(spec.opens || "") || !TIME.test(spec.closes || "")) return null;
// 24 hours: 00:00 to 23:59. Closed all day: 00:00 to 00:00. Past midnight: closes earlier than opens.
return {
"@type": "OpeningHoursSpecification",
dayOfWeek: days.map((d) => `https://schema.org/${d}`),
opens: spec.opens,
closes: spec.closes,
validFrom: toIsoDate(spec.validFrom), // seasonal hours only
validThrough: toIsoDate(spec.validThrough)
};
}
function buildLocalBusinessSchema(source) {
// (a) Page gates: indexable, self-canonical, business details visible on the page.
if (!passesPageGates(source)) return null;
// (b) Required: name, address.
const address = source.address || {};
if (!source.name || !address.street || !address.locality) return null;
const type = chooseLocalBusinessType(source);
const country = String(address.country || "").toUpperCase();
const geo = source.geo || {};
// (c) Required + recommended properties, only when the source has them.
const schema = {
"@context": "https://schema.org",
"@type": type,
"@id": isAbsoluteUrl(source.url) ? `${source.url}#business` : null,
name: source.name,
url: isAbsoluteUrl(source.url) ? source.url : null,
image: (source.images || []).filter(isAbsoluteUrl),
// (d) Custom rules: international phone, 5-decimal geo, hh:mm hours, short priceRange.
telephone: /^\+\d/.test(source.telephone || "") ? source.telephone : null,
address: {
"@type": "PostalAddress",
streetAddress: address.street,
addressLocality: address.locality,
addressRegion: address.region,
postalCode: address.postalCode,
addressCountry: /^[A-Z]{2}$/.test(country) ? country : null
},
geo: hasFiveDecimals(geo.latitude) && hasFiveDecimals(geo.longitude)
? { "@type": "GeoCoordinates", latitude: geo.latitude, longitude: geo.longitude }
: null,
openingHoursSpecification: (source.hours || []).map(buildHours),
priceRange: source.priceRange && source.priceRange.length < 100 ? source.priceRange : null,
menu: FOOD_TYPES.includes(type) && isAbsoluteUrl(source.menuUrl) ? source.menuUrl : null,
servesCuisine: FOOD_TYPES.includes(type) ? source.cuisines : null,
department: (source.departments || []).map((dept) => ({
"@type": chooseLocalBusinessType(dept),
// "{store name} {department name}" unless the department name is its own brand.
name: dept.branded ? dept.name : `${source.name} ${dept.name}`,
telephone: /^\+\d/.test(dept.telephone || "") ? dept.telephone : null,
openingHoursSpecification: (dept.hours || []).map(buildHours)
})),
// Ratings only on sites that collect reviews about other businesses (no self-serving reviews).
aggregateRating: source.reviewsAreAboutOtherBusinesses && source.rating ? {
"@type": "AggregateRating",
ratingValue: source.rating.value,
reviewCount: source.rating.count
} : null
};
// (e) Strip empty values.
return compact(schema);
}
// Usage:
// const source = {
// "url": "https://www.example.com/locations/waltham",
// "canonicalUrl": "https://www.example.com/locations/waltham",
// "indexable": true,
// "contentVisible": true,
// "category": "restaurant",
// "name": "Moody Street Noodle Bar",
// "images": [
// "https://www.example.com/images/noodle-bar-16x9.jpg",
// "https://www.example.com/images/noodle-bar-4x3.jpg",
// "https://www.example.com/images/noodle-bar-1x1.jpg"
// ],
// "telephone": "+1-781-555-0142",
// "address": {
// "street": "212 Moody Street",
// "locality": "Waltham",
// "region": "MA",
// "postalCode": "02453",
// "country": "US"
// },
// "geo": {
// "latitude": 42.37241,
// "longitude": -71.23683
// },
// "hours": [
// {
// "days": [
// "Monday",
// "Tuesday",
// "Wednesday",
// "Thursday"
// ],
// "opens": "11:30",
// "closes": "21:30"
// },
// {
// "days": [
// "Friday",
// "Saturday"
// ],
// "opens": "11:30",
// "closes": "01:00"
// },
// {
// "days": [
// "Sunday"
// ],
// "opens": "00:00",
// "closes": "00:00"
// }
// ],
// "priceRange": "$$",
// "menuUrl": "https://www.example.com/locations/waltham/menu",
// "cuisines": [
// "Japanese",
// "Ramen"
// ],
// "reviewsAreAboutOtherBusinesses": false
// };
// const schema = buildLocalBusinessSchema(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
LOCAL_BUSINESS_TYPES = {
"restaurant": "Restaurant",
"cafe": "CafeOrCoffeeShop",
"bakery": "Bakery",
"day_spa": "DaySpa",
"gym": "HealthClub",
"dentist": "Dentist",
"auto_repair": "AutoRepair",
"hardware_store": "HardwareStore",
}
FOOD_TYPES = ["Restaurant", "CafeOrCoffeeShop", "Bakery"]
DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
TIME = re.compile(r"^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$")
PHONE = re.compile(r"^\+\d")
# Use the most specific LocalBusiness subtype available.
def choose_local_business_type(source):
return LOCAL_BUSINESS_TYPES.get(source.get("category"), "LocalBusiness")
def has_five_decimals(value):
return isinstance(value, float) and bool(re.fullmatch(r"-?\d+\.\d{5,}", repr(value)))
def build_hours(spec):
days = [d for d in spec.get("days") or [] if d in DAYS]
if not days or not TIME.match(spec.get("opens") or "") or not TIME.match(spec.get("closes") or ""):
return None
# 24 hours: 00:00 to 23:59. Closed all day: 00:00 to 00:00. Past midnight: closes earlier than opens.
return {
"@type": "OpeningHoursSpecification",
"dayOfWeek": [f"https://schema.org/{d}" for d in days],
"opens": spec["opens"],
"closes": spec["closes"],
"validFrom": to_iso_date(spec.get("validFrom")), # seasonal hours only
"validThrough": to_iso_date(spec.get("validThrough")),
}
def phone_or_none(value):
return value if PHONE.match(value or "") else None
def build_local_business_schema(source):
# (a) Page gates: indexable, self-canonical, business details visible on the page.
if not passes_page_gates(source):
return None
# (b) Required: name, address.
address = source.get("address") or {}
if not source.get("name") or not address.get("street") or not address.get("locality"):
return None
business_type = choose_local_business_type(source)
country = str(address.get("country") or "").upper()
geo = source.get("geo") or {}
url = source.get("url") if is_absolute_url(source.get("url")) else None
is_food = business_type in FOOD_TYPES
price_range = source.get("priceRange")
rating = source.get("rating")
# (c) Required + recommended properties, only when the source has them.
schema = {
"@context": "https://schema.org",
"@type": business_type,
"@id": f"{url}#business" if url else None,
"name": source["name"],
"url": url,
"image": [u for u in source.get("images") or [] if is_absolute_url(u)],
# (d) Custom rules: international phone, 5-decimal geo, hh:mm hours, short priceRange.
"telephone": phone_or_none(source.get("telephone")),
"address": {
"@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,
},
"geo": {"@type": "GeoCoordinates", "latitude": geo["latitude"], "longitude": geo["longitude"]}
if has_five_decimals(geo.get("latitude")) and has_five_decimals(geo.get("longitude")) else None,
"openingHoursSpecification": [build_hours(h) for h in source.get("hours") or []],
"priceRange": price_range if price_range and len(price_range) < 100 else None,
"menu": source.get("menuUrl") if is_food and is_absolute_url(source.get("menuUrl")) else None,
"servesCuisine": source.get("cuisines") if is_food else None,
"department": [
{
"@type": choose_local_business_type(dept),
# "{store name} {department name}" unless the department name is its own brand.
"name": dept["name"] if dept.get("branded") else f"{source['name']} {dept['name']}",
"telephone": phone_or_none(dept.get("telephone")),
"openingHoursSpecification": [build_hours(h) for h in dept.get("hours") or []],
}
for dept in source.get("departments") or []
],
# Ratings only on sites that collect reviews about other businesses (no self-serving reviews).
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": rating.get("value"),
"reviewCount": rating.get("count"),
} if source.get("reviewsAreAboutOtherBusinesses") and rating else None,
}
# (e) Strip empty values.
return compact(schema)
{
"@context": "https://schema.org",
"@type": "Restaurant",
"@id": "https://www.example.com/locations/waltham#business",
"name": "Moody Street Noodle Bar",
"url": "https://www.example.com/locations/waltham",
"image": [
"https://www.example.com/images/noodle-bar-16x9.jpg",
"https://www.example.com/images/noodle-bar-4x3.jpg",
"https://www.example.com/images/noodle-bar-1x1.jpg"
],
"telephone": "+1-781-555-0142",
"address": {
"@type": "PostalAddress",
"streetAddress": "212 Moody Street",
"addressLocality": "Waltham",
"addressRegion": "MA",
"postalCode": "02453",
"addressCountry": "US"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": 42.37241,
"longitude": -71.23683
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": [
"https://schema.org/Monday",
"https://schema.org/Tuesday",
"https://schema.org/Wednesday",
"https://schema.org/Thursday"
],
"opens": "11:30",
"closes": "21:30"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": [
"https://schema.org/Friday",
"https://schema.org/Saturday"
],
"opens": "11:30",
"closes": "01:00"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": [
"https://schema.org/Sunday"
],
"opens": "00:00",
"closes": "00:00"
}
],
"priceRange": "$$",
"menu": "https://www.example.com/locations/waltham/menu",
"servesCuisine": [
"Japanese",
"Ramen"
]
}
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 Local business 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 | |
|---|---|---|---|
| address | Required | PostalAddress | The physical location of the business. Include as many properties as possible. |
| address.streetAddress | Recommended | Text | The street address. |
| address.addressLocality | Recommended | Text | The city or locality. |
| address.addressRegion | Recommended | Text | The region, state or province. |
| address.postalCode | Recommended | Text | The postal code. |
| address.addressCountry | Recommended | Text or Country | The country, as a two-letter ISO 3166-1 alpha-2 code. |
| name | Required | Text | The name of the business. |
| aggregateRating | Recommended | AggregateRating | The average rating of the local business based on multiple ratings or reviews. |
| department | Recommended | LocalBusiness, can repeat | A nested item for a single department. |
| geo | Recommended | GeoCoordinates | Geographic coordinates of the business. |
| geo.latitude | Recommended | Number | The latitude of the business location, with at least 5 decimal places. |
| geo.longitude | Recommended | Number | The longitude of the business location, with at least 5 decimal places. |
| menu | Recommended | URL | The fully-qualified URL of the menu. |
| openingHoursSpecification | Recommended | OpeningHoursSpecification, can repeat | Hours during which the business location is open. |
| openingHoursSpecification.closes | Recommended | Time | The time the business location closes, in hh:mm:ss format. |
| openingHoursSpecification.dayOfWeek | Recommended | Enum, can repeat | One or more of Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday. One of: Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, and 1 more. |
| openingHoursSpecification.opens | Recommended | Time | The time the business location opens, in hh:mm:ss format. |
| openingHoursSpecification.validFrom | Recommended | Date | The start date of a seasonal business closure, in YYYY-MM-DD format. |
| openingHoursSpecification.validThrough | Recommended | Date | The end date of a seasonal business closure, in YYYY-MM-DD format. |
| priceRange | Recommended | Text | The relative price range of the business; must be shorter than 100 characters. |
| review | Recommended | Review, can repeat | A review of the local business. |
| servesCuisine | Recommended | Text, can repeat | The type of cuisine the restaurant serves. |
| telephone | Recommended | Text | A business phone number meant to be the primary contact method for customers; include country and area code. |
| url | Recommended | URL | The fully-qualified URL of the specific business location. |