JSTL i18n & Messages
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against the Jakarta Standard Tag Library (Jakarta Tags) 3.0, chapter 8 "Internationalization (i18n)
Actions" and the configuration data of chapter 2, together with Jakarta Pages 4.0, Jakarta Expression Language 6.0 and
Jakarta Servlet 6.1 (Jakarta EE 11), as implemented by Apache Tomcat 11.0.26 on Java 21 with the Tags implementation
org.glassfish.web:jakarta.servlet.jsp.jstl 3.0.1 and the API jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api
3.0.2. Everything was verified on 2026-10-08: the rules against the specification and the Javadoc, and every example by
building it into a Maven WAR and running it on Tomcat 11.0.26 with curl; the outputs shown are the outputs of those
runs. The fmt: library is the part of JSTL that lets a page keep its words out of its markup: labels, messages and
sentences live in resource bundles, one per language, and the page asks for them by key. This page covers the actions
that choose a language and fetch text (fmt:setLocale, fmt:bundle, fmt:setBundle, fmt:message, fmt:param,
fmt:requestEncoding), the algorithm that picks a bundle for a visitor, and the configuration settings that steer it.
Numbers, currencies, dates and time zones use the same locale machinery but belong to the JSTL Formatting page of this
section. The running scenario is the Bookshelf, now in
English and Spanish.
Status: Archived Tutorial, Current Library
The fmt: library is not deprecated and not removed: Tags 3.0 is the current version of the specification, it ships in
Jakarta EE 10 and 11, and its implementation is the same one Tomcat, Jetty and GlassFish applications load today. What is
archived is the teaching material. The Jakarta EE Tutorial moved its Jakarta Pages and Jakarta Tags chapters to an
"Archived" area, and its remaining chapter on internationalization, "Internationalizing and Localizing Web
Applications", is written around Jakarta Faces (faces-config.xml, f:loadBundle) and was last updated for Jakarta EE
9.1; the only JSTL content in it is one sentence saying that a component can set the locale with fmt:setLocale. The
last complete official tutorial for the JSP flavour, the Java EE 5 Tutorial, uses javax.* names. The consequence is
that the specification chapter and the Javadoc of jakarta.servlet.jsp.jstl.fmt are the primary sources, and they are
what this page follows.
| Piece | Where it is defined | Status on Jakarta EE 11 |
|---|---|---|
|
Tags 3.0, chapter 8 |
Current, unchanged since JSTL 1.0 apart from the package and URI rename |
|
Tags 3.0 Javadoc, package |
Current |
Resource bundles ( |
Java SE 21, |
Current; |
Parameter substitution and plural forms |
|
Current; the substitution engine behind |
Page character encoding, response and request encoding |
Pages 4.0 chapter 4, Servlet 6.1 |
See the Character Encoding page |
Number, date and time zone formatting |
Tags 3.0, chapter 9 |
Covered by the formatting page; uses the locale chosen here |
Faces localization |
Jakarta Faces and the archived-era tutorial chapter |
Out of scope here |
Locale, Bundle, Basename and Key
The specification builds the library on four ideas, all taken from java.util.
-
A locale is a language, optionally with a country and a variant. The language is the lower-case two-letter ISO-639 code (
es,fr), the country the upper-case two-letter ISO-3166 code (ES,CA). The semantics are those ofjava.util.Locale, so the legacy codes apply:he,yiandidare reported asiw,jiandinbyLocaleunless the JVM propertyjava.locale.useOldISOCodessays otherwise. -
A resource bundle holds locale-specific objects, here strings, each associated with a key.
-
A basename identifies a family of bundles that translate the same keys. The bundle for a locale is the basename plus a suffix:
Messagesis the root bundle,Messages_esthe Spanish one,Messages_es_ESthe Spanish of Spain. -
A localization context is the pair the actions actually work with: a resource bundle and the locale for which it was found. It is the Java class
jakarta.servlet.jsp.jstl.fmt.LocalizationContext.
The specification names two ways of internationalizing a page. The first is one page per locale with a controller
servlet that dispatches to the right one, which suits pages where most of the content differs. The second isolates the
locale-sensitive text in bundles and fetches it with the fmt: actions, which suits everything else and is what the
rest of this page is about. The two combine: a controller can pick a page variant per locale and each variant still
uses bundles for its labels.
The Bookshelf Used on This Page
All the examples are files of one Maven WAR, g5jstli18n.war, deployed under the context path /g5jstli18n on Tomcat
11.0.26 (plus one variant that differs only in web.xml, named where it appears). The WAR has the dependencies of the
Getting Started page of this section: provided Servlet, Pages and EL APIs, plus the Jakarta Tags API 3.0.2 and its
implementation 3.0.1. One servlet, DataServlet, mapped to /data/*, puts a list of Book records in the request
and forwards to /WEB-INF/e/<name>.jsp, so /data/basic runs /WEB-INF/e/basic.jsp. Every request below was made
with curl and an explicit Accept-Language header, because that header is what a browser uses to say which languages
its user reads; the header is set with -H 'Accept-Language: es'.
The bundles are plain .properties files in src/main/resources/org/example/bookshelf/i18n/. They are saved as UTF-8
and contain the accented characters directly; Java 9 and later read them as UTF-8 (see the pitfalls section).
Messages.propertiesshelf.title=My Bookshelf
shelf.count=You have {0} books on the shelf
shelf.summary={0} was written by {1} in {2}
shelf.summary.year={0} was written by {1} in {2,number,#}
form.label=Title
form.search=Search
com.example.labels.author=Author
com.example.labels.year=Year
Messages_es.propertiesshelf.title=Mi estantería
shelf.count=Tienes {0} libros en la estantería
shelf.summary={0} fue escrito por {1} en {2}
shelf.summary.year={0} fue escrito por {1} en {2,number,#}
form.label=Título
form.search=Buscar
com.example.labels.author=Autor
com.example.labels.year=Año
@WebServlet("/data/*")
public class DataServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
List<Book> books = List.of(
new Book(1, "Dune", "Frank Herbert", 1965),
new Book(2, "Cien años de soledad", "Gabriel García Márquez", 1967),
new Book(3, "Neuromancer", "William Gibson", 1984));
req.setAttribute("books", books);
req.getRequestDispatcher("/WEB-INF/e" + req.getPathInfo() + ".jsp").forward(req, resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req, resp);
}
}
Declaring the Library
The i18n actions live in the fmt library with the URI jakarta.tags.fmt (Tags 3.0 renamed the URIs from
http://java.sun.com/jsp/jstl/ to jakarta.tags.). The prefix fmt is a convention, not a rule.
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
The old URIs still work with the 3.0.1 implementation because the jar keeps the older TLD files (fmt-1_0.tld,
fmt-1_0-rt.tld, fmt-1_1.tld, fmt.tld): uri="http://java.sun.com/jsp/jstl/fmt" and the JSTL 1.0 URI
uri="http://java.sun.com/jstl/fmt" both rendered the Bookshelf title on Tomcat 11.0.26. The URI of the JSF-era
namespace, http://xmlns.jcp.org/jsp/jstl/fmt, is not one of them and fails with JasperException: The absolute uri:
[http://xmlns.jcp.org/jsp/jstl/fmt] cannot be resolved in either web.xml or the jar files deployed with this application.
New code should use jakarta.tags.fmt.
The First Message: fmt:setBundle and fmt:message
The smallest internationalized page names a bundle once and then asks for keys. fmt:setBundle without a var makes
the bundle the default localization context of the page, and fmt:message reads its key from that default.
basic.jsp<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<fmt:setBundle basename="org.example.bookshelf.i18n.Messages"/>
<h1><fmt:message key="shelf.title"/></h1>
<p><fmt:message key="shelf.count"><fmt:param value="${books.size()}"/></fmt:message></p>
<ul>
<c:forEach var="b" items="${books}">
<li><c:out value="${b.title}"/></li>
</c:forEach>
</ul>
With Accept-Language: es the page answers with the Spanish bundle, and with no Accept-Language header at all it
answers with the root bundle:
<!-- curl -H 'Accept-Language: es' /g5jstli18n/data/basic -->
<h1>Mi estantería</h1>
<p>Tienes 3 libros en la estantería</p>
<!-- curl /g5jstli18n/data/basic -->
<h1>My Bookshelf</h1>
<p>You have 3 books on the shelf</p>
The response headers are part of the result. For the Spanish request Tomcat answered Content-Language: es and
Content-Type: text/html;charset=UTF-8; for a request with Accept-Language: en-GB it answered Content-Language:
en-GB; for a request without the header there was no Content-Language at all. The actions set the response locale as
a side effect, which is the subject of the response section below.
The book titles are printed with c:out, never as a bare ${b.title}, because a title is data and not a message:
bundles hold the application’s own text, and the data around it still has to be escaped (the security section shows why
fmt:message is not an escaping tag).
fmt:message and fmt:param Reference
fmt:message looks a key up in the bundle of the localization context it determines and writes the text to the current
JspWriter, or stores it in a scoped variable when var is given. It has three syntaxes: the key in an attribute and no
body, the key in an attribute with fmt:param children, and the key as the (trimmed) body, optionally followed by
fmt:param children.
| Attribute | Dynamic | Type | Meaning |
|---|---|---|---|
|
yes |
|
The message key to look up. If it is absent, the trimmed body is the key |
|
yes |
|
The localization context to read the key from; wins over everything else |
|
no |
|
Name of the scoped variable that receives the message instead of printing it |
|
no |
|
Scope of |
| Attribute | Dynamic | Type | Meaning |
|---|---|---|---|
|
yes |
|
The argument; when it is absent, the trimmed body content is the argument |
The error handling is specified and was observed:
| Situation | Specified result | Observed output (Tomcat 11.0.26) |
|---|---|---|
The key is not in the bundle |
The text |
|
The key is null or empty |
|
|
No resource bundle can be found for the context |
|
|
|
Constraint of the tag |
Translation fails with |
|
Constraint of the tag |
Translation fails with |
|
Type mismatch |
|
None of these failures is an exception at request time except the translation-time ones, so a missing key is visible
only as ??? in the page. A page test that greps for ??? catches most of them.
bundle.jsp<fmt:setBundle basename="org.example.bookshelf.i18n.Messages" var="msgs"/>
<p><fmt:message key="shelf.title" bundle="${msgs}"/></p>
<fmt:message key="shelf.title" bundle="${msgs}" var="t" scope="request"/>
<p>stored: ${requestScope.t}</p>
<p>body key: <fmt:message bundle="${msgs}"> shelf.title </fmt:message></p>
<p>missing: <fmt:message key="no.such.key" bundle="${msgs}"/></p>
<p>empty key: <fmt:message key="" bundle="${msgs}"/></p>
<p>Mi estantería</p>
<p>stored: Mi estantería</p>
<p>body key: Mi estantería</p>
<p>missing: ???no.such.key???</p>
<p>empty key: ??????</p>
Parameters and MessageFormat
A message that contains variables is a java.text.MessageFormat pattern, and fmt:param supplies the arguments in the
order of the tags. The specification states the mechanism exactly: with one or more fmt:param tags the message is
passed to MessageFormat.applyPattern() and the values are collected in an Object[] for format(), with the
MessageFormat locale set to the locale of the localization context (or, when the context has no locale, to the
formatting locale of the page, or finally to the JVM default). Without any fmt:param the message is left untouched and
MessageFormat is not used at all. That second rule explains several outputs below.
params.jsp<fmt:setBundle basename="org.example.bookshelf.i18n.Messages"/>
<c:set var="b" value="${books[0]}"/>
<p><fmt:message key="shelf.summary"><fmt:param value="${b.title}"/><fmt:param value="${b.author}"/><fmt:param value="${b.year}"/></fmt:message></p>
<p><fmt:message key="shelf.summary.year"><fmt:param value="${b.title}"/><fmt:param value="${b.author}"/><fmt:param value="${b.year}"/></fmt:message></p>
<p><fmt:message key="shelf.copies"><fmt:param value="${0}"/><fmt:param value="${b.title}"/></fmt:message></p>
<p><fmt:message key="shelf.copies"><fmt:param value="${1}"/><fmt:param value="${b.title}"/></fmt:message></p>
<p><fmt:message key="shelf.copies"><fmt:param value="${1234}"/><fmt:param value="${b.title}"/></fmt:message></p>
<p><fmt:message key="shelf.count"><fmt:param><fmt:formatNumber value="${12345}"/></fmt:param></fmt:message></p>
<jsp:useBean id="now" class="java.util.Date"/>
<p><fmt:message key="shelf.due"><fmt:param value="${now}"/></fmt:message></p>
with these extra keys in Messages.properties (the Spanish file has the same keys translated):
shelf.copies=There {0,choice,0#are no copies|1#is one copy|1<are {0,number,integer} copies} of {1}
shelf.due=Due on {0,date,long}
Accept-Language: en and with Accept-Language: es<p>Dune was written by Frank Herbert in 1,965</p>
<p>Dune was written by Frank Herbert in 1965</p>
<p>There are no copies of Dune</p>
<p>There is one copy of Dune</p>
<p>There are 1,234 copies of Dune</p>
<p>You have 12,345 books on the shelf</p>
<p>Due on October 8, 2026</p>
<p>Dune fue escrito por Frank Herbert en 1.965</p>
<p>Dune fue escrito por Frank Herbert en 1965</p>
<p>Hay ninguna copia de Dune</p>
<p>Hay una copia de Dune</p>
<p>Hay 1.234 copias de Dune</p>
<p>Tienes 12.345 libros en la estantería</p>
<p>Vence el 8 de octubre de 2026</p>
Four lessons are visible in this output.
-
A number argument is formatted by
MessageFormatwith the grouping of the locale. The year 1965 printed as1,965in English and1.965in Spanish. The pattern{2,number,#}(aDecimalFormatpattern without grouping) prints1965in both. A year, an id or a postal code is not a quantity and needs that pattern, or aStringargument. -
The
choiceformat gives plural forms (0#...|1#...|1<...), and the number inside it can use its own sub-format ({0,number,integer}printed1,234). Spanish shows that the plural words are the translator’s business: the Spanish line reads awkwardly (Hay ninguna copia, where a translator would writeNo hay ninguna copia), which is the argument for putting the whole sentence, not a fragment, in the bundle and letting the translator own it. Plural rules of languages with more than two forms are beyondchoice; ICU4J’sMessageFormatis the usual answer when they are needed. -
A date argument can be given a style (
{0,date,long}) and is then localized by the same locale:October 8, 2026against8 de octubre de 2026. -
fmt:paramaccepts a body, so another action can compute the argument.fmt:formatNumberinside thefmt:paramprinted12,345and12.345, using the same formatting locale as the message.
The apostrophe trap
MessageFormat uses the single quote to quote text, so an apostrophe in a message that is formatted has to be doubled,
and an apostrophe in a message that is not formatted must not be. Because fmt:message calls MessageFormat only when
there are fmt:param children, the same text needs different spelling in the two cases. The bundle for this example
adds three keys:
shelf.apostrophe.plain=Don't panic
shelf.apostrophe.param=Don''t panic about {0}
shelf.apostrophe.broken=Don't panic about {0}
apostrophe.jsp and apos2.jsp<p>1: <fmt:message key="shelf.apostrophe.plain"/></p>
<p>2: <fmt:message key="shelf.apostrophe.param"><fmt:param value="Dune"/></fmt:message></p>
<p>3: <fmt:message key="shelf.apostrophe.broken"><fmt:param value="Dune"/></fmt:message></p>
<p>4: <fmt:message key="shelf.apostrophe.broken"/></p>
<p>5: <fmt:message key="shelf.apostrophe.param"/></p>
<p>1: Don't panic</p>
<p>2: Don't panic about Dune</p>
<p>3: Dont panic about {0}</p>
<p>4: Don't panic about {0}</p>
<p>5: Don''t panic about {0}</p>
Line 3 is the failure: the single quote started a quoted section, so the apostrophe vanished and {0} was printed
literally. Line 5 is the mirror image: the doubled apostrophe shows doubled when the message is not formatted. The rule
that works is to double the apostrophe in every message that is ever used with a fmt:param, and to keep a message
either always with parameters or never. A message whose parameter count may be zero should be given a dummy parameter or
should be rewritten, because the same bundle line cannot satisfy both spellings.
Bundles and the Localization Context
An i18n action needs a localization context, and the specification fixes where it comes from. The first rule that applies wins.
| Order | Source | Example |
|---|---|---|
1 |
The |
|
2 |
The innermost enclosing |
|
3 |
The default localization context, the configuration setting |
|
If the default setting is a LocalizationContext, its bundle is used as it is. If it is a String, the action builds its
own context with that string as the basename, using the lookup algorithm of the next section.
fmt:bundle and its prefix
fmt:bundle creates a localization context for its body: every fmt:message nested inside it, at any depth of markup,
uses it (unless the message has its own bundle attribute). The prefix attribute is prepended to the key of every
nested fmt:message, which saves typing for long, dotted keys. The body of fmt:bundle is processed normally and
written to the page.
| Action | Attribute | Dynamic | Type | Meaning |
|---|---|---|---|---|
|
|
yes |
|
The fully qualified resource name of the bundle, in class-name form with |
|
|
yes |
|
Text prepended to the key of every nested |
|
|
yes |
|
As above |
|
|
no |
|
Scoped variable that receives the |
|
|
no |
|
Scope of |
bundle.jsp<fmt:bundle basename="org.example.bookshelf.i18n.Messages" prefix="com.example.labels.">
<p><fmt:message key="author"/>, <fmt:message key="year"/></p>
</fmt:bundle>
<p>Author, Year</p> <!-- no Accept-Language -->
<p>Autor, Año</p> <!-- Accept-Language: es -->
If basename is null or empty, or no bundle is found, the context holds the null bundle, and every fmt:message then
prints ???key???; there is no error.
fmt:setBundle: a variable or the default
fmt:setBundle with a var stores a LocalizationContext that other tags can pass in their bundle attribute. This is
the way to use two bundles on one page, for instance the application’s messages and a separate bundle of error texts,
without nesting fmt:bundle blocks. Without var, the context becomes the page’s default (or, with scope="session"
or scope="application", the default of a wider scope).
fmt:setBundle<%-- 1. as a variable, used explicitly --%>
<fmt:setBundle basename="org.example.bookshelf.i18n.Errors" var="errors"/>
<fmt:message key="book.notFound" bundle="${errors}"/>
<%-- 2. as the default of the page, session or application --%>
<fmt:setBundle basename="org.example.bookshelf.i18n.Messages" scope="application"/>
The default bundle for a whole application
Writing fmt:setBundle at the top of every page is repetitive, and the specification gives a declarative alternative:
the context-parameter jakarta.servlet.jsp.jstl.fmt.localizationContext in web.xml, whose string value is a basename.
Pages then use fmt:message with no bundle declaration at all.
web.xml (the g5jstli18nb variant)<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_1.xsd"
version="6.1">
<context-param>
<param-name>jakarta.servlet.jsp.jstl.fmt.localizationContext</param-name>
<param-value>org.example.bookshelf.i18n.Plain</param-value>
</context-param>
<context-param>
<param-name>jakarta.servlet.jsp.jstl.fmt.fallbackLocale</param-name>
<param-value>es</param-value>
</context-param>
</web-app>
ctx.jsp<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<h1><fmt:message key="greeting"/></h1>
<!-- variant WAR, Accept-Language: es --> <h1>Hola desde el paquete en español</h1>
<!-- variant WAR, Accept-Language: en-GB --> <h1>Hello from the English bundle</h1>
<!-- the base WAR, which has no context-param --> <h1>???greeting???</h1>
The last line shows the other side: without a fmt:setBundle, a fmt:bundle or the context-parameter, there is no
default context, so the key is printed in question marks. The context-parameter holds only String values (the
specification says so); an object context can be set per scope with fmt:setBundle or Config.set.
Choosing the Locale
The locale that picks the bundle (and, for the formatting actions, the number and date style) comes from one of two places, and the specification gives the first priority.
-
Application-based. The configuration setting
jakarta.servlet.jsp.jstl.fmt.locale(the constantConfig.FMT_LOCALE) holds a locale chosen by the application. It is set byfmt:setLocale, byConfig.setfrom Java code, or by acontext-paramof the same name. It is the right choice when the user picks a language in the interface, or when the language is stored in the user’s profile. -
Browser-based. If the setting is absent, the actions call
ServletRequest.getLocales(), the ordered list built from theAccept-Languageheader, and try the locales in that order.
Either way an ordered list of preferred locales is fed to the bundle algorithm. One detail of the implementation matters
in tests: the Util.getRequestLocales of the 3.0.1 implementation returns the locales of the request only when the
request has an Accept-Language header. Without the header the list is empty, even though request.getLocales() alone
would return the server’s default locale (it printed en_US in the test). A curl without the header therefore behaves
like a visitor whose browser expresses no preference, and falls to the fallback locale or the root bundle.
fmt:setLocale
| Attribute | Dynamic | Type | Meaning |
|---|---|---|---|
|
yes |
|
A string with a lower-case language and optionally an upper-case country, separated by |
|
yes |
|
A vendor or browser variant; ignored when |
|
no |
|
Scope of the configuration variable: |
fmt:setLocale stores the locale in the configuration variable of the requested scope and thereby switches off
browser-based selection. The specification asks for it to be declared at the beginning of the page, before any other
i18n-capable action, because the actions that run earlier have already chosen their locale.
setlocale.jsp<fmt:setLocale value="es"/>
<fmt:setBundle basename="org.example.bookshelf.i18n.Messages"/>
<p><fmt:message key="shelf.title"/> -- <c:out value="${pageContext.response.locale}"/></p>
<p>Mi estantería -- es</p> <!-- Accept-Language: en, still Spanish -->
The fixed locale overrides the browser: the request with Accept-Language: en was answered in Spanish with
Content-Language: es. Further observed results on Tomcat 11.0.26:
| Input | Result |
|---|---|
|
The locale |
|
|
|
The JVM default locale, |
|
Accepted without any error: the response locale printed |
The last row is the reason to validate a locale before it reaches the tag. fmt:setLocale value="${param.lang}" accepts
anything the visitor sends, and the response locale (and the Content-Language header) follow it. The safe design is a
small allowlist of the application’s own languages in a servlet or filter.
A language switcher with an allowlist and Config
A language selector should not be a request parameter read by the tag. The pattern that scales is a servlet that
validates the choice and stores it in the session with the Config class, after which every page of the session uses it.
Config.set(session, Config.FMT_LOCALE, locale) is the Java equivalent of fmt:setLocale scope="session".
LanguageServlet@WebServlet("/language")
public class LanguageServlet extends HttpServlet {
private static final Set<String> SUPPORTED = Set.of("en", "es");
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String tag = req.getParameter("lang");
if (tag != null && SUPPORTED.contains(tag)) {
Config.set(req.getSession(), Config.FMT_LOCALE, Locale.forLanguageTag(tag));
}
resp.sendRedirect(req.getContextPath() + "/data/shelf");
}
}
shelf.jsp<fmt:setBundle basename="org.example.bookshelf.i18n.Messages"/>
<h1><fmt:message key="shelf.title"/></h1>
<p><fmt:message key="shelf.count"><fmt:param value="${books.size()}"/></fmt:message></p>
<form method="post" action="${pageContext.request.contextPath}/language">
<button name="lang" value="en">English</button>
<button name="lang" value="es">Español</button>
</form>
The sequence below was run with a cookie jar so that the session persisted, and with Accept-Language: en on every GET:
GET /data/shelf -> <h1>My Bookshelf</h1>
POST /language lang=es -> 302, Location: /g5jstli18n/data/shelf
GET /data/shelf -> Content-Language: es <h1>Mi estantería</h1>
POST /language lang=xx -> 302 (ignored, not in the allowlist)
GET /data/shelf -> <h1>Mi estantería</h1>
The switch is a POST because it changes state of the session, and the redirect after it follows the
Post/Redirect/Get pattern. A cookie that remembers the choice across sessions is a separate decision (it is a preference
cookie, with the privacy considerations of any cookie) and is not part of the specification.
The Bundle Lookup and Fallback Algorithm
Given a basename and the ordered preferred locales, the specification defines the resource bundle for a localization context in two parts.
The lookup of one locale uses ResourceBundle.getBundle(basename, locale) but only its first level: the candidates are
basename_language_country_variant, basename_language_country and basename_language. The JDK’s later steps (the
JVM default locale, then the root bundle) are explicitly removed so that other locales can be considered first.
The determination of the bundle then runs these steps and stops at the first success:
-
For each preferred locale, in order, do the lookup. The first match wins and the context stores that locale and bundle.
-
If none matched, do the lookup for the locale in the
jakarta.servlet.jsp.jstl.fmt.fallbackLocalesetting. -
If still nothing, load the root bundle (
basenamealone). If it exists it is used and the context has no locale. -
Otherwise the context has neither bundle nor locale and the action must take corrective action (
fmt:messageprints???key???).
Two consequences are stated in the specification and are easy to get wrong.
-
A language match beats an exact match further down the list. With the browser preferences
enthenfr_CAand the bundlesMessages_enandMessages_fr_CA, the bundle used isMessages_en, because the lookup forensucceeds beforefr_CAis tried. -
A bundle for a language and country (
Resources_fr_CA) should be backed by one for the language alone (Resources_fr). A visitor whose browser asks only forfrfinds nothing inResources_fr_CA: the lookup for the localefrdoes not look atfr_CA.
The algorithm on Tomcat 11, observed
To see the algorithm work, four families of bundles were created. Plain has Plain_en and Plain_es and no root.
Region has Region_en and Region_fr_CA. Canada has Canada_en and Canada_fr_CA. Each is used by a page that
prints one greeting and the response locale:
<fmt:bundle basename="org.example.bookshelf.i18n.Plain"><fmt:message key="greeting"/> | ${pageContext.response.locale}</fmt:bundle>
| Bundles and request | Result | Reading |
|---|---|---|
|
|
The language match beats the later exact match, as in the specification |
|
|
The exact match first |
|
|
|
|
|
The lookup for |
|
|
No |
|
|
See the next paragraph |
|
|
The fallback locale applies |
|
|
An empty preference list goes straight to the fallback locale |
|
|
The root bundle, used because |
The last-resort steps carry a surprise on the 3.0.1 implementation. For Plain with Accept-Language: de, there is no
German bundle, no fallback locale and no root bundle, so the specification says the result is a context without a bundle
and ???greeting???. The page printed the English greeting instead. Inspecting the bytecode of the implementation’s BundleSupport class shows that its last step is
ResourceBundle.getBundle(basename, <empty locale>, contextClassLoader), and the JDK’s own algorithm, when the root
bundle is missing, falls back to the JVM default locale (en_US), which finds Plain_en. The specification promises that the default
locale of the container’s JVM is never considered "because this would result in a non-portable behavior"; this
implementation lets it in through the last step. The practical guidance is the one the specification gives: always
define a fallback locale (or a root bundle) so that the outcome never depends on the JVM that happens to run the
application.
Configuration Settings
Three settings concern the i18n actions. Each is both a context-parameter name and a configuration variable name, and each
has a constant in jakarta.servlet.jsp.jstl.core.Config. A scoped configuration variable overrides the context parameter
for the scope where it is set.
Name (web.xml and variable) |
Config constant |
Type | Set by | Used by |
|---|---|---|---|---|
|
|
|
|
The i18n actions and the four formatting actions |
|
|
|
Nothing in the tag library; use |
The same actions |
|
|
|
|
|
Two more names appear in this area. jakarta.servlet.jsp.jstl.fmt.timeZone belongs to the formatting page. The scoped
variable jakarta.servlet.jsp.jstl.fmt.request.charset is set by the i18n actions in session scope and read by
fmt:requestEncoding (next sections).
Only strings can be given in web.xml. For a configuration variable the type is checked with the conversion rules of the
Expression Language, and a failure raises a JspException at run time. The specification’s reason for the Config class
is a naming trick: all scopes of a PageContext behave as one namespace, so setting a plain attribute in one scope would
override it in all, whereas Config mangles the name per scope. This leads to a pitfall that was tested:
c:set, cset.jsp<c:set var="jakarta.servlet.jsp.jstl.fmt.locale" value="es" scope="request"/>
<fmt:setBundle basename="org.example.bookshelf.i18n.Messages"/>
<p><fmt:message key="shelf.title"/></p>
With Accept-Language: en-GB the page answered <p>My Bookshelf</p>, so the plain attribute had no effect on the
locale choice, while the same request with Accept-Language: es answered in Spanish from the browser preference alone.
Use fmt:setLocale or Config.set(...), which write the name the library actually reads.
The Java Side: LocalizationContext, LocaleSupport and Config
The library exposes three classes for code that is not a JSTL tag.
| Class | Use |
|---|---|
|
The pair of |
|
Static |
|
|
Because LocalizationContext is an ordinary object in a scoped variable, Expression Language reaches into it without a tag.
EL 6.0 can also import a class, so the static LocaleSupport method is callable from an expression without a scriptlet.
el.jsp<%@ page import="jakarta.servlet.jsp.jstl.fmt.LocaleSupport" %>
<fmt:setBundle basename="org.example.bookshelf.i18n.Messages" var="msgs"/>
<p>locale: ${msgs.locale}</p>
<p>title: ${msgs.resourceBundle['shelf.title']}</p>
<p>support: ${LocaleSupport.getLocalizedMessage(pageContext, 'shelf.title', 'org.example.bookshelf.i18n.Messages')}</p>
<!-- Accept-Language: es --> <!-- Accept-Language: en-GB,fr -->
<p>locale: es</p> <p>locale: </p>
<p>title: Mi estantería</p> <p>title: My Bookshelf</p>
<p>support: Mi estantería</p> <p>support: My Bookshelf</p>
For English with a regional preference the context has no locale (the root bundle was used), so msgs.locale is empty:
test for it before using it. ${msgs.resourceBundle['key']} is useful where a message is needed inside an attribute of
a non-JSTL tag, but it skips the MessageFormat step and the ???key??? convention (a missing key raises an exception
from the bundle instead).
The Response Locale and the Request Charset
The i18n actions have a side effect that the specification spells out: an action that establishes a localization context
sets the locale of the response by calling ServletResponse.setLocale() with the context’s locale (unless the context has
no locale). fmt:setLocale always does it; fmt:bundle, fmt:setBundle, a fmt:message that establishes a context and
the formatting actions that choose a formatting locale do it too. The results of the tests above show it in the
Content-Language header: es for Spanish, en-GB for Accept-Language: en-GB.
The Servlet rule behind it is that setLocale also sets the response character encoding when no encoding has been set
explicitly by setContentType with a charset or by setCharacterEncoding; on a stock Tomcat 11 the two
response-character-encoding elements of conf/web.xml already say UTF-8, and the pages above also declare
charset=UTF-8, so the charset stayed UTF-8 in every response. Two conditions apply in general: setLocale must run
before the writer is obtained, which is why the page needs a large enough buffer, and an explicit charset always wins
over the locale’s. How a locale maps to a charset, and what to declare so that the result never depends on the container,
is on the Character Encoding page.
After setting the response locale, an action stores the charset of the response in the scoped variable
jakarta.servlet.jsp.jstl.fmt.request.charset in session scope, if a session exists and is valid. That variable exists
for the next action.
fmt:requestEncoding
A browser encodes the values of a form with the encoding of the page that contained the form, but most browsers do not
send a charset in the request’s Content-Type, so the container has to guess how to decode the parameters. The guess
is ISO-8859-1 unless the container or application says otherwise. fmt:requestEncoding sets the request character
encoding by calling setCharacterEncoding and must run before any parameter is read, "either explicitly or through the
use of an Expression Language expression".
| Attribute | Dynamic | Type | Meaning |
|---|---|---|---|
|
yes |
|
The charset name to use for the parameters. If absent, the charset of the request’s |
form.jsp, search.jsp and search2.jsp<%-- form.jsp: the page that makes the charset variable available --%>
<fmt:setBundle basename="org.example.bookshelf.i18n.Messages"/>
<form method="post" action="${pageContext.request.contextPath}/data/search2">
<label><fmt:message key="form.label"/> <input name="q"></label>
<button><fmt:message key="form.search"/></button>
</form>
<p>charset var: ${sessionScope['jakarta.servlet.jsp.jstl.fmt.request.charset']}</p>
<%-- search.jsp: a fixed value --%>
<fmt:requestEncoding value="ISO-8859-1"/>
<p>q=<c:out value="${param.q}"/> length=${param.q.length()}</p>
<%-- search2.jsp: no value, so the Content-Type charset, then the session variable --%>
<fmt:requestEncoding/>
<p>q=<c:out value="${param.q}"/> length=${param.q.length()}</p>
| Request | Result |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
HTTP 400, |
|
|
Three facts follow. A fixed value is a promise about the form’s encoding and overrides what the client declares, so it
must match the page that built the form. The no-value form is meant for applications whose pages are localized
dynamically, where the form’s charset varies with the locale: it reads the charset the i18n actions saved in the session
when they served the form. And on Tomcat 10.1 and 11 the container already decodes parameters as UTF-8 when nothing else
is said (the stock conf/web.xml), which makes the action unnecessary for an all-UTF-8 application and turns an
ISO-8859-1 client into an HTTP 400 instead of silent garbage; declaring <request-character-encoding>UTF-8 in the
application’s own web.xml is the container-independent equivalent. The full ordering of encodings is on the
Character Encoding page.
Security: fmt:message Does Not Escape
Neither fmt:message nor fmt:param escape their output. The text of the bundle is trusted (the application’s own),
but a parameter that comes from the request or the database is not, and it is written into the page as markup. A message
with the visitor’s input as a parameter is therefore a cross-site scripting hole unless the parameter is escaped.
escape.jsp with ?t=<script>alert(1)</script><%@ taglib prefix="fn" uri="jakarta.tags.functions" %>
<p>1: <fmt:message key="shelf.summary"><fmt:param value="${param.t}"/><fmt:param value="x"/><fmt:param value="y"/></fmt:message></p>
<p>2: <fmt:message key="shelf.summary"><fmt:param value="${fn:escapeXml(param.t)}"/><fmt:param value="x"/><fmt:param value="y"/></fmt:message></p>
<p>3: <fmt:message key="shelf.summary"><fmt:param><c:out value="${param.t}"/></fmt:param><fmt:param value="x"/><fmt:param value="y"/></fmt:message></p>
<p>4: <fmt:message key="${param.k}"/></p>
<p>1: <script>alert(1)</script> was written by x in y</p>
<p>2: <script>alert(1)</script> was written by x in y</p>
<p>3: <script>alert(1)</script> was written by x in y</p>
<p>4: Search</p>
Line 1 is the vulnerability. Lines 2 and 3 fix it, with fn:escapeXml (a function, available since JSTL 1.1) or with a
c:out in the fmt:param body. The rule to apply uniformly is to escape every parameter that is not a constant, and
to escape the whole message when the bundle itself contains no markup and the message is used as an attribute value or
text. If a translated message legitimately contains markup, such as a link, keep that markup in the bundle and escape only
the parameters.
Line 4 is a different problem: the key came from the request, so a visitor can read any message of the bundle
(form.search was returned), which can reveal internal or unreleased text. Keys should be literals or come from the
application, never from a request parameter. The same applies to the basename of fmt:bundle and to a locale taken
from a parameter (see the table of fmt:setLocale).
Bundle files are application resources. They should live inside the classpath (WEB-INF/classes or a library), not in a
directory served by the container, and translations received from third parties are reviewed like code, because a
translation can contain markup and the tag does not escape it.
The Same Idea in Spring and Struts
fmt:message is not the only way to reach a bundle from a JSP. Spring MVC resolves the locale with a LocaleResolver
(accept-header, cookie or session, plus an interceptor that reads a request parameter) and reads messages with a
MessageSource; its JSP tag library offers spring:message for the same job as fmt:message, and the two libraries can
coexist on a page. In Struts the bundles are resolved by the framework with a different search order, and the
Localization and i18n page of the Struts part describes it. The point for a JSP author is that
the concept (locale chosen per request, text by key, parameters by position) is the same in all of them, while the bundle
search algorithm and the way a locale is selected are specific to each.
Pitfalls and Error Names
| Symptom or error | Cause | Fix |
|---|---|---|
|
The key is missing, or there is no bundle: no |
Check the basename (dots, not slashes, no |
|
The |
Check the expression that supplies the key |
Spanish text is garbled or shows |
A |
Save bundles as UTF-8 (Java 9 and later read them as UTF-8, falling back to ISO-8859-1 only for invalid sequences); declare |
The year prints as |
|
|
|
A single apostrophe in a message used with |
Double the apostrophe in messages with parameters |
|
A doubled apostrophe in a message used without |
Do not double it where |
|
|
|
The page ignores the visitor’s language after |
A fixed locale disables browser-based selection |
Set it only from a user choice; otherwise let the browser decide |
The locale does not change for the first part of the page |
|
Put it first (or in a servlet or filter with |
|
The library reads a scope-mangled name written by |
|
English text appears for a visitor who asked for German (observed) |
No root bundle and no fallback locale, so the JDK’s default-locale step of |
Define |
The French-Canadian bundle is never used (observed) |
Only |
Provide the language bundle ( |
|
|
Add |
|
|
Nest it |
|
A basename string passed to the |
Create the context with |
|
A wrong or old taglib URI such as |
|
HTTP 400 |
A form posted in ISO-8859-1 to a container that decodes UTF-8 |
Serve the form in UTF-8, or |
|
A parameter was read first (a filter, an include, an EL expression) |
Place it first or use |
Edits to a |
|
Redeploy or restart; do not rely on live reload in production |
A bundle added to |
The file is not on the classpath of the application, or the basename does not match the package |
Build output must contain |
|
The i18n actions set the response locale as a side effect |
Intentional; make sure the declared locale is accurate |
What Changed Recently
The i18n actions are stable: chapter 8 of Tags 3.0 describes the same actions as JSTL 1.0. What changed is around them.
| Version | Change | Effect for JSP authors |
|---|---|---|
Jakarta Tags 2.0 (Jakarta EE 9) |
The packages moved from |
Imports of |
Jakarta Tags 3.0 (Jakarta EE 10, Java 11) |
Tag URIs renamed to |
|
Java 9 (JEP 226) |
|
Bundles can contain accented characters directly; |
Tomcat 10.1 and 11 |
|
UTF-8 forms work without |
Jakarta EE 11 (Pages 4.0, Servlet 6.1) |
Tags stays at 3.0 |
Nothing changes in |
Jakarta Tags 3.1 (Jakarta EE 12, under development) |
The Jakarta site lists the specification as under development; Tags 3.1.0-M2 is the milestone API on Maven Central |
Per the section’s baseline, |
Where the Books Are Outdated
JSTL in Action (Manning, 2002, JSTL 1.0 on JSP 1.2) devotes a chapter to formatting and internationalization, and its
chapters on configuration and on control and performance touch the topic again. It is listed in the section
bibliography. The concepts it teaches (locale, bundle, basename, the localization context, fmt:message with parameters, the
fallback locale, fmt:requestEncoding, the Config settings) are the same today; the platform around them is not.
| The book’s era | Today (Tags 3.0, Pages 4.0) |
|---|---|
|
|
|
|
Attributes that accept only literal strings or JSTL-evaluated EL, with scriptlet expressions in the |
EL in every dynamic attribute; no scriptlets needed (and disabled in modern configuration with |
Setting the configuration names as plain scoped attributes of the page, a tempting shortcut |
|
|
UTF-8 files read natively since Java 9 |
|
Still the tool behind |
|
|
Struts 1’s |
Still valid inside those frameworks; the Localization and i18n page of the Struts part covers them |
Locale chosen by a servlet and passed to the page as a request attribute |
Same idea, with |
The EE tutorial’s JSP i18n material (J2EE 1.4, Java EE 5) |
Archived; the current tutorial’s i18n chapter is written for Jakarta Faces |
Source: Manning — JSTL in Action
References
-
Jakarta Standard Tag Library 3.0 — 8. Internationalization (i18n) Actions
-
Jakarta Standard Tag Library 3.0 — 8.2 I18n Localization Context
-
Jakarta Standard Tag Library 3.0 — 8.3.1 Resource Bundle Lookup
-
Jakarta Standard Tag Library 3.0 — 8.3.2 Resource Bundle Determination Algorithm
-
Jakarta Standard Tag Library 3.0 — 8.11 Configuration Settings
-
Jakarta Standard Tag Library 3.0 — Appendix B.2, Changed between 2.0 and JSR-52
-
Jakarta EE Tutorial — Internationalizing and Localizing Web Applications