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"]),Source:https://dev.to/hello_nakama_f89fb103e925/designing-a-location-data-model-that-works-as-a-single-source-of-truth-5396Related posts

Copilot Fusion Takes Local Models, and the Offline Boundary Is Somewhere Else
