A location single source of truth is one internal record per location, with a strict schema, that every output is generated from: Google Business Profile, other listings, store pages, and structured data. Define it with JSON Schema, validate every change against it plus a few business rules, and write mappers that translate it into each platform's format.
Updated September 2026.
Most multi-location data problems are not API problems. They are "which spreadsheet is right" problems. The website team has one set of hours, the listings team has another, and the store manager changed a third directly in Google. This post shows a data model that makes one record authoritative, with working code for validation and mapping.
What should a location record contain?
Only the facts you publish, plus the IDs that connect the record to each platform. A practical minimum:
| Field group | Examples | Why it exists |
|---|---|---|
| Identity | internal id, store_code, name
|
Stable keys that never change when the store name does |
| Status |
open, temporarily_closed, permanently_closed, coming_soon
|
Drives what every output shows |
| Address and phone | US address parts, E.164 phone | The fields that must match everywhere |
| Hours | regular periods and dated special hours | The most frequently wrong field on any listing |
| Categories | Google category ID | Brand-level decision, stored once |
| External IDs | Google location name, Place ID | Joins your record to each platform |
Keep an internal id separate from the store code. Store codes get reused after closures and renumbered after acquisitions. Your primary key should outlive both.
How do you define the schema?
Use JSON Schema draft 2020-12. It is language-neutral, so the same file can validate records in a Python pipeline, a Node admin tool, and a CI check.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/location.json",
"type": "object",
"required": ["id", "store_code", "name", "status", "address", "phone", "timezone", "hours"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "pattern": "^loc_[a-z0-9]+$" },
"store_code": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1 },
"status": { "enum": ["open", "temporarily_closed", "permanently_closed", "coming_soon"] },
"address": {
"type": "object",
"required": ["lines", "city", "state", "postal_code", "country"],
"additionalProperties": false,
"properties": {
"lines": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 2 },
"city": { "type": "string" },
"state": { "type": "string", "pattern": "^[A-Z]{2}$" },
"postal_code": { "type": "string", "pattern": "^[0-9]{5}(-[0-9]{4})?$" },
"country": { "const": "US" }
}
},
"phone": { "type": "string", "pattern": "^\\+1[0-9]{10}$" },
"website_url": { "type": "string", "format": "uri" },
"timezone": { "type": "string", "examples": ["America/Chicago"] },
"categories": {
"type": "object",
"properties": {
"google_primary": { "type": "string", "pattern": "^gcid:[a-z0-9_]+$" }
}
},
"hours": {
"type": "object",
"required": ["regular"],
"properties": {
"regular": { "type": "array", "items": { "$ref": "#/$defs/period" } },
"special": { "type": "array", "items": { "$ref": "#/$defs/special" } }
}
},
"external_ids": {
"type": "object",
"properties": {
"google_location": { "type": "string", "pattern": "^locations/[0-9]+$" },
"google_place_id": { "type": "string" }
}
},
"updated_at": { "type": "string", "format": "date-time" }
},
"$defs": {
"time": { "type": "string", "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$" },
"period": {
"type": "object",
"required": ["day", "open", "close"],
"additionalProperties": false,
"properties": {
"day": { "enum": ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"] },
"open": { "$ref": "#/$defs/time" },
"close": { "$ref": "#/$defs/time" }
}
},
"special": {
"type": "object",
"required": ["date", "closed"],
"additionalProperties": false,
"properties": {
"date": { "type": "string", "format": "date" },
"closed": { "type": "boolean" },
"open": { "$ref": "#/$defs/time" },
"close": { "$ref": "#/$defs/time" }
},
"if": { "properties": { "closed": { "const": false } } },
"then": { "required": ["open", "close"] }
}
}
}
A few choices worth explaining:
-
Phone as E.164.
+15125550142is unambiguous. Format it for display at the edge, not in storage. -
Times as
HH:MMstrings. Easy to read and diff. The mappers convert them to each platform's format. -
Special hours require
openandclosewhenclosedis false. Theif/thenblock enforces that. -
additionalProperties: false. Typos likepostal_codfail loudly instead of being ignored.
The Google category pattern follows the gcid:... form that Google uses in the category examples in its location data guide, such as gcid:software_company.
What does a valid record look like?
{
"id": "loc_014",
"store_code": "TX-AUSTIN-014",
"name": "Example Hardware",
"status": "open",
"address": {
"lines": ["1200 S Lamar Blvd"],
"city": "Austin",
"state": "TX",
"postal_code": "78704",
"country": "US"
},
"phone": "+15125550142",
"website_url": "https://example.com/locations/texas/austin/south-lamar/",
"timezone": "America/Chicago",
"hours": {
"regular": [
{ "day": "MONDAY", "open": "08:00", "close": "20:00" },
{ "day": "FRIDAY", "open": "08:00", "close": "20:00" },
{ "day": "SATURDAY", "open": "09:00", "close": "18:00" }
],
"special": [
{ "date": "2026-11-26", "closed": true },
{ "date": "2026-12-24", "closed": false, "open": "08:00", "close": "15:00" }
]
},
"external_ids": { "google_location": "locations/1234567890123456789" },
"updated_at": "2026-09-20T14:05:00Z"
}
Short, boring, and reviewable in a pull request. That is the point.
How do you validate beyond the schema?
JSON Schema checks shape. It cannot easily check that two periods on Monday overlap or that last year's holiday is still in the file. Add a small rules layer.
# validate.py
# pip install jsonschema
import json
import sys
from datetime import date
from jsonschema import Draft202012Validator, FormatChecker
schema = json.load(open("location.schema.json"))
Draft202012Validator.check_schema(schema)
validator = Draft202012Validator(schema, format_checker=FormatChecker())
def business_rules(loc: dict) -> list[str]:
errors = []
by_day = {}
for p in loc["hours"]["regular"]:
by_day.setdefault(p["day"], []).append(p)
for day, periods in by_day.items():
periods.sort(key=lambda p: p["open"])
for prev, nxt in zip(periods, periods[1:]):
if prev["close"] > prev["open"] and nxt["open"] < prev["close"]:
errors.append(f"{day}: overlapping periods {prev['open']} and {nxt['open']}")
for p in loc["hours"]["regular"]:
if p["open"] == p["close"]:
errors.append(f"{p['day']}: open equals close, use a 24-hour flag instead")
seen = set()
for s in loc["hours"].get("special", []):
if s["date"] in seen:
errors.append(f"{s['date']}: duplicate special-hours date")
seen.add(s["date"])
if date.fromisoformat(s["date"]) < date.today():
errors.append(f"{s['date']}: special hours in the past, prune them")
if loc["status"] == "permanently_closed" and loc["hours"]["regular"]:
errors.append("closed location still has regular hours")
return errors
def main(path: str) -> int:
loc = json.load(open(path))
problems = [f"schema: {e.json_path}: {e.message}" for e in validator.iter_errors(loc)]
if not problems:
problems = [f"rule: {m}" for m in business_rules(loc)]
for p in problems:
print(p)
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1]))
Run it in CI on every change to the location files, and in the admin tool before a save. A failing check blocks the change instead of publishing it. Note that the jsonschema library only enforces some formats, like uri, when optional extra packages are installed. Check the library's format documentation for the formats you rely on.
How do you map one record to many outputs?
Write one small function per destination. Here are two: a Business Information API Location body and a schema.org LocalBusiness block.
# mappers.py
DAYS = ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"]
def _tod(hhmm: str) -> dict:
h, m = (int(x) for x in hhmm.split(":"))
return {"hours": h, "minutes": m}
def _next_day(day: str) -> str:
return DAYS[(DAYS.index(day) + 1) % 7]
def _date(iso: str) -> dict:
y, m, d = (int(x) for x in iso.split("-"))
return {"year": y, "month": m, "day": d}
def to_gbp(loc: dict) -> dict:
"""Map an internal record to a Business Information API Location body."""
periods = []
for p in loc["hours"]["regular"]:
overnight = p["close"] <= p["open"]
periods.append({
"openDay": p["day"],
"openTime": _tod(p["open"]),
"closeDay": _next_day(p["day"]) if overnight else p["day"],
"closeTime": _tod(p["close"]),
})
special = []
for s in loc["hours"].get("special", []):
item = {"startDate": _date(s["date"]), "closed": s["closed"]}
if not s["closed"]:
item["openTime"] = _tod(s["open"])
item["closeTime"] = _tod(s["close"])
special.append(item)
a = loc["address"]
body = {
"storeCode": loc["store_code"],
"title": loc["name"],
"phoneNumbers": {"primaryPhone": loc["phone"]},
"storefrontAddress": {
"regionCode": a["country"],
"administrativeArea": a["state"],
"locality": a["city"],
"postalCode": a["postal_code"],
"addressLines": a["lines"],
},
"regularHours": {"periods": periods},
"specialHours": {"specialHourPeriods": special},
}
if "website_url" in loc:
body["websiteUri"] = loc["website_url"]
return body
def to_jsonld(loc: dict, page_url: str) -> dict:
"""Map the same record to schema.org LocalBusiness JSON-LD."""
spec = [
{"@type": "OpeningHoursSpecification", "dayOfWeek": p["day"].title(),
"opens": p["open"], "closes": p["close"]}
for p in loc["hours"]["regular"]
]
for s in loc["hours"].get("special", []):
spec.append({
"@type": "OpeningHoursSpecification",
"opens": "00:00" if s["closed"] else s["open"],
"closes": "00:00" if s["closed"] else s["close"],
"validFrom": s["date"],
"validThrough": s["date"],
})
a = loc["address"]
return {
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": f"{page_url}#location",
"name": loc["name"],
"url": page_url,
"telephone": loc["phone"],
"address": {
"@type": "PostalAddress",
"streetAddress": ", ".join(a["lines"]),
"addressLocality": a["city"],
"addressRegion": a["state"],
"postalCode": a["postal_code"],
"addressCountry": a["country"],
},
"openingHoursSpecification": spec,
}
The Google mapper follows the Business Information API Location reference: regularHours.periods with openDay, openTime, closeDay, and closeTime, and specialHours.specialHourPeriods with a startDate and either times or closed: true. Overnight regular hours set closeDay to the next day.
The JSON-LD mapper follows Google's LocalBusiness structured data guide, which says to mark a business closed all day by setting both opens and closes to "00:00", and to use validFrom and validThrough for date-limited hours.
This version does not handle overnight special hours. Google's special hours rules require splitting a period that runs past midnight into two sets, so add that before you rely on it for late-night locations.
How do you push changes safely?
Send only what changed. The Business Information API's locations.patch takes an updateMask, so compute a field-level diff between the last published version and the new record, and patch only those fields. It also accepts validateOnly=true, which is useful because Google offers no sandbox.
A simple flow:
- A change lands in the location record through a pull request or admin tool.
- Schema and rule validation run.
- A diff job builds the update mask for each destination.
- Writes go out, with
validateOnlyfirst for risky fields. - A read-back job compares what each platform now serves against the record.
Who should be allowed to edit which fields?
Put ownership in the model, not in people's heads. A common split for brands:
- Brand team: name, categories, website URL pattern, standard services.
- Location or franchisee: special hours, local phone, accessibility details, photos.
- Nobody directly on the platform: anything the pipeline publishes, or the next sync overwrites it.
Enforce it with per-field permissions in your admin tool, and with CODEOWNERS rules if the records live in Git.
Should you build this or use a platform?
Build it if you have engineering capacity and many custom outputs. Use a listing platform if the main outputs are listings and pages. Many platforms already keep a location record like this one. Synup, for example, stores one record per location and publishes it to its publisher network, and it offers an API. In that setup, treat the platform record as the source of truth and use a schema like this to validate what you send into it or pull out of it.
FAQ
Why JSON Schema instead of a database schema?
Use both if you have a database. JSON Schema gives you one portable contract that CI, admin tools, and import scripts can all enforce.
Should hours be stored in local time or UTC?
Local time with an IANA timezone per location. Listings and store pages display local hours, and daylight saving changes make UTC storage error-prone.
Where should the Google Place ID live?
In external_ids. Google's Places API policies say place IDs are exempt from caching restrictions, so you can store them indefinitely.
How do I handle a store that is closing?
Change status, keep the record, and let each mapper decide what to publish. Deleting the record loses history and external IDs.
Top comments (0)