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.

Table 1. The formatting actions of Jakarta Tags 3.0 (specification sections 9.5 to 9.10)
Action What it does Result

fmt:formatNumber

Formats a number, a currency amount or a percentage in the conventions of a locale, or with a DecimalFormat pattern

Text, written to the page or stored in var as a String

fmt:parseNumber

Parses the text of a number, currency or percentage into a java.lang.Number

A Long or a Double, stored in var or written with toString()

fmt:formatDate

Formats a java.util.Date as a date, a time or both, with predefined styles or a SimpleDateFormat pattern

Text, written to the page or stored in var as a String

fmt:parseDate

Parses the text of a date and time into a java.util.Date; non-lenient

A Date, stored in var or written with toString()

fmt:timeZone

Sets the time zone for the formatting and parsing actions in its body

The body’s output, unchanged

fmt:setTimeZone

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).

The data the pages format: a record, one book and a fixed instant
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.

flowchart TD A["formatting action runs"] --> B{"inside fmt:bundle whose bundle has a locale?"} B -- yes --> U["use that locale"] B -- no --> C{"localizationContext setting with a locale?"} C -- yes --> U C -- no --> D["preferred locales: the fmt.locale setting (fmt:setLocale), else the Accept-Language locales"] D --> E{"does an available locale match one of them?"} E -- yes --> U E -- no --> F{"does the fallbackLocale setting match?"} F -- yes --> U F -- no --> G["no locale: formatNumber and formatDate print the value with toString, parseNumber and parseDate throw JspException"]

The steps in the diagram, in order:

  1. A formatting action nested in fmt:bundle takes the locale of that bundle’s localization context.

  2. Otherwise the jakarta.servlet.jsp.jstl.fmt.localizationContext setting, if it holds a bundle with a locale, supplies the locale.

  3. Otherwise the action establishes a locale. The preferred locales are those of fmt:setLocale or the jakarta.servlet.jsp.jstl.fmt.locale setting when one exists (the "application-based" locale, which wins) and the locales of the Accept-Language header, in order, otherwise (the "browser-based" locale).

  4. 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.

  5. If no preferred locale matches, the jakarta.servlet.jsp.jstl.fmt.fallbackLocale setting is looked up the same way.

  6. If nothing matches, the specification leaves the corrective action to the tag: the format actions print the value with Number.toString() or Date.toString(), and the parse actions throw a JspException that 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:

flowchart LR A["fmt:formatDate or fmt:parseDate"] --> B{"timeZone attribute?"} B -- yes --> Z["use it"] B -- no --> C{"inside fmt:timeZone?"} C -- yes --> Z C -- no --> D{"fmt.timeZone setting (fmt:setTimeZone or context parameter)?"} D -- yes --> Z D -- no --> E["the time zone of the container: the JVM default"] E --> Z

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.

Table 2. Attributes of fmt:formatNumber (specification section 9.7)
Attribute Type Meaning

value

String or Number

The number. If absent, the trimmed body is the number. A string is parsed with Long.valueOf when it has no decimal point and Double.valueOf otherwise

type

String

number (the default), currency or percent. The locale’s default pattern for that type comes from NumberFormat.getNumberInstance, getCurrencyInstance or getPercentInstance

pattern

String

A DecimalFormat pattern. It takes precedence over type; the locale still supplies the symbols (decimal and grouping separators, currency sign, percent sign)

currencyCode

String

An ISO 4217 currency code. Applied only when type is currency

currencySymbol

String

The symbol to print. Applied only when type is currency; currencyCode wins when both are given

groupingUsed

boolean

Whether grouping separators appear

maxIntegerDigits minIntegerDigits

int

The largest and smallest number of digits of the integer part

maxFractionDigits minFractionDigits

int

The largest and smallest number of digits of the fraction part

var

String

Name of the scoped variable that receives the result as a String. The action then writes nothing

scope

String

