Notes

Why a job's city is a closed vocabulary

The location a source publishes is a string. Turning it into something you can filter on meant deciding, once, that a name no source emits is a name that cannot arrive.

Every job row this API serves carries location exactly as the source published it, and the field documentation says so with unusual bluntness: not normalised, not geocoded, and not comparable between sources. That honesty is also the reason nobody could filter on it.

What people did instead was pattern-match. Downstream of us, the gap was being filled by running a city name against title || company with ILIKE — whose own documentation conceded the result. A job at "Bakı Bank" in Sumqayıt surfaces under "Bakı". A set of indexable per-city landing pages was being built out of job titles while a location column sat unused two fields away.

So city and district were added, in migration 0041. The interesting part is not the columns. It is the two decisions that had to be made before they could exist.

The provenance problem, which decided where the code lives

The requirement was: publish a city where the source states one, and NULL everywhere it would have to be guessed.

That distinction cannot be recovered from the stored location, and finding out why shaped the whole change. The scraper base class runs a location backfill on every scrape: for any row whose location is empty, it regexes the title and the first 5,000 characters of the body for a city name. That is precisely the inference the requirement rules out — a city read out of prose is as likely to be the employer's head office as the job's location. And by the time a row reaches storage, a city the source published and a city we inferred are the same string in the same column, indistinguishable forever.

A city derived in SQL from location would therefore have inherited every guess and quietly shipped the exact thing the request was careful to prohibit. It had to be captured upstream, at the one instant the difference still exists: after each scraper sets location from its own structured fields, and before the prose sweep runs. That ordering is the entire provenance guarantee. The migration only stores what arrives.

There is no backfill, and that is not a cost decision. The stored location cannot say whether it was stated or guessed, so there is nothing to backfill from.

The vocabulary is built from the data, not from a gazetteer

The city list was built from 334 distinct live location values across 20 Azerbaijani sources, measured on 2026-08-22. Not from a list of Azerbaijani settlements — from the strings the sources actually emit. A name no source emits is a name that cannot arrive, and putting it in the vocabulary only creates a value a consumer can filter on and never match.

Three rules follow from that, and each of them exists because of a specific way the naive version is wrong:

  1. Whole components are matched, never substrings. Bakı şəhəri, Qaradağ rayonu, Salyan şossesi 27 contains "Salyan", a real city 130km away. A substring search reads that address as a Salyan job.
  2. More than one city in one value is NULL, not a pick. Choosing the first is choosing arbitrarily and then publishing the choice as a fact.
  3. Azerbaijani cities only. hh1.az publishes Москва, Астана and Батуми alongside Баку. Those keep a NULL city and their untouched location.

Over 15,039 rows, 99% resolve to a city and 1% are correctly NULL — foreign cities, street addresses, building names, "Baş ofis", and postings advertising several places at once.

A closed vocabulary a consumer can enumerate is worth more than coverage nobody can validate. That is the trade, stated plainly, and it is the reason the last 1% is a null rather than a best effort.

Why district had to arrive at the same time

A city alone does not answer the question the field is wanted for. iseqebul.az publishes a Baku rayon where every other source publishes a city: 857 of its 861 rows say "Nəsimi". Per-rayon landing pages need rayon granularity, which a city vocabulary cannot express.

Adding district beside city settled that immediately instead of forcing city to be redefined the first time somebody tried to build a page for one. A rayon implies its city, so a district always arrives with city = 'Bakı'.

location did not change

Both columns sit beside location, not instead of it. Normalising the raw value in place would break every consumer reading it today, and the raw string carries precision a vocabulary discards — a street address is more specific than "Bakı", and some readers want that.

This is the same shape as source and source_name, which worked for the same reason: publish the normalised handle and keep the thing it was derived from.

The failure mode this replaced

It is worth naming what the alternative looks like, because it is documented too.

An earlier bug in one source's JSON-LD coercion ended with return str(v).strip() or default. An empty list — a site saying it has no value — fell past every branch to str([]), which is '[]'. Two characters, non-empty, and therefore true. So the call site's guard, _str(addr.get('addressLocality')) or None, never fired: '[]' or None is '[]'.

493 of that source's 9,749 rows carried a location of []. The guard read as though it handled the empty case, and it handled every empty case except that one.

A closed vocabulary would have refused [] on the way in. That is the argument for having one, in a sentence.

The jobs reference lists city, district and location side by side with what each is and is not.