Accept-Language content negotiation

The browser whispers its language preferences, ranked; the server reads the list top to bottom and serves the first one it can actually speak.

The idea

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.

CLIENT PREFERENCES (sorted by q) SERVER CAN SERVE
server serves: en-US · fr · de · ja  (default en-US)
Press play to walk down the ranked list and find the first language the server can serve.

How it works

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.

Signals & trade-offs

ApproachEffectWatch
Strict exact matchPredictable, never guesses a regionen never satisfied by en-US — more fallbacks
Language-range matchGeneric en served by any en-*Region nuance (en-GB vs en-US) is lost
Trust client headerZero-config, works for most visitorsHeader reflects the OS, not the human reading
Explicit user settingHonours the actual choiceNeeds UI + persistence; header is the seed
Vary: Accept-LanguageCDN caches one copy per languageOmit it and the cache serves the wrong language

Watch out for

Worked example

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.

Check yourself

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?