Scope of var: page (the default), request, session or application. Illegal without var

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>
Rendered output (U+00A0 and U+202F shown as spaces; the price is 1234.5 and the rating 0.875)
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}"/>]
Rendered output, line by line
[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:

Table 3. Pattern characters of DecimalFormat that matter on a page (Java SE 21)
Character Meaning Example with 1234.5 and 0.875

0

A digit; shown even when zero

0000.000 gives 1234.500

#

A digit; zero is not shown

#,##0.## gives 1,234.5

.

Decimal separator (the locale’s symbol is printed)

0.00 gives 1234.50

,

Grouping separator and group size

#,##0 gives 1,234

E

Scientific notation

0.#E0 gives 1.2E3

;

Separates the positive and negative patterns

#,##0.00;(#,##0.00) shows -1234.5 as (1,234.50)

%

Multiplies by 100 and prints a percent sign

0.0% of 0.875 gives 87.5%

¤

The currency sign; two of them (¤¤) print the ISO 4217 code

#,##0.00 ¤¤ with currencyCode="EUR" gives 12.00 EUR

'

Quotes special characters so that they are printed as text

'#'0 of 5 gives #5

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.

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 --%>
Rendered output, 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.

Table 4. Attributes of fmt:parseNumber (specification section 9.8)
Attribute Type Meaning

value

String

The text to parse; if absent, the trimmed body

type

String

number (the default), currency or percent

pattern

String

A DecimalFormat pattern; wins over type

parseLocale

String or Locale

The locale whose conventions the text follows. If absent, the formatting locale of the previous section is used

integerOnly

boolean

Parse only the integer part of the text

var and scope

String

Where the Number is stored (a Long when the text is an integer that fits, a Double otherwise). Without var the result is written with toString()

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"/>
Rendered output
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}
Rendered output
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}
Rendered output
In &lt;parseNumber&gt;, 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"/>">
Rendered output
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.

Table 5. Attributes of fmt:formatDate (specification section 9.9)
Attribute Type Meaning

value

java.util.Date

The instant to format. Null or empty prints nothing and removes var

type

String

date (the default), time or both

dateStyle

String

default, short, medium, long or full, as in java.text.DateFormat. Used when type is date or both

timeStyle

String

The same styles. Used when type is time or both

pattern

String

A SimpleDateFormat pattern. When present, type, dateStyle and timeStyle are ignored

timeZone

String or TimeZone

The zone in which to show the time. Empty means "not given"

var and scope

String

Where the formatted String is stored; otherwise the text is written to the page

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>
Rendered output (U+202F before PM shown as a space)
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

The five styles of one locale (en_US, both date and time) show the ladder from terse to verbose:

Table 6. The five styles with type="both" and the same style for the date and the time
Style Output

short

10/8/26, 4:30 PM

medium (also default)

Oct 8, 2026, 4:30:00 PM

long

October 8, 2026, 4:30:00 PM CEST

full

Thursday, October 8, 2026, 4:30:00 PM Central European Summer Time

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:

Table 7. Date and time pattern letters of SimpleDateFormat (Java SE 21)
Letter Meaning Example for 2026-10-08 16:30:00 +02:00 (en_US)

y

Year; yyyy is the four-digit year

yyyy gives 2026

Y

Week year (the year that owns the week); not the calendar year

YYYY for 2026-12-31 gives 2027

M

Month: M and MM numbers, MMM short name, MMMM full name

MM gives 10, MMMM gives October

m

Minute

mm gives 30

d

Day of month

d gives 8

D

Day of the year

D gives 281

E

Day name: EEE short, EEEE full

EEE gives Thu

H / h

Hour of day 0-23 / hour in AM/PM 1-12

HH gives 16, hh gives 04

a

AM/PM marker

a gives PM

s

Second

ss gives 00

z / Z / X

Time zone: name, +0200 form, ISO 8601 form

zzzz gives Central European Summer Time, Z gives +0200, XXX gives +02:00

'

Quotes literal text

yyyy-MM-dd'T'HH:mm:ssXXX gives 2026-10-08T16:30:00+02:00

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"/>]
Rendered output (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.

Table 8. Attributes of fmt:timeZone and fmt:setTimeZone (specification sections 9.5 and 9.6)
Attribute Used by Meaning

value

both

A time-zone ID or a TimeZone. Null or empty selects GMT

var

fmt:setTimeZone

Names a scoped variable that receives the TimeZone; the default zone does not change

scope

fmt:setTimeZone

The scope of var, or of the default time zone when var is absent

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}"/>
Rendered output (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.getTimeZone falls back to GMT, so timeZone="Nowhere/Land" printed 2:30:00 PM GMT (observed). A misspelled zone silently shows UTC; take the ID from a list.

  • An empty timeZone attribute is "not given" and falls through the chain (4:30:00 PM CEST), while an empty value on fmt:timeZone selects 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, 1967 in Madrid and in Tokyo and May 29, 1967 in New York (observed). A date without a time of day should be stored and formatted as a date (LocalDate), or formatted with timeZone="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.

Table 9. Differences between fmt:formatDate and fmt:parseDate attributes (specification section 9.10)
Attribute Meaning

value

The text to parse; the trimmed body when absent. A null or empty value removes var (so "empty" can be told from "invalid")

parseLocale

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

timeZone

The zone of the text when the text carries no zone of its own; otherwise the zone chain applies

pattern

A SimpleDateFormat pattern describing the text; type, dateStyle and timeStyle are then ignored

<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"/>
Rendered output (application time zone Madrid)
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"/>
Rendered output
In &lt;parseDate&gt;, value attribute can not be parsed: "1967-02-31"
In &lt;parseDate&gt;, 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}
Rendered output
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"/>
Rendered output
1: 1967-05-30
2: 1967-05-30
3: 30 May 1967
4: May 30, 1967

