Interpreting the Job Location Object
TL;DR
Every job's locations array holds resolved geography rows, each with a kind (city_state_country, city_country, state_country, or country_only) showing how much actually resolved, plus confidence and is_partial for how sure the resolver was. Remote scope lives separately on has_remote/remote_type, not inside locations — a fully-remote job typically has an empty locations array. Multi-location postings are common; use is_primary for a single representative row but iterate the full array for anything claiming to show all locations.
This guide covers the consuming side of location data: what's actually in the locations array on a Job object once results come back, and the edge cases that trip up rendering (multi-location postings, partial resolution, remote flags at two different levels). If you're building the filtering side instead — resolving user-typed text into city_id/state_id/country_id — see How to Use Geo Suggest with the Jobs API, which covers that in depth and isn't repeated here.
Two Location Representations on Every Job
location— a single display string, e.g."San Francisco, CA; Remote". Good for showing as-is, not for parsing.locations— a JSON array of structured rows, each a fully resolved (or partially resolved) geographic entity.
{
"location": "San Francisco, CA; Remote",
"remote_type": "hybrid",
"locations": [
{
"kind": "city_state_country",
"is_primary": true,
"city_id": 5391959,
"city_name": "San Francisco",
"state_id": 5332921,
"state_name": "California",
"state_code": "CA",
"country_id": 233,
"country_name": "United States",
"country_code": "US",
"lat": 37.7749,
"lng": -122.4194,
"timezone": "America/Los_Angeles",
"is_remote": false,
"confidence": 0.99,
"is_partial": false
}
]
}locations isn't always a single object — treat it as a list from the start, even for postings that look single-location in the display string. But don't expect a dedicated "remote" row inside it: remote scope is tracked separately, on the job-level remote_type field (see below), not as an entry in locations. A fully-remote job typically has an empty locations array, not a locations array with one remote-flagged row.
kind Tells You the Resolution Granularity, Not Just "Where"
Each row's kind describes how much of the hierarchy actually resolved, and it isn't always the full city_state_country:
city_state_country— fully resolved (city, state, country all present).city_country— city and country resolved, no state (common outside the US/Canada, where "state" often doesn't apply).state_country— state and country resolved, no city (the employer gave a state/region without naming a specific city, e.g. "Texas, US").country_only— only the country resolved; the source text was too coarse (e.g. just "Remote - US" or "Europe") to resolve further.
If your UI assumes every row has city_name and country_name, a country_only row will render awkwardly (empty city). Branch on kind, or just render whichever of city_name/state_name/country_name is non-null for that row, rather than assuming a fixed template.
Each row also carries confidence (a 0-1 score) and is_partial (boolean) — use these instead of guessing at reliability from kind alone. A row parsed straight from a structured city/state/country field on the source posting comes back with confidence: 0.99, is_partial: false; a row the resolver had to force a best guess on (see the ambiguous-string case below) comes back with a deliberately lower confidence and is_partial: true. Check confidence before trusting a row for anything compliance- or benchmarking-sensitive, rather than treating every row as equally certain.
Ambiguous Source Strings Get a Best Guess — and the Response Tells You So
Some raw strings genuinely can't be resolved with full confidence — Georgia is the standing example, since it's both a US state and a country, and if the source posting gives no other hint (no office context, no country signal from the ATS), the resolver has to pick one. The same applies one level down: a bare city name shared by multiple places, or a two-letter code like CA that could mean California or Canada.
The resolver uses whatever context is available on the posting — other locations on the same listing, country hints passed along by the source adapter — to break these ties where it can, and unlike a silent guess, a row that required this kind of disambiguation is flagged: it carries ambiguity_reason (why the match wasn't clean), candidate_count (how many places the raw string could have matched), and often resolved_by (what signal broke the tie), alongside a confidence capped at 0.5 rather than the ~0.99 a clean match gets. A row with no ambiguity_reason and confidence near 1 resolved cleanly; a row with ambiguity_reason present was a forced best guess.
If a specific case matters enough to double-check — a compliance-sensitive market, a benchmarking report — read confidence/ambiguity_reason on the row first. They're the intended signal for exactly this. Cross-referencing against company.headquarters or the job description is a reasonable second opinion, but it's not something you need to fall back to as your only signal.
Multi-Location Postings Are Common — Don't Just Take locations[0]
Employers frequently list a job as open in several offices, or as "on-site in City A or City B, or remote." Each qualifying location becomes its own row in locations. Grabbing locations[0] and assuming that's "the" location silently drops the other offices from anything you build (search facets, map pins, filters).
Use is_primary to pick a single representative row when you need exactly one — for a compact job card, for example — but iterate the full array for anything that claims to show "where this job is located" (a map view, a location chips list, a detail page).
function primaryLocation(job: Job) {
return job.locations?.find((l) => l.is_primary) ?? job.locations?.[0] ?? null;
}
function allLocationLabels(job: Job) {
return (job.locations ?? [])
.filter((l) => l.city_name || l.state_name || l.country_name)
.map((l) => [l.city_name, l.state_name, l.country_name].filter(Boolean).join(", "));
}There's no display_label field on a job's locations rows — build the label yourself from city_name/state_name/country_name as above. (display_label does exist, but only on Geo Suggest results — a different endpoint with a different response shape. See How to Use Geo Suggest with the Jobs API.)
Remote Scope Lives on remote_type, Not Inside locations
Two fields answer "is this remote":
has_remote(top-level, boolean) — whether the job has any remote option at all.remote_type(top-level, string ornull) — how broad that remote option is:fully_remote(no office presence at all —locationsis typically empty),remote_country(remote within one country),remote_region(remote within a broader region), orhybrid(on-site with a remote component). It'snullwheneverhas_remoteisfalse.
Don't look for a remote flag inside locations — rows in that array represent resolved on-site geography, not remote-scope. A hybrid or multi-office posting can have has_remote: true and remote_type: "hybrid" while locations lists only its on-site offices; a fully-remote posting with no office at all typically has has_remote: true, remote_type: "fully_remote", and an empty locations array — there's nothing to resolve geographically. Use remote_type, not row-counting or is_primary, to tell "remote-first" apart from "on-site with a remote option."
For filtering, remote=true on /jobs filters on has_remote, so it will include hybrid/multi-office jobs where remote is only one of several options — it is not a "remote-only" filter in the strict sense. If you need strictly-remote listings, filter with remote=true and then check remote_type === "fully_remote" (or use the remote_type query param directly, if you only want a specific scope) rather than inspecting locations.
Summary
locationsis always an array — even single-location postings are one-element arrays, and multi-location postings are common enough thatlocations[0]is not a safe shortcut. Remote-only jobs typically have an emptylocationsarray.kindtells you how much of city/state/country actually resolved for that row (city_state_country,city_country,state_country, orcountry_only); don't assume every row has the full hierarchy.- Every row carries
confidenceandis_partial— check these before trusting a row for anything compliance- or benchmarking-sensitive, rather than treating every row as equally certain. - Use
is_primaryfor a single representative location, but iterate the full array for anything claiming to enumerate "where this job is." - Remote scope is a job-level concept, not a
locationsrow: usehas_remote(is remote possible at all) andremote_type(fully_remote/remote_country/remote_region/hybrid) together — don't look for a remote flag insidelocations. - Some source strings are genuinely ambiguous (
Georgiathe state vs. the country, a shared city name, a bareCA); a forced best guess is flagged withambiguity_reason,candidate_count, and a cappedconfidence— read those fields rather than cross-checkingcompany.headquartersor the jobdescriptionas your primary signal.