Accept-Language header: format, q values, real examples and a parser
Accept-Language is the browser's ranked list of preferred languages. Here is what Chromium really sent, and a small parser tested on it.
1. Real values
| Browser setup | Accept-Language sent |
|---|---|
| Chromium 153, English (UK) system | en-GB,en-US;q=0.9,en;q=0.8 |
Chromium with --accept-lang=de-DE,fr | de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7 |
| Playwright default headless shell | no header at all |
Format: comma-separated language tags, each optionally followed by ;q= (0 to 1, default 1). Chromium wrote the q values itself, stepping down 0.1 per entry, and added the base language de after de-DE. In our run it also left out fr and added English, so don't assume the header mirrors a setting one-to-one. --lang=de-DE did not change the header on this Linux build; only --accept-lang did. A missing header (as from the headless shell) is common for scripts and bots: always have a default.
2. Parsing it (Python, tested)
def parse_accept_language(header):
# language tags sorted by q (highest first); q=0 means 'not acceptable'
langs = []
for i, part in enumerate((header or "").split(",")):
tag, _, params = part.strip().partition(";")
tag = tag.strip()
if not tag:
continue
q = 1.0
for p in params.split(";"):
k, _, v = p.strip().partition("=")
if k.strip().lower() == "q":
try:
q = float(v)
except ValueError:
q = 0.0
if q > 0:
langs.append((-q, i, tag))
return [tag for _, _, tag in sorted(langs)]
def pick(header, supported, default="en"):
sup = {s.lower(): s for s in supported}
for tag in parse_accept_language(header):
t = tag.lower()
if t in sup: return sup[t]
if t.split("-")[0] in sup: return sup[t.split("-")[0]]
if t == "*": return default
return default
print(pick("de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7", ["en", "de", "fr"])) # de
| Header | Ranked | pick(en, de, fr) |
|---|---|---|
fr;q=0.5, en;q=0.8 ,de | de, en, fr | de |
fr-CA;q=0, fr;q=0.9, *;q=0.1 | fr, * (fr-CA dropped: q=0 = "not acceptable") | fr |
en;q=abc,de;q=0.4 | de (invalid q ignored) | de |
| (empty or missing) | [] | en (default) |
Order in the header is not the ranking: de came first in the third row because it has the implicit q=1. Ties keep header order.
3. Using it well
- Treat it as a hint for the first visit. Let users switch language and remember the choice (cookie or URL like
/de/): someone abroad or on a shared computer gets the wrong language otherwise. - If the same URL returns different languages depending on this header, send
Vary: Accept-Languageso caches don't mix them up (what happens without Vary, measured). Separate URLs per language are better for search engines, which mostly crawl without the header. - Server-side frameworks have this built in (Spring's
AcceptHeaderLocaleResolver, Django'sLocaleMiddleware, PHP'sLocale::acceptFromHttp); the parser above is for when you need the logic yourself.
Frequently asked questions
What does q mean in Accept-Language?
Quality, 0 to 1: how much the user wants that language. No q means 1. q=0 means not acceptable, so our parser drops it.
What does Chrome send in Accept-Language?
In our test, full Chromium on an English (UK) system sent en-GB,en-US;q=0.9,en;q=0.8. Playwright's default headless shell sent no Accept-Language header at all.
Is the first language in the header always the preferred one?
No, q decides. In 'fr;q=0.5, en;q=0.8, de' the preferred language is de (implicit q=1).
How do I test my site with a different Accept-Language?
Start Chromium with --accept-lang=de-DE (that changed the header in our test; --lang did not), change the browser's language settings, or set the header with an extension such as PlainHeaders.
Written with AI assistance. Measured on 2026-10-01: a local server logged the Accept-Language header from Chromium 153 (full build, new headless, and Playwright's chrome-headless-shell) with default settings, --lang and --accept-lang. The Python parser below was run on those real headers plus edge cases. PlainHeaders is our extension; the other methods are free.