The workarounds, in prose:

  1. Convert in the controller or in a view model. The servlet or the controller puts a java.util.Date (or a String already formatted for the reader) in the request next to the java.time value; 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.

  2. Print the ISO form, line 1 and 2. The toString() of a LocalDate is the ISO 8601 date, which is right for machine-readable output (<time datetime="${book.released}">, a value attribute of <input type="date">) and for no one else.

  3. Format with DateTimeFormatter from EL, line 3. A page directive’s import makes the class (and the static constants of the class, such as ISO_LOCAL_DATE) known to the Expression Language, which then calls instance and static methods. Note the pitfall in the example: pageContext.request.locale is the first locale of Accept-Language, which is not the locale fmt:setLocale chose, so the line and the fmt tags of the same page can disagree about the language. The class used must be public and not abstract: ZoneId.of(...) fails with The 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 class ZoneOffset.

  4. Convert to Date in the page, line 4, with a zone that you decide (UTC for a calendar date) and timeZone set 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.

Table 10. Configuration variables read by the formatting actions (specification sections 8.11 and 9.11)
Variable Java constant Set by Effect on this page’s actions

jakarta.servlet.jsp.jstl.fmt.locale

Config.FMT_LOCALE

fmt:setLocale

The application-based preferred locale; disables the browser’s locales

jakarta.servlet.jsp.jstl.fmt.fallbackLocale

Config.FMT_FALLBACK_LOCALE

A context parameter, Config.set

The locale of last resort, if no preferred locale is available

jakarta.servlet.jsp.jstl.fmt.localizationContext

Config.FMT_LOCALIZATION_CONTEXT

fmt:setBundle, a context parameter

The default bundle, whose locale (when it has one) becomes the formatting locale

jakarta.servlet.jsp.jstl.fmt.timeZone

Config.FMT_TIME_ZONE

fmt:setTimeZone, a context parameter

The default time zone of fmt:formatDate and fmt:parseDate

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>
Rendered rows for ?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 value of fmt:formatNumber and fmt:parseNumber can be user text. A string that is not a number fails with a JspException, which on a default Tomcat error page prints the message with the offending text; configure an error page and do not print err.message for requests that come from users.

  • A pattern is code for the formatter. A pattern taken from a request parameter lets a user crash the page (the IllegalArgumentException above) 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 ParseException becomes a message next to the field, and use an error page for the rest (see the error handling page).

Pitfalls and Error Names

Table 11. Formatting pitfalls, their symptoms and fixes (rows marked "observed" were reproduced on Tomcat 11.0.26 and Java 21)
Symptom or error Cause Fix

Prices print 1234.5 and dates print Thu Oct 08 16:30:00 CEST 2026 in curl but not in a browser (observed)

No Accept-Language header, no fmt:setLocale, no fallback locale: no formatting locale, so the tags use toString()

fallbackLocale context parameter, or fmt:setLocale or a filter

The same output with an exotic Accept-Language such as xx-XX (observed)

The locale is not among the available locales of NumberFormat or DateFormat

A fallback locale with a country, such as es_ES

¤ 1,234.50 (observed)

The locale found is a language without a country (sw, or a fallback of es), which has no currency

Use locales with countries as fallbacks; pass currencyCode

A thousands separator or % that does not equal the typed text, so a comparison or a parse fails (observed)

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

567.9 for 1234567.891 (observed)

maxIntegerDigits truncates the high-order digits

Do not set it for display of data that can be large

2 for 2.5 and 4 for 3.5 (observed)

HALF_EVEN rounding of DecimalFormat

Round money in the model with an explicit RoundingMode

€1,234 in ja_JP for a euro amount (observed)

currencyCode changes the symbol and not the decimals

Set minFractionDigits and maxFractionDigits

A percent value that does not multiply when a pattern is given (observed)

pattern takes precedence over type

Put the % in the pattern (0.0%) or use type="percent" only

1.234 for 1.234,56 and 12 for 12abc, no error (observed)

