Parsing Accept & quality values
Ranking types by q-value, breaking ties by specificity, and respecting q=0. The parsing rules every content-negotiating server needs to get right.
The Accept header is a structured list of preferences, not a string.
Implementing negotiation with includes() or startsWith() gets wrong
answers on real browsers. Here’s what a correct parser does.
The input
Accept: text/markdown, text/html;q=0.8, */*;q=0.1
Split into entries on ,. Each entry has:
- A type (possibly with a wildcard, like
text/*or*/*) - Zero or more parameters, separated by
; - A quality factor
q(default 1 if absent), ranging 0 to 1
The ranking rules
- Sort by q descending. Higher-q types are preferred.
- Break ties by specificity. Within the same q, a fully specified
type (
text/markdown) beats a subtype wildcard (text/*), which beats the catch-all*/*. - Respect
q=0. It explicitly means don’t send me this. Never choose a type the client markedq=0, even if it’s your only supported representation — return406 Not Acceptableinstead.
The algorithm
Given:
- A list of types the client accepts (from the header)
- A list of types your server can produce
Return the type you should serve, or “none” (→ 406).
for each type you can produce:
find the best-matching accept entry
(exact type match > text/* match > */* match)
the score is that entry's q-value
(0 if no match, or if matched entry had q=0)
pick the type with the highest score
if max score is 0, return 406
What libraries do for you
Every mature stack has an Accept parser:
- Node: the
acceptspackage (what Express uses), ornegotiator - Flask / Werkzeug:
request.accept_mimetypes.best_match([...]) - Django:
request.accepted_types(4.2+), orrequest.get_preferred_type([...])(5.2+) - Ruby: Rails’
respond_tohandles it; raw access viaRack::Utils.q_values - PHP: Symfony’s HttpFoundation
Request::getAcceptableContentTypes - Go:
github.com/golang/gddo/httputilhasNegotiateContentType
Use these. Writing your own parser is how you end up with a bug where
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
(a real Chrome header) accidentally matches your “Markdown” branch.
Gotchas
- Missing
Acceptheader: means no constraint. Serve your default (usually HTML). It is not the same as an emptyAccept. Accept: */*: means anything is fine. Also serve your default.Accept: text/markdown;q=0: means anything but Markdown. Your server should serve HTML (or whatever else), not 406.- Whitespace and case:
Acceptvalues are case-insensitive for type names and parameter names. Parameter values are not, in general, butcharset=utf-8is fine either case.
Test vectors
Quick sanity checks your parser should pass:
| Accept | Server produces | Should serve |
|---|---|---|
text/markdown | md, html | markdown |
text/markdown, text/html;q=0.8 | md, html | markdown |
text/html | md, html | html |
text/markdown;q=0, text/html | md, html | html |
text/markdown;q=0 | md only | 406 |
| (no Accept) | md, html | html (default) |
*/* | md, html | html (default) |