JSTL Formatting
|
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 Jakarta Standard Tag Library 3.0 (specification chapter 9, "Formatting Actions"), Jakarta
Pages 4.0, Jakarta Expression Language 6.0 and Jakarta Servlet 6.1 (Jakarta EE 11), with the Tags API
jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api 3.0.2 and its implementation
org.glassfish.web:jakarta.servlet.jsp.jstl 3.0.1, running on Apache Tomcat 11.0.26 and Java 21 (21.0.x, with its CLDR locale data). Everything was verified on 2026-10-08: the rules against the specification, the tag documentation and the Java SE
21 API documentation, and every example by building it into a Maven WAR and running it on Tomcat 11.0.26 with curl
and a small script; the outputs shown are the outputs of those runs. The formatting actions turn numbers, currencies,
percentages, dates and times into text and back, in the conventions of a locale and in the time zone of a user. They
are the part of JSTL that has changed least since 2003 and the part where the Java platform underneath has changed the
most, so most surprises come from the locale data, from the patterns of DecimalFormat and SimpleDateFormat, and from
the rules that choose the locale and the time zone, not from the tags. The page explains those rules first, then each
tag, then the problem that Tags 3.0 still cannot format java.time values and the current workarounds. The running
scenario is the Bookshelf, whose books now have a price, a rating and a publication date. Message bundles and
fmt:message are the other half of the fmt library and are covered by the JSTL i18n & Messages page of this
section; this page only uses the part of them that influences formatting.
The Formatting Actions at a Glance
The fmt library is the Jakarta Tags library whose URI is jakarta.tags.fmt (conventionally the prefix fmt). Its
actions fall in two groups: the i18n actions (fmt:setLocale, fmt:bundle, fmt:setBundle, fmt:message, fmt:param
and fmt:requestEncoding, covered by the character encoding page) and the formatting actions below. The two groups share one locale mechanism, which is why
this page keeps referring to the other one. The library is current: Tags 3.0 is the version of Jakarta EE 10 and 11, and
nothing in the Jakarta EE 12 plan removes it.
| Action | What it does | Result |
|---|---|---|
|
Formats a number, a currency amount or a percentage in the conventions of a locale, or with a |
Text, written to the page or stored in |
|
Parses the text of a number, currency or percentage into a |
A |
|
Formats a |
Text, written to the page or stored in |
|
Parses the text of a date and time into a |
A |
|
Sets the time zone for the formatting and parsing actions in its body |
The body’s output, unchanged |
|
Stores a time zone in a scoped variable, or as the default time zone of a scope |
Nothing is written |
Every action is usable without a body except fmt:timeZone, and formatNumber, parseNumber and parseDate accept
their value as the trimmed body instead of the value attribute (examples below). The type, pattern, var and
scope attributes work the same way across the actions, and scope without var is a translation-time error. The
actions format with java.text classes (DecimalFormat, SimpleDateFormat, DateFormat), so everything the Java SE
documentation says about those classes, including how locale data is chosen, applies to the output.
The taglib directive is the one that every page of this section uses, with the jakarta.tags.* URI. The old http://java.sun.com/jsp/jstl/fmt URI is not a link to anything any more (the Tags 3.0 release page says only that the old URIs may still be used as identifiers), and the specification and every tool of the Jakarta era use the new one.
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
The Bookshelf Used on This Page
All the examples are files of one Maven WAR, g5jstlformatting.war, deployed under the context path
/g5jstlformatting on Tomcat 11.0.26. Its dependencies are those of the Getting Started page of this section
(provided Servlet, Pages and EL APIs, plus the Jakarta Tags API and its implementation, which Tomcat does not
include). One servlet puts a Book record and a list holding it in the request, plus a fixed now so that every output
is repeatable, and forwards to /WEB-INF/views/<name>.jsp (the views live under WEB-INF, as everywhere in this
section).
public record Book(long id, String title, String author, BigDecimal price, double rating,
Date publishedAt, LocalDate released) { }
Book book = new Book(1, "Cien años de soledad", "Gabriel García Márquez",
new BigDecimal("1234.5"), 0.875,
Date.from(LocalDate.of(1967, 5, 30).atStartOfDay(ZoneOffset.UTC).toInstant()),
LocalDate.of(1967, 5, 30));
req.setAttribute("book", book);
req.setAttribute("books", List.of(book));
req.setAttribute("now", Date.from(Instant.parse("2026-10-08T14:30:00Z")));
The price is deliberately a BigDecimal (money is never a double), the rating is a double between 0 and 1 so that
it can be shown as a percentage, and the publication date is stored as the instant of midnight UTC, as many databases
and importers do. The released field holds the same day as a LocalDate, to show what the tags cannot yet do with it.
Unless a section says otherwise, web.xml contains two context parameters, the default time zone and the fallback
locale, which are explained in the configuration section:
<context-param>
<param-name>jakarta.servlet.jsp.jstl.fmt.timeZone</param-name>
<param-value>Europe/Madrid</param-value>
</context-param>
<context-param>
<param-name>jakarta.servlet.jsp.jstl.fmt.fallbackLocale</param-name>
<param-value>es_ES</param-value>
</context-param>
A word on reading the outputs. Java 21 uses the Unicode CLDR locale data, and CLDR separates numbers from their
symbols with no-break characters: U+00A0 (no-break space) before € or % in Spanish and German, and U+202F (narrow
no-break space) as the thousands separator in French and before AM and PM in English times. They look like spaces,
they are not spaces, and the output blocks below show them as ordinary spaces. A comparison or a parse that types a
normal space fails, which is a pitfall in its own right (see the pitfalls table).
Which Locale and Time Zone an Action Uses
Before the tags themselves, the two rules that explain most of the output. An action never "has" a locale; it finds one every time it runs, following the algorithm of specification section 9.2 and 9.3.
The steps in the diagram, in order:
-
A formatting action nested in
fmt:bundletakes the locale of that bundle’s localization context. -
Otherwise the
jakarta.servlet.jsp.jstl.fmt.localizationContextsetting, if it holds a bundle with a locale, supplies the locale. -
Otherwise the action establishes a locale. The preferred locales are those of
fmt:setLocaleor thejakarta.servlet.jsp.jstl.fmt.localesetting when one exists (the "application-based" locale, which wins) and the locales of theAccept-Languageheader, in order, otherwise (the "browser-based" locale). -
Each preferred locale is looked up among the locales that Java reports as available for that action (
NumberFormat.getAvailableLocales()for the number actions,DateFormat.getAvailableLocales()for the date actions). A lookup tries language, country and variant, then language and country, then the language alone when the available locale has no country. -
If no preferred locale matches, the
jakarta.servlet.jsp.jstl.fmt.fallbackLocalesetting is looked up the same way. -
If nothing matches, the specification leaves the corrective action to the tag: the format actions print the value with
Number.toString()orDate.toString(), and the parse actions throw aJspExceptionthat names the value.
The last step is not theoretical. These are the same page, the same tags and the same data, requested with three
different headers, on a JVM whose default locale is es_ES and with no fallbackLocale configured:
$ curl -s http://localhost:18080/g5jstlformatting/shelf?v=locales
request locales: en_US
1234.5 | Thu Oct 08 16:30:00 CEST 2026
$ curl -s -H 'Accept-Language: en-US' http://localhost:18080/g5jstlformatting/shelf?v=locales
request locales: en_US
$1,234.50 | October 8, 2026
$ curl -s -H 'Accept-Language: xx-XX' http://localhost:18080/g5jstlformatting/shelf?v=locales
request locales: xx_XX
1234.5 | Thu Oct 08 16:30:00 CEST 2026
The first request has no Accept-Language header, so the tags see no preferred locale (the Servlet API’s
request.getLocale() still answers en_US there, which is why ${pageContext.request.locale} is a poor proxy for what
the tags will do) and print the raw values. The third has a header that names a locale Java does not know. Browsers
always send the header, curl, health checks, tests and many API clients do not, and that is why a "formatting" page can
look correct in the browser and print 1234.5 in a script. The fix is to configure a fallbackLocale, or to call
fmt:setLocale from the page or from a filter. With the context parameter above the same two requests print
1.234,50 € and 8 de octubre de 2026.
A locale that is available but incomplete is the other trap. Accept-Language: sw (Swahili, language only) matches the
available locale sw, which has no country and therefore no currency, so the currency format prints the generic
currency sign ¤ (observed: ¤ 1,234.50). A fallback locale should therefore name a country too: es_ES printed
1.234,50 €, while the language-only es printed 1.234,50 ¤ in the same test.
The time zone has a shorter chain, used by fmt:formatDate and by fmt:parseDate for text that carries no zone of its
own:
The locale and the time zone are independent: a Spanish user can see Tokyo time, and a Japanese user can see Madrid time. The browser tells the server nothing about the time zone, so unless the application records it (a profile setting, a cookie set by JavaScript, an offset sent with a form) the container’s zone is what every user sees. The number and currency formatting does not depend on the time zone.
fmt:formatNumber
fmt:formatNumber formats a number according to the formatting locale (type) or to a custom pattern (pattern), and
fine-tunes the result with the digit and grouping attributes.
| Attribute | Type | Meaning |
|---|---|---|
|
|
The number. If absent, the trimmed body is the number. A string is parsed with |
|
|
|
|
|
A |
|
|
An ISO 4217 currency code. Applied only when |
|
|
The symbol to print. Applied only when |
|
|
Whether grouping separators appear |
|
|
The largest and smallest number of digits of the integer part |
|
|
The largest and smallest number of digits of the fraction part |
|
|
Name of the scoped variable that receives the result as a |
|
|
Scope of |
Types and locales
The same three tags, run once per locale with fmt:setLocale (the c:forTokens loop only keeps the page short):
<c:forTokens items="en_US,es_ES,de_DE,fr_FR,fr_CH,ja_JP" delims="," var="l">
<fmt:setLocale value="${l}"/>
${l}: <fmt:formatNumber value="${book.price}" type="currency"/>
| <fmt:formatNumber value="${book.price}"/>
| <fmt:formatNumber value="${book.rating}" type="percent"/>
</c:forTokens>
en_US: $1,234.50 | 1,234.5 | 88%
es_ES: 1.234,50 € | 1.234,5 | 88 %
de_DE: 1.234,50 € | 1.234,5 | 88 %
fr_FR: 1 234,50 € | 1 234,5 | 88 %
fr_CH: 1 234.50 CHF | 1 234,5 | 88%
ja_JP: ¥1,234 | 1,234.5 | 88%
Several things are visible in six lines. The decimal and grouping separators, the position of the currency sign and the
space before % are all the locale’s; the page never says where a symbol goes. The currency comes from the locale (Swiss
francs for fr_CH, yen for ja_JP), not from the data: a fmt:formatNumber of a price does not know what currency the
price is in, it knows the currency of the reader’s country. ja_JP prints 1,234 without cents because the yen has no
minor unit, so the .5 was rounded away (half-even, see below). A shop that sells in one currency to readers of many
must pass the currency of the data, as in the next section. Finally, the specification’s own example for fr_CH
(SFr. 9'876'543.21) predates the CLDR data and Java 21 prints something else: examples in books and in the
specification show what the platform printed when they were written.
Patterns, digits and the currency of the data
The attribute list in one page, each line a different attribute (the locale is en_US, the price 1234.5):
<fmt:setLocale value="en_US"/>
[<fmt:formatNumber value="${book.price}" pattern="#,##0.00"/>]
[<fmt:formatNumber value="12.3" pattern=".000"/>]
[<fmt:formatNumber value="123456.7891" pattern="#,#00.0#"/>]
[<fmt:formatNumber value="${book.price}" type="currency" currencyCode="EUR"/>]
[<fmt:formatNumber value="${book.price}" type="currency" currencySymbol="EUR "/>]
[<fmt:formatNumber value="${book.price}" type="currency" currencyCode="EUR" currencySymbol="X"/>]
[<fmt:formatNumber value="${book.price}" groupingUsed="false"/>]
[<fmt:formatNumber value="7" minIntegerDigits="3"/>]
[<fmt:formatNumber value="${book.rating}" type="percent" minFractionDigits="1"/>]
[<fmt:formatNumber value="3" minFractionDigits="2"/>]
[<fmt:formatNumber type="currency" currencyCode="USD"> 19.99 </fmt:formatNumber>]
[<fmt:formatNumber value="${book.price}" type="percent" pattern="0.0"/>]
[<fmt:formatNumber value="${missing}"/>]
[1,234.50]
[12.300]
[123,456.79]
[€1,234.50]
[EUR 1,234.50]
[€1,234.50]
[1234.5]
[007]
[87.5%]
[3.00]
[$19.99]
[1234.5]
[]
The lines show the rules of the specification at work. pattern replaces the locale’s pattern but not its symbols
(lines 1 to 3, the second and third are the specification’s own examples and print the values it states).
currencyCode="EUR" changes the symbol to the euro sign while the layout stays that of en_US; currencySymbol prints
any text; with both, the code wins (line 6). groupingUsed="false" removes the separators, which is how to print a
number that another program will read. A body can replace value, and is trimmed (line 11), which is useful when the
number is the result of another tag. A type of percent multiplies by 100, but pattern wins over type, so line 12
does not multiply: it is a plain 0.0 pattern, and mixing the two is almost always a mistake. A null or empty value
prints nothing at all and removes var, which is why an empty cell in a table is not an error and also why a typo in a
variable name (${missing}) produces an empty cell instead of a failure.
The pattern language is the one of java.text.DecimalFormat:
| Character | Meaning | Example with 1234.5 and 0.875 |
|---|---|---|
|
A digit; shown even when zero |
|
|
A digit; zero is not shown |
|
|
Decimal separator (the locale’s symbol is printed) |
|
|
Grouping separator and group size |
|
|
Scientific notation |
|
|
Separates the positive and negative patterns |
|
|
Multiplies by 100 and prints a percent sign |
|
|
The currency sign; two of them ( |
|
|
Quotes special characters so that they are printed as text |
|
Three consequences of that class are worth knowing. The rounding mode is HALF_EVEN ("banker’s rounding"): with
maxFractionDigits="0", 2.5 prints 2 and 3.5 prints 4, and the rating 0.875 printed as 88% above is 87.5
rounded to the even neighbour. Money is therefore formatted from BigDecimal values that were already rounded
deliberately by the application; the page is not the place to decide how a price rounds. The maxIntegerDigits
attribute does not round, it truncates the high-order digits: 1234567.891 with maxIntegerDigits="3" and
maxFractionDigits="1" printed 567.9 (observed), a way to print a plausible and wrong number. And currencyCode only
changes the symbol and the code; it does not move the decimal places to those of the currency, so ja_JP with
currencyCode="EUR" printed €1,234 (the layout keeps the yen’s zero decimals) while en_US printed €1,234.50. If a
page shows amounts in a currency that is not the reader’s, set minFractionDigits and maxFractionDigits too.
Source: Java SE 21 — RoundingMode
Storing the result with var
With var the action writes nothing and stores a String. This is how to use a formatted amount inside an attribute or in
another tag (such as the fmt:message argument of the i18n page) without printing it first:
<fmt:formatNumber value="${book.price}" type="currency" var="label" scope="request"/>
<span title="${label}">${label}</span> <%-- label is a java.lang.String --%>
en_US<span title="$1,234.50">$1,234.50</span>
The result is text made of digits, the locale’s symbols and no markup, so it needs no escaping by itself, but the
symbols are data from the platform and the page that prints ${label} inside an attribute is still printing a value.
When the number is something a user typed (a "price" field echoed back), print the original string with c:out, or
parse it first and format the parsed number, never the raw text inside a value attribute that is later reflected in
HTML.
fmt:parseNumber
fmt:parseNumber is the inverse: it reads text written with the conventions of a locale (or a pattern) and produces a
Number.
| Attribute | Type | Meaning |
|---|---|---|
|
|
The text to parse; if absent, the trimmed body |
|
|
|
|
|
A |
|
|
The locale whose conventions the text follows. If absent, the formatting locale of the previous section is used |
|
|
Parse only the integer part of the text |
|
|
Where the |
Parsing is where the conventions are strictest, because the same text means different numbers in different locales:
<fmt:setLocale value="en_US"/>
<fmt:parseNumber value="1.234,56" parseLocale="es_ES" var="a"/> a=${a} ${a.getClass().simpleName}
<fmt:parseNumber value="1,234.56" parseLocale="en_US" var="b"/> b=${b} ${b.getClass().simpleName}
<fmt:parseNumber value="1,234.56" parseLocale="en_US" integerOnly="true" var="c"/> c=${c} ${c.getClass().simpleName}
<fmt:parseNumber value="${pctEs}" type="percent" parseLocale="es_ES" var="d"/> d=${d}
<fmt:parseNumber value="$1,234.50" type="currency" parseLocale="en_US" var="e"/> e=${e}
<fmt:parseNumber value="42" var="f"/> f=${f} ${f.getClass().simpleName}
<fmt:parseNumber var="i"> 7.5 </fmt:parseNumber> i=${i}
<fmt:parseNumber value="1234.5" parseLocale="en_US"/>
a=1234.56 Double
b=1234.56 Double
c=1234 Long
d=0.875
e=1234.5
f=42 Long
i=7.5
1234.5
The percent text of line 4 is 87,5 % with a no-break space (U+00A0) before the sign, which is what es_ES prints on
Java 21 and therefore what the parser expects; the servlet put that string in the request attribute pctEs for the test.
The result is the fraction 0.875, not 87.5: a percent is divided by 100 on the way in, as it was multiplied on the way
out. The last line shows that without var the number goes to the page through Number.toString(), which is not
locale-aware.
The parser is lenient at the end and strict at the start
DecimalFormat.parse reads the longest prefix that is a number and ignores whatever follows. The tag inherits that. Each
of the following is silently wrong, not an error:
<fmt:parseNumber value="12abc" parseLocale="en_US" var="h"/> h=${h}
<fmt:parseNumber value="1.234,56" parseLocale="en_US" var="k"/> k=${k}
<fmt:parseNumber value="1 234,5" pattern="#,##0.#" parseLocale="fr_FR" var="g"/> g=${g}
h=12
k=1.234
g=1
The second line reads a Spanish number with an English parser: the parse stops at the comma and returns 1.234, a thousand times too small and with no message. The third types an ordinary space where French uses the narrow no-break space, and the parse stops at it and returns 1. Text that cannot start a number does fail:
<c:catch var="err"><fmt:parseNumber value="87,5%" type="percent" parseLocale="es_ES" var="m"/></c:catch>
${err.message}
In <parseNumber>, value attribute can not be parsed: "87,5%"
The message is the specification’s required one (it must include the string being parsed, with the parser’s exception as
root cause); the < is in the implementation’s message text itself (it was added to avoid HTML injection in error
pages), so it prints as entities when ${err.message} is written to a page without escaping. The exception is a
jakarta.servlet.jsp.JspException wrapping a java.text.ParseException; the error handling page of this section explains how an error page or a c:catch receives it. A parseLocale that is absent when the page has no formatting
locale throws too, with In <parseNumber>, a parse locale can not be established (observed with a request without
Accept-Language and no fallback). An empty value is not an error: it removes var (if var held an older value, the
older value is gone), so "empty input" and "invalid input" can be told apart with empty var.
Two rules follow for a form. Do not parse user input in the view: a form field is untrusted, the conversion belongs in
the controller (or in a validator) where a failure can produce a message next to the field and a 400 or a re-display, and
the view then formats the parsed value. And give the user the same convention the parser expects (a fmt:formatNumber
of the current value in the same locale), or use <input type="number">, which always submits a dot as the decimal
separator whatever the locale of the browser.
A number for a machine is not a number for a person
The value of an <input type="number"> must use a dot and no grouping. Formatting it with fmt:formatNumber in a
locale that uses a comma produces a value the browser rejects:
<fmt:setLocale value="es_ES"/>
shown to people: <fmt:formatNumber value="${book.price}" pattern="0.00"/>
machine value: <input type="number" step="0.01" value="${book.price}">
wrong: <input type="number" step="0.01" value="<fmt:formatNumber value="${book.price}" pattern="0.00"/>">
shown to people: 1234,50
machine value: <input type="number" step="0.01" value="1234.5">
wrong: <input type="number" step="0.01" value="1234,50">
fmt:formatDate
fmt:formatDate formats a java.util.Date (or a subclass such as java.sql.Timestamp; java.sql.Date was observed to
work) as a date, a time, or both.
| Attribute | Type | Meaning |
|---|---|---|
|
|
The instant to format. Null or empty prints nothing and removes |
|
|
|
|
|
|
|
|
The same styles. Used when |
|
|
A |
|
|
The zone in which to show the time. Empty means "not given" |
|
|
Where the formatted |
type defaults to date, so a fmt:formatDate of an instant without a type prints no time at all, which is a frequent
"where did the time go?" question. The styles run through every locale’s own conventions (the instant is 2026-10-08
14:30 UTC, the time zone of the application Madrid, which is CEST, UTC+2):
<c:forTokens items="en_US,en_GB,es_ES,de_DE,fr_FR,ja_JP" delims="," var="l">
<fmt:setLocale value="${l}"/>
${l}: <fmt:formatDate value="${now}" dateStyle="long"/>
| <fmt:formatDate value="${now}" type="both" dateStyle="short" timeStyle="short"/>
| <fmt:formatDate value="${now}" type="time" timeStyle="long"/>
</c:forTokens>
en_US: October 8, 2026 | 10/8/26, 4:30 PM | 4:30:00 PM CEST
en_GB: 8 October 2026 | 08/10/2026, 16:30 | 16:30:00 CEST
es_ES: 8 de octubre de 2026 | 8/10/26, 16:30 | 16:30:00 CEST
de_DE: 8. Oktober 2026 | 08.10.26, 16:30 | 16:30:00 MESZ
fr_FR: 8 octobre 2026 | 08/10/2026 16:30 | 16:30:00 CEST
ja_JP: 2026年10月8日 | 2026/10/08 16:30 | 16:30:00 CEST
Source: Java SE 21 — DateFormat
The five styles of one locale (en_US, both date and time) show the ladder from terse to verbose:
| Style | Output |
|---|---|
|
|
|
|
|
|
|
|
Custom patterns
A pattern bypasses the locale’s styles and uses SimpleDateFormat letters (the locale still supplies the names of
months and days). The letters that are used on pages:
| Letter | Meaning | Example for 2026-10-08 16:30:00 +02:00 (en_US) |
|---|---|---|
|
Year; |
|
|
Week year (the year that owns the week); not the calendar year |
|
|
Month: |
|
|
Minute |
|
|
Day of month |
|
|
Day of the year |
|
|
Day name: |
|
|
Hour of day 0-23 / hour in AM/PM 1-12 |
|
|
AM/PM marker |
|
|
Second |
|
|
Time zone: name, |
|
|
Quotes literal text |
|
The two classic mistakes are in that table. mm is minutes and MM is the month, so dd/mm/yyyy prints the minute where
the month belongs and nobody notices until the minute is not the month. And YYYY is the week-based year, which equals
the calendar year except in the first and last days of December and January:
<fmt:setLocale value="en_US"/>
y: [<fmt:formatDate value="${eve}" pattern="yyyy-MM-dd"/>] Y: [<fmt:formatDate value="${eve}" pattern="YYYY-MM-dd"/>]
mm: [<fmt:formatDate value="${now}" pattern="MM-mm hh-HH a"/>]
[<fmt:formatDate value="${now}" type="time" pattern="EEEE d MMMM yyyy"/>]
eve is 2026-12-31 12:00 UTC)y: [2026-12-31] Y: [2027-12-31]
mm: [10-30 04-16 PM]
[Thursday 8 October 2026]
The last line also shows that pattern overrides type: the instruction type="time" was ignored and the date printed.
A pattern letter that does not exist is not caught at translation time: pattern="bbb" fails at request time with
java.lang.IllegalArgumentException: Illegal pattern character 'b' (observed), a plain IllegalArgumentException and not
a JspException. That matters when the pattern comes from a user preference: take it from a fixed list, never from a
request parameter.
Time Zones: fmt:timeZone and fmt:setTimeZone
A java.util.Date is an instant; the same instant is a different wall-clock time in each zone. fmt:timeZone sets the
zone for every fmt:formatDate and fmt:parseDate in its body; fmt:setTimeZone stores a zone either as a variable or
as the default for a scope. A zone is a TimeZone object or a string: a Java time-zone ID such as Asia/Tokyo (the
tz database names), or a custom ID such as GMT+1:00.
| Attribute | Used by | Meaning |
|---|---|---|
|
both |
A time-zone ID or a |
|
|
Names a scoped variable that receives the |
|
|
The scope of |
The five ways to choose a zone, in the order of the precedence chain, in one page (the application default is Madrid):
attribute: <fmt:formatDate value="${now}" type="both" timeStyle="long" timeZone="Asia/Tokyo"/>
<fmt:timeZone value="America/New_York">
fmt:timeZone: <fmt:formatDate value="${now}" type="both" timeStyle="long"/>
</fmt:timeZone>
<fmt:timeZone value="GMT+1:00">
custom ID: <fmt:formatDate value="${now}" type="both" dateStyle="full" timeStyle="full"/>
</fmt:timeZone>
context param: <fmt:formatDate value="${now}" type="both" timeStyle="long"/>
<fmt:setTimeZone value="Pacific/Auckland" scope="request"/>
setTimeZone: <fmt:formatDate value="${now}" type="both" timeStyle="long"/>
<fmt:setTimeZone value="Australia/Perth" var="zone"/>
var only: <fmt:formatDate value="${now}" type="both" timeStyle="long"/>
explicit: <fmt:formatDate value="${now}" type="both" timeStyle="long" timeZone="${zone}"/>
en_US; the instant is 14:30 UTC; U+202F shown as a space)attribute: Oct 8, 2026, 11:30:00 PM JST
fmt:timeZone: Oct 8, 2026, 10:30:00 AM EDT
custom ID: Thursday, October 8, 2026, 3:30:00 PM GMT+01:00
context param: Oct 8, 2026, 4:30:00 PM CEST
setTimeZone: Oct 9, 2026, 3:30:00 AM NZDT
var only: Oct 9, 2026, 3:30:00 AM NZDT
explicit: Oct 8, 2026, 10:30:00 PM AWST
Reading the output: the context parameter is the default (line 4), fmt:setTimeZone with a scope replaces it for that scope
(line 5), and a fmt:setTimeZone with var only creates a variable, so the line that follows it still shows Auckland
(line 6); the variable becomes useful when it is passed as a timeZone attribute (line 7), the last line showing that an
attribute beats everything. The fmt:setTimeZone with scope="session" is the way to remember a user’s zone for the
whole visit; put it in the request that reads the profile. Three behaviours of the platform are worth remembering:
-
An unknown zone ID does not fail.
TimeZone.getTimeZonefalls back to GMT, sotimeZone="Nowhere/Land"printed2:30:00 PM GMT(observed). A misspelled zone silently shows UTC; take the ID from a list. -
An empty
timeZoneattribute is "not given" and falls through the chain (4:30:00 PM CEST), while an emptyvalueonfmt:timeZoneselects GMT (2:30:00 PM GMT). The same empty string means two different things. -
The stored date matters. The publication date of the Bookshelf, midnight UTC, prints
May 30, 1967in Madrid and in Tokyo andMay 29, 1967in New York (observed). A date without a time of day should be stored and formatted as a date (LocalDate), or formatted withtimeZone="UTC"; formatting it in the reader’s zone moves it a day for half the planet.
fmt:parseDate
fmt:parseDate takes text and a description of its shape, and produces a java.util.Date. It has the attributes of
fmt:formatDate (type, dateStyle, timeStyle, pattern, timeZone, var, scope), a parseLocale like
fmt:parseNumber, and a body as an alternative to value. The parse is non-lenient: a date that does not exist is an
error, not the next valid day.
| Attribute | Meaning |
|---|---|
|
The text to parse; the trimmed body when absent. A null or empty value removes |
|
The locale whose styles (or whose names of months and days, in a pattern) apply. If absent the formatting locale of the page is used |
|
The zone of the text when the text carries no zone of its own; otherwise the zone chain applies |
|
A |
<fmt:setLocale value="en_US"/>
<fmt:parseDate value="4/13/02" pattern="M/d/yy" var="p1"/>
p1: <fmt:formatDate value="${p1}" dateStyle="full"/> ${p1.getClass().name}
<fmt:parseDate value="1967-05-30" pattern="yyyy-MM-dd" var="p2"/>
<fmt:parseDate value="1967-05-30" pattern="yyyy-MM-dd" timeZone="UTC" var="p3"/>
<fmt:parseDate value="30/05/1967 18:45" pattern="dd/MM/yyyy HH:mm" timeZone="Asia/Tokyo" var="p4"/>
p2: ${p2.time} p3: ${p3.time} p4: ${p4.time}
<fmt:parseDate value="30 de mayo de 1967" type="date" dateStyle="long" parseLocale="es_ES" var="p5"/>
p5: ${p5.time}
<fmt:parseDate var="p6" pattern="yyyy-MM-dd">1967-05-30</fmt:parseDate>
<fmt:parseDate value="" pattern="yyyy-MM-dd" var="p7"/>
p7 removed: ${empty p7}
<fmt:parseDate value="2026-10-08" pattern="yyyy-MM-dd"/>
p1: Saturday, April 13, 2002 java.util.Date
p2: -81824400000 p3: -81820800000 p4: -81785700000
p5: -81824400000
p7 removed: true
Thu Oct 08 00:00:00 CEST 2026
p1 is the specification’s own example. p2 and p3 are the same text read in two zones: without timeZone the text is
Madrid’s midnight (UTC+1 on that day of 1967, hence the hour of difference with UTC) and with it UTC’s midnight. p4
shows the zone attribute applied to the time of day, and p5 that parseLocale (not the page’s locale, en_US here)
decides how the Spanish month names read. The p5 and p2 numbers are equal because both mean midnight in Madrid on
the same day. Without var the parsed date is written with Date.toString(), in the container’s zone, which is almost
never what a page wants.
A date that does not exist is rejected, and so is text that does not match the style exactly:
<c:catch var="err"><fmt:parseDate value="1967-02-31" pattern="yyyy-MM-dd" var="p8"/></c:catch>
${err.message}
<c:catch var="e10"><fmt:parseDate value="10/8/26 2:30 PM" type="both" dateStyle="short" timeStyle="short" var="p10"/></c:catch>
${e10.message}
<fmt:parseDate value="${dtUs}" type="both" dateStyle="short" timeStyle="short" var="p11"/>
In <parseDate>, value attribute can not be parsed: "1967-02-31"
In <parseDate>, value attribute can not be parsed: "10/8/26 2:30 PM"
February 31st is rejected because the parse is non-lenient, as the specification requires. The second line is the Java 20
change in disguise: the en_US short style of Java 21 is 10/8/26, 4:30 PM with a comma after the date and a narrow
no-break space before PM, so the text a person (or a Java 17 era test fixture) types, 10/8/26 2:30 PM, does not match
and the parse fails. The third line, whose dtUs is the text with the comma and the U+202F, parses. Parsing text typed
by a user with a style is therefore fragile across Java versions; prefer a pattern that you control, yyyy-MM-dd for
text from a form or a feed, and the <input type="date"> of HTML, which always submits that format.
Dates Without java.time: the Missing Support and the Workarounds
The value attribute of fmt:formatDate is typed java.util.Date, and Tags 3.0 knows nothing about java.time.
A LocalDate, LocalDateTime, Instant or ZonedDateTime is rejected before the tag runs. A String is rejected too,
because there is no editor that turns text into a Date:
<c:catch var="e1"><fmt:formatDate value="${book.released}" dateStyle="long"/></c:catch>
${e1.getClass().name}: ${e1.message}
<c:catch var="e2"><fmt:formatDate value="1967-05-30" dateStyle="long"/></c:catch>
${e2.getClass().name}: ${e2.message}
jakarta.el.ELException: Cannot convert [1967-05-30] of type [class java.time.LocalDate] to [class java.util.Date]
org.apache.jasper.JasperException: Unable to convert string [1967-05-30] to class [java.util.Date] for attribute [value]: [Property Editor not registered with the PropertyEditorManager]
The first error is the Expression Language’s attempt to coerce the attribute to the declared type; the second comes from
the Jasper compiler for a literal that must become a Date. This is the single most common complaint about the library
in modern projects, and the Jakarta Tags project has recorded it: issue 247, "Support java.time API in fmt:parseDate and
fmt:formatDate", is the one feature planned for Tags 3.1 (Jakarta EE 12), which also raises the minimum Java to 21.
Tags 3.1 is under development and available only as milestone builds, so a production page today needs one of these four
workarounds, from the most to the least recommended:
<%@ page import="java.time.format.DateTimeFormatter, java.time.ZoneOffset, java.util.Date" %>
<fmt:setLocale value="en_US"/>
1: ${book.released}
2: ${book.released.format(DateTimeFormatter.ISO_LOCAL_DATE)}
3: ${DateTimeFormatter.ofPattern("d MMMM uuuu").withLocale(pageContext.request.locale).format(book.released)}
4: <fmt:formatDate value="${Date.from(book.released.atStartOfDay(ZoneOffset.UTC).toInstant())}" dateStyle="long" timeZone="UTC"/>
1: 1967-05-30
2: 1967-05-30
3: 30 May 1967
4: May 30, 1967
Source: Java SE 21 — DateTimeFormatter
The workarounds, in prose:
-
Convert in the controller or in a view model. The servlet or the controller puts a
java.util.Date(or aStringalready formatted for the reader) in the request next to thejava.timevalue; the view stays plain. This is the simplest and the safest, because the zone decision is made once, in Java, where it can be tested. -
Print the ISO form, line 1 and 2. The
toString()of aLocalDateis the ISO 8601 date, which is right for machine-readable output (<time datetime="${book.released}">, avalueattribute of<input type="date">) and for no one else. -
Format with
DateTimeFormatterfrom EL, line 3. Apagedirective’simportmakes the class (and the static constants of the class, such asISO_LOCAL_DATE) known to the Expression Language, which then calls instance and static methods. Note the pitfall in the example:pageContext.request.localeis the first locale ofAccept-Language, which is not the localefmt:setLocalechose, so the line and thefmttags of the same page can disagree about the language. The class used must be public and not abstract:ZoneId.of(...)fails withThe class [java.time.ZoneId] must be public, in an exported package, non-abstract and not an interface(observed), which is why the example uses the final classZoneOffset. -
Convert to
Datein the page, line 4, with a zone that you decide (UTC for a calendar date) andtimeZoneset to the same zone. It works and is the one to avoid, since the conversion lives in the view where the zone rule is easy to forget.
A custom EL function or a tag file wrapping DateTimeFormatter is the clean way to reuse workaround 3; both are covered
by the SimpleTag and TLDs and EL functions pages of this section. Until Tags 3.1 is final and the container ships
it, none of this code needs to change when it arrives.
Configuration Settings
The formatting actions read four configuration variables, all in the jakarta.servlet.jsp.jstl.fmt. namespace (the 1.x
and 2.x names began with javax.). The jakarta.servlet.jsp.jstl.core.Config class has a constant for each.
| Variable | Java constant | Set by | Effect on this page’s actions |
|---|---|---|---|
|
|
|
The application-based preferred locale; disables the browser’s locales |
|
|
A context parameter, |
The locale of last resort, if no preferred locale is available |
|
|
|
The default bundle, whose locale (when it has one) becomes the formatting locale |
|
|
|
The default time zone of |
The two forms of setting a variable are different animals. A tag (fmt:setLocale, fmt:setTimeZone, fmt:setBundle) sets the variable in the scope you name (page by default, then request, session or application), so it lives and dies with that scope; the implicit objects and scopes page explains the scopes. A
context-param in web.xml sets it for the whole application and is the right place for the defaults: the zone of the
business, the fallback locale. Setting the variable with c:set under the plain variable name, in request scope, had no
effect in the test (observed with fallbackLocale: the page still printed 1234.5), because the library does not read a plain attribute of that name as a setting. Use the tags, Config.set in Java (from a filter that reads the user’s
profile) or the context parameter.
A filter that sets the formatting configuration from a user profile is the usual way to make the whole application obey it without a tag on every page:
Config.set(request, Config.FMT_LOCALE, profile.locale());
Config.set(request, Config.FMT_TIME_ZONE, TimeZone.getTimeZone(profile.zoneId()));
chain.doFilter(request, response);
The snippet was run in the test WAR (a servlet calling Config.set made the same page print ¥1,234 and 2026年10月8日 23:30:00 JST). Specification section 9.11.1 prints the constant as Config.FMT_TIMEZONE, which does not exist: the Javadoc constant is FMT_TIME_ZONE.
A Worked Example: the Bookshelf Table in Three Locales
A page that combines everything above: a locale and a zone chosen by the application from two request parameters (a stand-in for a profile), a currency of the data (the books are sold in euros whatever the reader’s country), a percentage and two dates. The page has no scriptlet and no business logic; the data comes from the servlet.
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<c:set var="loc" value="${empty param.lang ? 'en_US' : param.lang}"/>
<fmt:setLocale value="${loc}"/>
<fmt:setTimeZone value="${empty param.tz ? 'UTC' : param.tz}"/>
<table>
<c:forEach var="b" items="${books}">
<tr><td><c:out value="${b.title}"/></td>
<td><fmt:formatNumber value="${b.price}" type="currency" currencyCode="EUR"/></td>
<td><fmt:formatNumber value="${b.rating}" type="percent"/></td>
<td><fmt:formatDate value="${b.publishedAt}" dateStyle="long"/></td>
<td><fmt:formatDate value="${now}" type="both" dateStyle="medium" timeStyle="short"/></td></tr>
</c:forEach>
</table>
?lang=en_US, ?lang=es_ES&tz=Europe/Madrid and ?lang=ja_JP&tz=Asia/Tokyo (the U+00A0 and U+202F characters shown as spaces)<tr><td>Cien años de soledad</td> <td>€1,234.50</td> <td>88%</td> <td>May 30, 1967</td> <td>Oct 8, 2026, 2:30 PM</td></tr>
<tr><td>Cien años de soledad</td> <td>1.234,50 €</td> <td>88 %</td> <td>30 de mayo de 1967</td> <td>8 oct 2026, 16:30</td></tr>
<tr><td>Cien años de soledad</td> <td>€1,234</td> <td>88%</td> <td>1967年5月30日</td> <td>2026/10/08 23:30</td></tr>
The first row has no zone parameter and prints 14:30 (UTC, the page’s default), the second 16:30 (Madrid in summer) and
the third 23:30 (Tokyo). The currency attribute is the data’s, so the euro sign is printed in all three, but the third row
shows the pitfall of the previous sections: the yen locale’s zero decimal places survive and 1234.5 becomes €1,234.
A table of prices in a currency other than the reader’s needs minFractionDigits="2" maxFractionDigits="2". Two
parameters that name a locale and a zone are untrusted input, which is why the page uses them only as the value attributes of the tags, which tolerate unknown ones (an invalid zone gives GMT and an unknown locale the fallback locale, both observed). Production code reads
them from the user’s profile and checks them against a list.
Security and Correctness
The formatting tags do not write markup, so they are not an XSS vector by themselves: a number or a date formats to digits, the symbols of the locale data and spaces. The security issues are around them:
-
The
valueoffmt:formatNumberandfmt:parseNumbercan be user text. A string that is not a number fails with aJspException, which on a default Tomcat error page prints the message with the offending text; configure an error page and do not printerr.messagefor requests that come from users. -
A
patternis code for the formatter. A pattern taken from a request parameter lets a user crash the page (theIllegalArgumentExceptionabove) and choose how much of a number is shown. Patterns come from a fixed list. -
A time zone or locale from a request parameter is harmless to the tags (they fall back), but it changes the output, so it must not be part of a cache key unless it was validated.
-
Convert and validate user input in the controller (see the parsing section) so that a
ParseExceptionbecomes a message next to the field, and use an error page for the rest (see the error handling page).
Pitfalls and Error Names
| Symptom or error | Cause | Fix |
|---|---|---|
Prices print |
No |
|
The same output with an exotic |
The locale is not among the available locales of |
A fallback locale with a country, such as |
|
The locale found is a language without a country ( |
Use locales with countries as fallbacks; pass |
A thousands separator or |
CLDR data uses U+00A0 and U+202F where people type a space |
Never compare formatted text; parse with the same locale, or from a pattern, in the controller |
|
|
Do not set it for display of data that can be large |
|
|
Round money in the model with an explicit |
|
|
Set |
A percent value that does not multiply when a |
|
Put the |
|
|
Validate input in the controller; check the whole string |
|
The text does not match the locale’s percent or currency layout (the space is U+00A0) |
Parse with the exact format of the locale, or normalise the text first |
|
No |
A fallback locale, |
|
Tags 3.0 only formats |
Convert in the controller or format with |
|
A literal string given as |
Parse it with |
|
|
Use |
|
|
Use |
The date is a day early in New York and right in Madrid (observed) |
A date-only value stored as midnight UTC and shown in the reader’s zone |
Store dates as dates; show them with |
|
An unknown time-zone ID falls back to GMT; an empty |
Take IDs from |
|
|
Add |
|
A letter that |
Fixed patterns; quote text with |
|
A value other than |
Use the documented values and real ISO 4217 codes |
|
The |
Parse with a pattern |
A fallback set with |
The library reads its own scope-specific attributes, not the plain name |
Context parameter, tags or |
What Changed Recently
The tags did not change between Tags 1.1 and 3.0 in a way that affects their behaviour. What changed is the namespace of the library and, above all, the platform below it.
| Version | Change | Effect for page authors |
|---|---|---|
JSTL 1.1 (JSP 2.0, 2003) |
New URIs ( |
Pages written for JSTL 1.0 need the new |
Java 9 (JEP 252) |
CLDR locale data becomes the default instead of the older JRE data |
Separators, currency sign positions and date styles of many locales differ from older outputs |
Tags 3.0 (Jakarta EE 10) |
The URI becomes |
|
Java 20 (CLDR 42) |
A narrow no-break space (U+202F) before |
Text compared with, or parsed from, a time style written before Java 20 stops matching |
Tags 3.0.1 (2023-01-19) |
The implementation |
Nothing to upgrade; pin 3.0.1 with the 3.0.2 API |
Tags 3.1 (Jakarta EE 12, in progress) |
|
Milestones only today; do not rely on them in production |
Source: Jakarta Standard Tag Library 3.0
Where the Books Are Outdated
JSTL in Action (Manning, 2003, listed in the section bibliography) teaches JSTL 1.0 on JSP 1.2, and its chapters on formatting and internationalization are the origin of the vocabulary of this page (number and date formatting, parsing, time zones, locales). The concepts survived; the platform around them did not.
| The book’s era | Today (Tags 3.0, Java 21, Tomcat 11) |
|---|---|
|
One library, |
EL only inside JSTL attributes, and JSP 1.2 scriptlets to build values |
EL everywhere; no scriptlet to compute a date or a number |
Configuration variables named |
|
Output samples from a JDK 1.3 or 1.4 JRE ( |
CLDR data with different separators, no-break characters and styles; always run the page, never copy a sample |
|
|
Parsing user input in the page with |
Validation and conversion in the controller or with Bean Validation, with a message next to the field; the tags parse leniently at the end and throw on failure |
A currency symbol chosen only by locale |
An explicit |
A server time zone that is the user’s time zone |
A configured default and a per-user zone ( |
Scriptlets ( |
A custom tag, a tag file or an EL function; or the format done in the controller |
Source: Manning — JSTL in Action
References
-
Jakarta Standard Tag Library 3.0 — 9.1.1 Formatting Numbers, Currencies, and Percentages
-
Jakarta Standard Tag Library 3.0 — 9.1.2 Formatting Dates and Times
-
Jakarta Standard Tag Library 3.0 — 9.3 Establishing a Formatting Locale
-
Jakarta Standard Tag Library 3.0 — 9.3.3 Formatting Locale Lookup Algorithm
-
Jakarta Standard Tag Library 3.0 — 9.11 Configuration Settings
-
Jakarta Standard Tag Library 3.0 — tag documentation, fmt:formatNumber
-
Jakarta Standard Tag Library 3.0 — tag documentation, fmt:parseNumber
-
Jakarta Standard Tag Library 3.0 — tag documentation, fmt:formatDate
-
Jakarta Standard Tag Library 3.0 — tag documentation, fmt:parseDate
-
Jakarta Tags issue 247 — Support java.time API in fmt:parseDate and fmt:formatDate
-
Oracle — JDK 20 Release Notes, Issues (CLDR 42 and the narrow no-break space)