parseNumber parses the longest numeric prefix and ignores the rest

Validate input in the controller; check the whole string

JspException: ... value attribute can not be parsed: "87,5%" (observed)

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

JspException: In <parseNumber>, a parse locale can not be established (observed)

No parseLocale and no formatting locale

A fallback locale, fmt:setLocale or parseLocale

ELException: Cannot convert [1967-05-30] of type [class java.time.LocalDate] to [class java.util.Date] (observed)

Tags 3.0 only formats java.util.Date

Convert in the controller or format with DateTimeFormatter (section above)

JasperException: Unable to convert string [...] to class [java.util.Date] for attribute [value] (observed)

A literal string given as value of fmt:formatDate

Parse it with fmt:parseDate, then format the result

2027-12-31 for 31 December 2026 (observed)

YYYY is the week-based year

Use yyyy

10-30 where a month and a day were expected (observed)

mm is minutes, MM is the month

Use MM for months

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 timeZone="UTC"

2:30:00 PM GMT instead of the zone that was asked for (observed)

An unknown time-zone ID falls back to GMT; an empty value of fmt:timeZone is GMT too

Take IDs from ZoneId.getAvailableZoneIds()

Illegal scope attribute without var in "fmt:formatNumber" tag at translation (observed)

scope is only valid with var

Add var or remove scope

IllegalArgumentException: Illegal pattern character 'b' (observed)

A letter that SimpleDateFormat does not know, or a pattern from a user

Fixed patterns; quote text with '

In <formatNumber>, invalid type attribute: "bogus", unable to set currency (observed)

A value other than number, currency or percent; a currency code Java does not know

Use the documented values and real ISO 4217 codes

fmt:parseDate fails on 10/8/26 2:30 PM on Java 21 although it worked on Java 17 (observed)

The en_US short time style has a comma and U+202F since Java 20 (CLDR 42)

Parse with a pattern

A fallback set with c:set is ignored (observed)

The library reads its own scope-specific attributes, not the plain name

Context parameter, tags or Config.set

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.

Table 12. Changes that affect formatting
Version Change Effect for page authors

JSTL 1.1 (JSP 2.0, 2003)

New URIs (http://java.sun.com/jsp/jstl/fmt), the container evaluates EL in attributes, the _rt twin libraries disappear

Pages written for JSTL 1.0 need the new taglib URI and no longer use fmt_rt

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 jakarta.tags.fmt; the packages and the configuration variables move to jakarta.servlet.jsp.jstl.*; Java 11 minimum; the old java.sun.com URIs still work as identifiers

javax code and web.xml parameters with the old names need renaming; the tags behave as before

Java 20 (CLDR 42)

A narrow no-break space (U+202F) before AM and PM in English times

Text compared with, or parsed from, a time style written before Java 20 stops matching

Tags 3.0.1 (2023-01-19)

The implementation org.glassfish.web:jakarta.servlet.jsp.jstl 3.0.1 is still the latest final

Nothing to upgrade; pin 3.0.1 with the 3.0.2 API

Tags 3.1 (Jakarta EE 12, in progress)

fmt:formatDate and fmt:parseDate to support java.time; Java 21 minimum; no removals planned

Milestones only today; do not rely on them in production

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.

Table 13. JSTL 1.0-era formatting material and its modern equivalent
The book’s era Today (Tags 3.0, Java 21, Tomcat 11)

http://java.sun.com/jstl/fmt and the fmt_rt twin library for scriptlet expressions

One library, jakarta.tags.fmt; EL is evaluated by the container in every attribute and there is no _rt twin

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 javax.servlet.jsp.jstl.fmt.*

jakarta.servlet.jsp.jstl.fmt.* (the Config class lives in jakarta.servlet.jsp.jstl.core)

Output samples from a JDK 1.3 or 1.4 JRE ($1,234.50, 1.234,50 EUR, 22 octobre 2001 16:05:53 GMT-07:0)

CLDR data with different separators, no-break characters and styles; always run the page, never copy a sample

java.util.Date and Calendar as the only date types

java.time in the application; Tags 3.0 still takes only java.util.Date, with the workarounds above and Tags 3.1 planned

Parsing user input in the page with fmt:parseNumber and fmt:parseDate to validate forms

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 currencyCode from the data plus explicit digits; money as BigDecimal

A server time zone that is the user’s time zone

A configured default and a per-user zone (fmt:setTimeZone with scope, or a filter calling Config.set)

Scriptlets (<% new SimpleDateFormat(...) %>) when the tag cannot do something

A custom tag, a tag file or an EL function; or the format done in the controller

References