Normalization

Interpreting the Job Location Object

CleanJobData Engineering

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 or null) — how broad that remote option is: fully_remote (no office presence at all — locations is typically empty), remote_country (remote within one country), remote_region (remote within a broader region), or hybrid (on-site with a remote component). It's null whenever has_remote is false.

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

  • locations is always an array — even single-location postings are one-element arrays, and multi-location postings are common enough that locations[0] is not a safe shortcut. Remote-only jobs typically have an empty locations array.
  • kind tells you how much of city/state/country actually resolved for that row (city_state_country, city_country, state_country, or country_only); don't assume every row has the full hierarchy.
  • Every row carries confidence and is_partial — check these before trusting a row for anything compliance- or benchmarking-sensitive, rather than treating every row as equally certain.
  • Use is_primary for 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 locations row: use has_remote (is remote possible at all) and remote_type (fully_remote/remote_country/remote_region/hybrid) together — don't look for a remote flag inside locations.
  • Some source strings are genuinely ambiguous (Georgia the state vs. the country, a shared city name, a bare CA); a forced best guess is flagged with ambiguity_reason, candidate_count, and a capped confidence — read those fields rather than cross-checking company.headquarters or the job description as your primary signal.