The browser whispers its language preferences, ranked; the server reads the list top to bottom and serves the first one it can actually speak.
Every request carries an Accept-Language header — a ranked wish list like en-US,en;q=0.9,fr;q=0.7. Each tag has an optional quality value q from 0 to 1 (default 1.0) that says how much the client prefers it.
The server compares that list against the languages it can actually serve and picks the best match by descending q, honouring range matching (a request for en can be satisfied by en-US). If nothing lines up, it must fall back to a sane default — never a blank page.
The header is a comma-separated list. Each entry is a language range with an optional ;q= weight; a missing q means 1.0. The server parses it, sorts by q descending (the highest preference first), then walks the list top to bottom. For each range it asks: can I serve this? Range matching means en matches any en-* the server offers, so a generic en preference is satisfied by an available en-US. The first entry that matches wins; if the walk ends with nothing, the server returns its configured default.
def negotiate(header, available, default):
# available: tags the server can serve, e.g. ["en-US", "fr", "de"]
prefs = []
for part in header.split(","):
part = part.strip()
if not part:
continue
bits = part.split(";")
tag = bits[0].strip().lower()
q = 1.0 # default when ;q= is absent
for b in bits[1:]:
b = b.strip()
if b.startswith("q="):
try:
q = float(b[2:])
except ValueError:
q = 0.0 # malformed q -> drop this entry
if tag and q > 0:
prefs.append((tag, q))
# stable sort by q descending; ties keep header order
prefs.sort(key=lambda p: p[1], reverse=True)
avail = {a.lower(): a for a in available}
for tag, _q in prefs:
if tag == "*": # wildcard: take anything available
return available[0]
if tag in avail: # exact match
return avail[tag]
for a_low, a_orig in avail.items(): # range match: en -> en-US
if a_low.startswith(tag + "-"):
return a_orig
return default # nothing matched -> sane fallback
Note the guard rails: a malformed q drops the entry instead of crashing, an empty header sorts to nothing and falls straight through to default, and * acts as a catch-all. The walk is purely ordered by q, so the loudest preference the server can honour always wins.
| Approach | Effect | Watch |
|---|---|---|
| Strict exact match | Predictable, never guesses a region | en never satisfied by en-US — more fallbacks |
| Language-range match | Generic en served by any en-* | Region nuance (en-GB vs en-US) is lost |
| Trust client header | Zero-config, works for most visitors | Header reflects the OS, not the human reading |
| Explicit user setting | Honours the actual choice | Needs UI + persistence; header is the seed |
Vary: Accept-Language | CDN caches one copy per language | Omit it and the cache serves the wrong language |
fr;q=0.1,en;q=0.9 as French when the user clearly prefers English. Sort by q descending first.en never resolves to your en-US content and silently falls back — even though you can serve it.Vary: Accept-Language. Without it a CDN caches the first language it saw and serves French content to English visitors from the edge.en;q=high or a stray space must downgrade gracefully, not throw. Default missing q to 1.0 and discard entries you can't parse.A request arrives with Accept-Language: en-US,en;q=0.9,fr;q=0.7,de;q=0.5 and the server can serve fr, de, and ja — but not English at all. Parsed and sorted by q: en-US (1.0), en (0.9), fr (0.7), de (0.5). The walk tries en-US — not available, and no en-* either; tries en — still nothing; tries fr — yes, served. French wins at q=0.7 because it's the highest-ranked preference the server can actually speak. Had the server offered no listed language at all, it would have returned its default instead of a blank.
A client sends Accept-Language: fr;q=0.2,en;q=0.8 and your server can serve both en-US and fr. Which language should win?