A lightweight, zero-dependency Elixir library for parsing the Accept-Language HTTP header field.
This implementation conforms to:
- RFC 7231 Section 5.3.5 — Accept-Language header field definition
- RFC 7231 Section 5.3.1 — Quality values syntax
- RFC 4647 Section 3.3.1 — Basic Filtering matching scheme
- BCP 47 — Tags for Identifying Languages
Note RFC 7231 obsoletes RFC 2616 (the original HTTP/1.1 specification). The
Accept-Languageheader behavior defined in RFC 2616 Section 14.4 remains unchanged in RFC 7231, ensuring full backward compatibility.
Add accept_language to your list of dependencies in mix.exs:
def deps do
[
{:accept_language, "~> 0.1.0"}
]
endAcceptLanguage.negotiate("da, en-GB;q=0.8, en;q=0.7", [:en, :da])
# => :daQuality values (q-values) indicate relative preference, ranging from 0 (not acceptable) to 1 (most preferred). When omitted, the default is 1.
Per RFC 7231 Section 5.3.1, valid q-values have at most three decimal places: 0, 0.7, 0.85, 1.000. Invalid q-values cause the associated language range to be ignored.
AcceptLanguage.negotiate("da, en-GB;q=0.8, en;q=0.7", [:en, :da])
# => :da (q=1 beats q=0.8)
AcceptLanguage.negotiate("da, en-GB;q=0.8, en;q=0.7", [:en, :"en-GB"])
# => :"en-GB" (q=0.8 beats q=0.7)
AcceptLanguage.negotiate("da, en-GB;q=0.8, en;q=0.7", [:ja])
# => nil (no match)When multiple languages share the same q-value, declaration order in the header determines priority—the first declared language wins:
AcceptLanguage.negotiate("en;q=0.8, fr;q=0.8", [:en, :fr])
# => :en (declared first)
AcceptLanguage.negotiate("fr;q=0.8, en;q=0.8", [:en, :fr])
# => :fr (declared first)This library implements the Basic Filtering matching scheme defined in RFC 4647 Section 3.3.1. A language range matches a language tag if, in a case-insensitive comparison, it exactly equals the tag, or if it exactly equals a prefix of the tag such that the first character following the prefix is -.
AcceptLanguage.negotiate("de-de", [:"de-DE-1996"])
# => :"de-DE-1996" (prefix match)
AcceptLanguage.negotiate("de-de", [:"de-Deva"])
# => nil ("de-de" is not a prefix of "de-Deva")
AcceptLanguage.negotiate("de-de", [:"de-Latn-DE"])
# => nil ("de-de" is not a prefix of "de-Latn-DE")Prefix matching respects hyphen boundaries:
AcceptLanguage.negotiate("zh", [:"zh-TW"])
# => :"zh-TW" ("zh" matches "zh-TW")
AcceptLanguage.negotiate("zh", [:zhx])
# => nil ("zh" does not match "zhx" — different language code)
AcceptLanguage.negotiate("zh-TW", [:zh])
# => nil (more specific range does not match less specific tag)The wildcard * matches any language not matched by another range in the header. This behavior is specific to HTTP, as noted in RFC 4647 Section 3.3.1.
AcceptLanguage.negotiate("de, *;q=0.5", [:ja])
# => :ja (matched by wildcard)
AcceptLanguage.negotiate("de, *;q=0.5", [:de, :ja])
# => :de (explicit match takes precedence)A q-value of 0 explicitly marks a language as not acceptable:
AcceptLanguage.negotiate("*, en;q=0", [:en])
# => nil (English explicitly excluded)
AcceptLanguage.negotiate("*, en;q=0", [:ja])
# => :ja (Japanese matched by wildcard)Exclusions apply via prefix matching:
AcceptLanguage.negotiate("*, en;q=0", [:"en-GB"])
# => nil (en-GB excluded via "en" prefix)Matching is case-insensitive per RFC 4647 Section 2, but the original case of available language tags is preserved in the return value:
AcceptLanguage.negotiate("EN-GB", [:"en-gb"])
# => :"en-gb"
AcceptLanguage.negotiate("en-gb", [:"EN-GB"])
# => :"EN-GB"To prevent denial-of-service via adversarial headers, the parser enforces two limits:
- Field size: headers exceeding 4096 bytes are treated as absent (returns
nil) - Range count: at most 50 language ranges are processed; any beyond this are silently discarded
These thresholds are well above real-world usage (browsers typically send 2–10 ranges in under 200 bytes) and should not affect legitimate traffic.
Full support for BCP 47 language tags including script subtags, region subtags, and variant subtags:
# Script subtags
AcceptLanguage.negotiate("zh-Hant", [:"zh-Hant-TW", :"zh-Hans-CN"])
# => :"zh-Hant-TW"
# Variant subtags
AcceptLanguage.negotiate("de-1996, de;q=0.9", [:"de-CH-1996", :"de-CH"])
# => :"de-CH-1996"Create a module plug that negotiates the locale from the Accept-Language
header and configures Gettext for the current request.
Place the file in lib/my_app_web/plugs/locale.ex:
defmodule MyAppWeb.Plugs.Locale do
import Plug.Conn
@locales Gettext.known_locales(MyAppWeb.Gettext) |> Enum.map(&String.to_atom/1)
def init(default), do: default
def call(conn, default) do
locale =
conn
|> get_req_header("accept-language")
|> List.first()
|> negotiate(default)
Gettext.put_locale(MyAppWeb.Gettext, Atom.to_string(locale))
assign(conn, :locale, Atom.to_string(locale))
end
defp negotiate(nil, default), do: default
defp negotiate(header, default) do
case AcceptLanguage.negotiate(header, @locales) do
nil -> default
locale -> locale
end
end
endThen add it to the :browser pipeline in lib/my_app_web/router.ex:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug MyAppWeb.Plugs.Locale, :en
endThe default locale (:en) is passed as argument and used when the
header is absent or matches none of the known locales. The list of
supported locales is derived from Gettext at compile time, so adding
a new translation automatically makes it available for negotiation.
Note: This plug does not depend on Phoenix — it uses only
Plug.Connand can be used in any Plug-based application. Remove theGettext.put_locale/2call if your application does not use Gettext.
| Specification | Description | Status |
|---|---|---|
| RFC 7231 §5.3.5 | Accept-Language header field | ✅ Supported |
| RFC 7231 §5.3.1 | Quality values (qvalues) | ✅ Supported |
| RFC 4647 §2.1 | Basic Language Range syntax | ✅ Supported |
| RFC 4647 §3.3.1 | Basic Filtering scheme | ✅ Supported |
| RFC 7230 §3.2.3 | OWS (optional whitespace) handling | ✅ Supported |
| BCP 47 | Language tag structure | ✅ Supported |
| Specification | Description | Reason |
|---|---|---|
| RFC 4647 §2.2 | Extended Language Range | Not used by HTTP |
| RFC 4647 §3.3.2 | Extended Filtering | Not used by HTTP |
| RFC 4647 §3.4 | Lookup scheme | Design choice — Basic Filtering is appropriate for HTTP content negotiation |
- API documentation on HexDocs
- RFC 7231 — HTTP/1.1 Semantics and Content
- RFC 4647 — Matching of Language Tags
- BCP 47 — Tags for Identifying Languages
- accept_language.rb — Ruby equivalent of this library
This library follows Semantic Versioning 2.0.
Available as open source under the terms of the MIT License.