JSTL Core

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 (API jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api 3.0.2, implementation org.glassfish.web:jakarta.servlet.jsp.jstl 3.0.1, the latest release of both), which is part of Jakarta EE 10 and 11, together with Jakarta Pages 4.0, Jakarta Expression Language 6.0 and Jakarta Servlet 6.1, running on Apache Tomcat 11.0.26 and Java 21. Everything was verified on 2026-10-08: the rules against chapters 1 to 5 of the Tags 3.0 specification and the tag documentation (tagdocs) of the core library, and every example by building it into a Maven WAR (context path /g5jstlcore) and running it on Tomcat 11.0.26 with curl; the outputs and error messages shown are the ones that run produced. The Standard Tag Library is the set of tags that page authors use instead of scriptlets: it prints and escapes values, creates and removes scoped variables, makes decisions and catches errors, and (on the pages of this section that follow this one) loops, builds URLs, imports resources, localises and formats text, calls string functions and, in two legacy libraries, queries databases and processes XML. This page covers the whole library at the level of concepts, the five URIs and the conventions every tag obeys, and then the part of the core library that is neither iteration nor URLs: c:out, c:set, c:remove, c:catch, c:if and c:choose. The running scenario is the Bookshelf, a small catalogue of books; the page ends with a book-detail page that uses every tag described here, then the pitfalls, what changed from JSTL 1.0 to 3.0 and where the books in the section bibliography are out of date. Writing ${...} itself, the implicit objects and the operators are on the expression language basics page and the operators and type coercion page of this section; the iteration, URL, internationalisation, formatting, function, SQL and XML tags have a page each.

What the Standard Tag Library Is

A JSP page that wants to show a list, hide a block for guests or format a price needs logic, and the first generation of JSP put that logic in Java scriptlets. The Standard Tag Library (JSTL, "Jakarta Tags" since Jakarta EE 9) is the answer the platform standardised: a few dozen tags and functions with fixed names, attributes and behaviour, available in every container, so that the template stays markup and the logic is expressed with the expression language. The specification states the goal as helping page authors who are not fluent in a programming language, and lists what it offers: general-purpose actions that complement the expression language (display a value, set and remove scoped attributes, catch exceptions), control-flow actions (conditionals and iterators), tag library validators that can forbid scripting, and standard actions and EL functions for URLs, text formatting, internationalisation, SQL, XML and string handling.

It is a specification with several implementations of the same tag handlers, not a Tomcat feature. The API artifact holds the few Java types an application may touch (Config, ConditionalTagSupport, LoopTagSupport, LoopTag, LoopTagStatus, Result, LocalizationContext and the validators); the implementation artifact holds the tag handlers and the TLD files that map a URI to them. Neither is in the JSP container: Tomcat 11.0.26 has no JSTL jar in its lib directory, so the web application packages both, which is the setup the getting started page of this section builds. Eclipse GlassFish and the other full Jakarta EE servers ship their own.

The Maven coordinates of the Bookshelf (versions of 2026-10-08)
<dependency>
    <groupId>jakarta.servlet.jsp.jstl</groupId>
    <artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
    <version>3.0.2</version>
</dependency>
<dependency>
    <groupId>org.glassfish.web</groupId>
    <artifactId>jakarta.servlet.jsp.jstl</artifactId>
    <version>3.0.1</version>
</dependency>

A page that uses the tags without the jars fails when it is translated, not when it runs. Removing both jars from the WAR of this page gives, for the first request:

org.apache.jasper.JasperException: Unable to get JAR resource [/jakarta.tags.core] containing TLD: [java.io.FileNotFoundException]

The specification text still says that the library requires a "Jakarta Server Pages 3.0 web container" and "Jakarta Expression Language 4.0": those sentences were not updated, and the current implementation runs on Pages 4.0 and EL 6.0.

The Five Libraries and Their URIs

The specification calls it the standard tag library, but it is exposed as five tag libraries, one per functional area, so that each area has its own namespace. A page opens a library with the taglib directive; the uri identifies the library and the prefix is the page author’s choice (the prefixes in the table are the specification’s, and every example of the section uses them).

Table 1. The five libraries of Jakarta Standard Tag Library 3.0 (TLD files and counts read from the 3.0.1 implementation jar)
Functional area URI Prefix TLD in the jar Content Covered in the section

core

jakarta.tags.core

c

META-INF/c.tld

14 tags

general-purpose and conditional tags on this page; iteration, URL, import and redirect tags on their own pages (iteration, URLs)

I18N-capable formatting

jakarta.tags.fmt

fmt

META-INF/fmt.tld

12 tags

the internationalisation and the formatting pages

functions

jakarta.tags.functions

fn

META-INF/fn.tld

16 EL functions, no tags

the functions page

relational database access

jakarta.tags.sql

sql

META-INF/sql.tld

6 tags

the legacy SQL and XML page

XML processing

jakarta.tags.xml

x

META-INF/x.tld

10 tags

the legacy SQL and XML page

Two more URIs live in the same jar, jakarta.tags.scriptfree and jakarta.tags.permittedTaglibs. They are not tag libraries in the usual sense but carriers of two tag library validators (ScriptFreeTLV and PermittedTaglibsTLV, specification chapter 14) that make a page fail translation when it contains scripting elements or imports a library that is not on a list; the Tag Library Descriptors & EL Functions page shows how to configure them.

flowchart TD jstl["Jakarta Standard Tag Library 3.0"] jstl --> core["core - prefix c - jakarta.tags.core"] jstl --> fmt["fmt - prefix fmt - jakarta.tags.fmt"] jstl --> fn["functions - prefix fn - jakarta.tags.functions"] jstl --> sql["sql - prefix sql - jakarta.tags.sql"] jstl --> xml["xml - prefix x - jakarta.tags.xml"] core --> c1["general purpose: out, set, remove, catch"] core --> c2["conditions: if, choose, when, otherwise"] core --> c3["iteration: forEach, forTokens"] core --> c4["URLs: import, url, redirect, param"] fmt --> f1["i18n: setLocale, bundle, setBundle, message, param, requestEncoding"] fmt --> f2["formatting: formatNumber, parseNumber, formatDate, parseDate, timeZone, setTimeZone"] fn --> n1["16 functions: length, escapeXml, substring, split, join and others"] sql --> s1["setDataSource, query, update, transaction, param, dateParam"] xml --> x1["parse, out, set, if, choose, when, otherwise, forEach, transform, param"] classDef here fill:#d6eaf8,stroke:#1f618d,color:#000 class c1,c2 here

The diagram shows the two groups of core tags that this page documents (shaded); the status of the five libraries differs. The Jakarta EE Tutorial says that "a subset of Jakarta Tags has been archived in favor of Jakarta XML Binding, Jakarta Faces and Jakarta Persistence", and that the core tag library and the functions "are still available in Facelets" (the Facelets view technology of Jakarta Faces accepts c: and fn: tags in its templates). In practice core, fmt and functions are the libraries a JSP page uses today, while sql and xml are kept for compatibility and prototyping.

A page that opens all five libraries and uses three of them
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<%@ taglib prefix="fn" uri="jakarta.tags.functions" %>
<%@ taglib prefix="sql" uri="jakarta.tags.sql" %>
<%@ taglib prefix="x" uri="jakarta.tags.xml" %>
<fmt:setLocale value="en_US"/>
core: <c:out value="${dune.title}"/>
fmt:  <fmt:formatNumber value="1234.5" pattern="#,##0.00"/>
fn:   ${fn:length(books)} books, ${fn:toUpperCase(dune.title)}

curl for /g5jstlcore/libs.jsp:

core: Dune
fmt:  1,234.50
fn:   3 books, DUNE

The fmt:setLocale line matters for the second output: without a formatting locale (no Accept-Language header, no fmt:setLocale) the same tag printed the bare 1234.5 and ignored the pattern on Tomcat 11.0.26, which the tag documentation does not say. The formatting page of this section explains how the locale is chosen.

The legacy URIs

Before Jakarta EE 9 the URIs were different and many pages still use them. The 3.0.1 jar ships all of these TLDs, which is why old pages keep compiling, but the specification defines only the jakarta.tags.* URIs.

Table 2. The URIs of the JSTL generations (the last column was observed on Tomcat 11.0.26 with the 3.0.1 jar)
Generation Core URI Behaviour on Tomcat 11.0.26

JSTL 1.0 (JSP 1.2, 2002), "EL" library

http://java.sun.com/jstl/core

translates, but value="${dune.title}" fails with JasperException: ... attribute [value] does not accept any expressions; a literal such as value="Dune" works

JSTL 1.0, _rt library

http://java.sun.com/jstl/core_rt

works with a Java expression: <rt:out value="<%= 1 + 1 %>"/> printed 2

JSTL 1.1 and 1.2 (JSP 2.0 to 2.3, Java EE 5 to 8)

http://java.sun.com/jsp/jstl/core

works exactly like jakarta.tags.core; <c:out value="${dune.title}"/> printed Dune

Jakarta Tags 2.0 and 3.0 (Jakarta EE 9 to 11)

jakarta.tags.core

the current URI; the same pattern gives jakarta.tags.fmt, jakarta.tags.sql, jakarta.tags.xml and jakarta.tags.functions

The first two rows are the books' world: in JSTL 1.0 on JSP 1.2 the container had no expression language, so every library came twice, one version whose attributes were evaluated by the JSTL tag itself (the "EL" library) and an _rt twin whose attributes took Java expressions. JSP 2.0 moved the EL into the container, JSTL 1.1 dropped the twins and added the fn: functions, and the attributes became ordinary request-time attributes. On a current container the 1.0 "EL" library is a trap: it is found, but it rejects ${...}, and nothing in it is worth keeping.

The Bookshelf Used on This Page

The examples need data in scope and a few beans to modify. A ServletContextListener puts the catalogue into application scope, and a filter mapped to *.jsp gives every request a fresh ReaderProfile, an empty map and a StringReader. The pages themselves are plain JSPs in the root of the WAR so that curl can reach them; in the real application of the section they live under WEB-INF/views behind a servlet.

The Bookshelf values used by the examples (shortened)
public record Book(long id, String title, String author, int year) { }

public class ReaderProfile {
    private String name = "guest";
    private String favouriteGenre = "sf";
    private int pageSize = 10;
    private boolean admin;
    private String note;
    // getters, setters (none for the read-only property "readOnly")
    public String getReadOnly() { return "fixed"; }
}

@WebListener
public class ShelfListener implements ServletContextListener {
    @Override
    public void contextInitialized(ServletContextEvent event) {
        var ctx = event.getServletContext();
        var dune = new Book(1, "Dune", "Frank Herbert", 1965);
        var neuro = new Book(2, "Neuromancer", "William Gibson", 1984);
        var disp = new Book(3, "The Dispossessed", "Ursula K. Le Guin", 1974);
        ctx.setAttribute("books", List.of(dune, neuro, disp));
        ctx.setAttribute("dune", dune);
        ctx.setAttribute("catalog", Map.of("dune", dune));
        ctx.setAttribute("tricky", new Book(4, "<script>alert('x')</script> & \"Co\"", "A. Author", 2001));
        ctx.setAttribute("stock", Map.of("1", 3, "2", 0, "3", 1));
        ctx.setAttribute("inventory", Map.of(1L, 3, 2L, 0, 3L, 1));
    }
}

@WebFilter("*.jsp")
public class ProfileFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        req.setAttribute("profile", new ReaderProfile());
        req.setAttribute("prefs", new HashMap<String, Object>());
        req.setAttribute("excerpt", new StringReader("Fear is the mind-killer & <i>nothing</i> else."));
        chain.doFilter(req, res);
    }
}

Every plain-text page starts with <%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>, which leaves out most blank lines but has a side effect worth knowing: trimDirectiveWhitespaces also removes template text that is only white space, so a single space between two ${...} expressions or two tags disappears (${a} ${b} prints ab). The examples therefore separate values with a visible character such as a bar. A name such as nothing that is not defined anywhere evaluates to null.

How a Tag Is Translated, Validated and Run

A tag is not interpreted at request time by looking up its name. The page is translated once, and the translation is where most of the work and most of the errors are:

flowchart LR subgraph T["Translation: once per page change"] d["taglib directive: prefix and URI"] --> r["URI resolved to a TLD in a jar, META-INF/c.tld"] r --> v["attributes checked against the TLD: required, request-time, static"] v --> l["tag library validator JstlCoreTLV checks the page as a whole"] l --> g["Java source calls the tag handler classes"] end subgraph R["Request: every time"] h["handler gets the PageContext and the attributes"] --> e["EL in attributes evaluated"] e --> w["output written to the JspWriter, scoped variables set"] end g --> h

What each step means in practice, in the order Tomcat applies it:

  • The taglib directive binds a prefix to a URI. A URI that no TLD declares is a translation error (the message above). The container finds the TLD in META-INF/*.tld of any jar under WEB-INF/lib; that is the implicit mapping, and no web.xml entry is needed since JSP 2.0.

  • The TLD (for example c.tld, tlib-version 3.0, short-name c) lists every tag with its handler class (org.apache.taglibs.standard.tag.rt.core.OutTag for c:out), its body content type and its attributes with required and rtexprvalue flags. The specification’s "Dynamic" column and the tag documentation’s "Request-time" column are the rtexprvalue flag.

  • Attribute checks happen at translation time: an unknown attribute, a missing required attribute, or an expression in an attribute that does not accept one is an error before anything runs.

  • The validator (org.apache.taglibs.standard.tlv.JstlCoreTLV for the core library) looks at the page as a whole, which is how rules such as the one that requires c:otherwise to be the last child of c:choose can be reported at translation time.

  • The handler runs for every request. It evaluates the EL in its attributes, reads and writes scoped attributes through the PageContext and writes to the current JspWriter.

The tag library specification summarises the error policy in one sentence: syntax errors and constraints on static attributes are reported at translation time, constraints on dynamic attributes at request time. The tags also try to avoid exceptions where a page author could not reasonably handle them: a null where a collection is expected means "do nothing", and a null where a fixed set of values is expected means "use the default". When an exception is raised it is a JspException or a subclass (usually JspTagException), with the original error as its root cause if the tag caught one in its body.

Table 3. Translation-time errors of the core library (each observed on Tomcat 11.0.26)
The page contains Message Why

<c:set var="${param.v}" value="1"/>

JasperException: ... According to TLD or attribute directive in tag file, attribute [var] does not accept any expressions

var and scope are static attributes (specification 2.3)

<c:set var="a" value="1" scope="global"/>

Validation error messages from TagLibraryValidator for [c]: Invalid "scope" attribute in "c:set" tag: "global"

only page, request, session and application

<c:if test="${true}" scope="request">x</c:if>

Illegal scope attribute without var in "c:if" tag.

a constraint of c:if

<c:out default="x"/>

According to the TLD or the tag file, attribute [value] is mandatory for tag [out]

required attribute

<c:out value="x" foo="1"/>

Attribute [foo] invalid for tag [out] according to TLD

unknown attribute

<c:if>x</c:if>

attribute [test] is mandatory for tag [if]

required attribute

<c:remove/>

attribute [var] is mandatory for tag [remove]

required attribute

<c:choose><c:otherwise>o</c:otherwise></c:choose>

Illegal "choose" without child "when" tag

at least one c:when

<c:choose><c:otherwise>o</c:otherwise><c:when test="${true}">w</c:when></c:choose>

Illegal "c:when" after "c:otherwise" tag in "c:choose" tag.

c:otherwise last

<c:choose><c:when test="${true}">w</c:when><c:otherwise>1</c:otherwise><c:otherwise>2</c:otherwise></c:choose>

Illegal "c:otherwise" after "c:otherwise" tag in "c:choose" tag.

at most one c:otherwise

<c:choose>stray text<c:when ...>

Illegal text inside "c:choose" tag: "stray t...".

only whitespace between the children

Two rows of that table are caught only by the validator, not by the attribute check, and one case is not caught at all at translation time: a c:when or c:otherwise outside a c:choose. That page translates and the first request fails with JspTagException: Illegal use of <when>-style tag without <choose> as its direct parent.

Expressions in Attributes

Almost every attribute of a JSTL tag accepts an expression, written with the syntax of the expression language page. Three things from the specification’s chapter 3 decide how the value reaches the tag.

  • Mixed text and expressions. An attribute may contain several ${...} expressions mixed with literal text; the result is a String.

  • Coercion to the attribute type. The tag declares a type (boolean for test, int for begin, Object for value) and the EL converts to it with the rules of the type-coercion section of the EL specification. null becomes 0 for a number and false for a boolean.

  • Defaults instead of errors. The EL turns many failures into type-correct defaults (a missing property is null, a null operand of + is 0) so that a page degrades instead of throwing.

attrs.jsp: mixed text, types of the values stored by c:set, and defaults for missing data
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
1 mixed:   <c:out value="Price of ${dune.title} is ${12.5}"/>
<c:set var="n" value="42"/>
<c:set var="m" value="${42}"/>
<c:set var="mix" value="${1}${2}"/>
<c:set var="two" value="${1 + 1}"/>
2 types:   n=${n.getClass().getSimpleName()} m=${m.getClass().getSimpleName()} mix=${mix.getClass().getSimpleName()}:${mix} two=${two.getClass().getSimpleName()}
3 compare: ${n == 42} | ${n eq '42'} | ${n + 1}
4 default: <c:out value="${profile.note}" default="no note"/> | <c:out value="${dune.title}" default="no title"/>
5 deep:    <c:out value="${nothing.address.city}" default="N/A"/> | <c:out value="${catalog.dune.title}" default="N/A"/> | <c:out value="${catalog.nope.title}" default="N/A"/>
6 checkbox: [<c:forEach var="t" items="${paramValues.tag}">${t},</c:forEach>] none=${empty paramValues.tag}

curl for /g5jstlcore/attrs.jsp, and for /g5jstlcore/attrs.jsp?tag=a&tag=b (only line 6 changes):

1 mixed:   Price of Dune is 12.5
2 types:   n=String m=Long mix=String:12 two=Long
3 compare: true | true | 43
4 default: no note | Dune
5 deep:    N/A | Dune | N/A
6 checkbox: [] none=true
6 checkbox: [a,b,] none=false

Line 2 is the one that surprises: <c:set var="n" value="42"/> stores the String "42", not a number, while value="${42}" stores a Long, and value="${1}${2}" is two expressions in one attribute, so it is text ("12"), not a sum. Line 3 shows why that rarely hurts: the EL converts the String where a number is needed, so ${n + 1} is 43 and ${n == 42} is true. Line 6 is the specification’s own reason for the "null means do nothing" rule: a group of check boxes that nobody ticked sends no parameter at all, ${paramValues.tag} is null, and the loop over it simply prints nothing instead of throwing.

Scoped Variables: var, scope and Visibility

Every JSTL tag that produces a value exposes it the same way: as a scoped variable, a JSP scoped attribute (an attribute of the PageContext, the request, the session or the application, see the implicit objects and scopes page). No tag creates a Java scripting variable, which is what makes the library usable on pages with scripting disabled. The conventions are fixed by chapter 2 of the specification and are the same in every library.

Table 4. The naming and scoping conventions of the specification
Convention Rule

var

the attribute that names the main exported variable (c:set, c:if, c:catch, c:forEach, fmt:formatDate, …​). It is deliberately not called id, to stress that only a scoped variable, not a scripting variable, is exposed

scope

page, request, session or application, with the meaning of jsp:useBean; the default is page. session is allowed only when the page takes part in a session

varStatus, varReader, …​

secondary variables of a tag are var plus a suffix; c:forEach has var for the item and varStatus for the loop status

static var and scope

both attributes take only literal values (no ${...}), which helps tools and is an error otherwise (previous section)

nested variables

visible only inside the tag body, stored in page scope and removed at the end of the tag; a nested variable has no scope attribute (the var and varStatus of c:forEach)

at-end variables

visible after the tag, with the lifetime of their scope; this is the default (c:set, c:if, fmt:formatNumber, …​)

configuration variables

scoped variables named by the specification that tune the behaviour of a library (locale, bundle, time zone, data source); next section

A name in the EL is resolved by PageContext.findAttribute, which looks in page, request, session and application scope, in that order, so scoped variables of different scopes behave as one namespace in which the innermost wins. The Pages and Tags specifications both ask page authors not to rely on the same name in different scopes, and Tomcat follows the lookup order. The loop below shows nested versus at-end variables on the Bookshelf; the shadow lines of the c:set section show the namespace rule.

nested.jsp: a nested loop variable disappears, a c:set inside the loop stays
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:forEach var="b" items="${books}">
  <c:set var="inside" value="in-${b.id}"/>
  loop ${b.id} ${inside}
</c:forEach>
after: b=[${b}] inside=[${inside}]
<c:forEach var="b2" items="${books}" varStatus="st">${st.count}</c:forEach>
after status: [${st}]
  loop 1in-1
  loop 2in-2
  loop 3in-3
after: b=[] inside=[in-3]
123
after status: []

(The missing space in loop 1in-1 is the trimDirectiveWhitespaces effect described above.)

Configuration variables and the Config class

Some tags read their settings from outside the tag: the formatting locale, the resource bundle, the time zone, the default SQL data source. The specification calls the setting a configuration setting and offers two ways to set it: a context initialisation parameter in web.xml (the string value is used for the whole application), or a configuration variable, a scoped variable that overrides it for a page, a request, a session or the application. Because the Pages specification says all scopes should behave as one namespace, a configuration variable is stored under a name that the class jakarta.servlet.jsp.jstl.core.Config manipulates, so that setting one scope does not hide the others; page authors therefore never set configuration variables with c:set but with the tags that own them (fmt:setLocale, fmt:setBundle, fmt:setTimeZone, sql:setDataSource) or from Java with Config.

A servlet filter sets the formatting locale from a request parameter (the addition to the filter above)
String lang = req.getParameter("lang");
if (lang != null) {
    Config.set(req, Config.FMT_LOCALE, Locale.forLanguageTag(lang));
}
config.jsp: the tag knows nothing about the filter
<%@ page contentType="text/plain;charset=UTF-8" %>
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<fmt:formatNumber value="1234.5" type="number"/> | <fmt:formatNumber value="1234.5" type="currency" currencyCode="EUR"/>

curl for /g5jstlcore/config.jsp?lang=de and /g5jstlcore/config.jsp?lang=en-US:

1.234,5 | 1.234,50 €
1,234.5 | €1,234.50
Table 5. The constants of Config and the context parameters they name (read from the 3.0.2 API jar)
Constant Name, also the context parameter Used by

FMT_LOCALE

jakarta.servlet.jsp.jstl.fmt.locale

formatting and i18n tags

FMT_FALLBACK_LOCALE

jakarta.servlet.jsp.jstl.fmt.fallbackLocale

i18n tags

FMT_LOCALIZATION_CONTEXT

jakarta.servlet.jsp.jstl.fmt.localizationContext

fmt:message

FMT_TIME_ZONE

jakarta.servlet.jsp.jstl.fmt.timeZone

date formatting and parsing

SQL_DATA_SOURCE

jakarta.servlet.jsp.jstl.sql.dataSource

SQL tags

SQL_MAX_ROWS

jakarta.servlet.jsp.jstl.sql.maxRows

sql:query

A value of the wrong type is converted with the EL rules and a failed conversion is a JspException at request time, exactly as for an attribute. The same names are valid as context-param entries; the internationalisation page of this section shows them in web.xml.

c:out: Printing and Escaping

c:out evaluates an expression and writes the result to the current JspWriter. The tag documentation describes it as "like <%= ... %>, but for expressions", and the specification’s overview explains what it adds to a bare ${...}: it escapes the five XML special characters by default and supports a default value for null.

Table 6. Attributes of c:out (specification 4.2; the tag documentation lists the types as String)
Attribute Dynamic Required Meaning

value

yes

yes

the expression to print. A java.io.Reader is read and copied instead of converted with toString

escapeXml

yes

no

true (the default) replaces <, >, &, ' and " by character entities; any other value prints the text unchanged

default

yes

no

printed when value is null; it may also be written as the tag body

Table 7. The replacements of escapeXml="true"
Character Entity written

<

&lt;

>

&gt;

&

&amp;

'

&#039;

"

&#034;

out.jsp: every behaviour of c:out on the Bookshelf (request with ?q=<b>x</b>&f=<i>&esc=false, URL-encoded)
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
1 default: <c:out value="${tricky.title}"/>
2 raw:     <c:out value="${tricky.title}" escapeXml="false"/>
3 EL only: ${tricky.title}
4 null:    [<c:out value="${nothing}"/>]
5 default: [<c:out value="${nothing}" default="unknown"/>]
6 body:    [<c:out value="${nothing}">no value for ${dune.id}</c:out>]
7 empty:   [<c:out value="" default="unknown"/>]
8 param:   [<c:out value="${param.q}"/>] raw EL: [${param.q}]
9 mixed:   <c:out value="Title ${dune.title} by ${dune.author}"/>
10 record: <c:out value="${dune}"/>
11 reader: <c:out value="${excerpt}"/>
12 again:  [<c:out value="${excerpt}"/>]
13 num:    <c:out value="${dune.year + 1}"/>
14 flag:   <c:out value="${param.f}" escapeXml="${param.esc}"/>
1 default: &lt;script&gt;alert(&#039;x&#039;)&lt;/script&gt; &amp; &#034;Co&#034;
2 raw:     <script>alert('x')</script> & "Co"
3 EL only: <script>alert('x')</script> & "Co"
4 null:    []
5 default: [unknown]
6 body:    [no value for 1]
7 empty:   []
8 param:   [&lt;b&gt;x&lt;/b&gt;] raw EL: [<b>x</b>]
9 mixed:   Title Dune by Frank Herbert
10 record: Book[id=1, title=Dune, author=Frank Herbert, year=1965]
11 reader: Fear is the mind-killer &amp; &lt;i&gt;nothing&lt;/i&gt; else.
12 again:  []
13 num:    1966
14 flag:   <i>

Reading the output line by line:

  • Lines 1 to 3 are the reason the tag exists. A plain ${tricky.title} is not escaped: the EL writes the value as it is, so a title containing markup becomes markup. c:out turns the same value into text.

  • Line 4: a null value prints nothing; there is no "null" string. Line 5 and 6: default as an attribute or as the body, which is evaluated only when value is null (the body of line 6 was read, the tag has JSP body content).

  • Line 7: the default is used only for null, never for an empty string; use ${empty x ? 'unknown' : x} when "empty" should count as "missing".

  • Line 8: the same parameter, escaped and not. The bare expression is the cross-site scripting hole.

  • Line 10: any object is converted with toString; the record prints its generated representation.

  • Lines 11 and 12: a Reader is consumed. The first tag copies the text (escaped), the second finds the reader at its end and prints nothing, so a Reader can be shown once.

  • Line 14: the value is printed unescaped because the request carried esc=false: escapeXml is itself an expression.

escapeXml is not a boolean check

The tag documentation says the default of escapeXml is true and only false switches it off. The implementation is looser: the attribute is converted to a boolean with the rules of the Pages specification, and the conversion of any text other than "true" is false, so the escaping is lost in cases a reader would not expect.

esc.jsp: what disables escaping on Tomcat 11.0.26
<%@ page contentType="text/plain;charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
typo "flase":     <c:out value="<b>x</b>" escapeXml="flase"/>
upper "FALSE":    <c:out value="<b>x</b>" escapeXml="FALSE"/>
empty "":         <c:out value="<b>x</b>" escapeXml=""/>
null EL:          <c:out value="<b>x</b>" escapeXml="${nothing}"/>
true:             <c:out value="<b>x</b>" escapeXml="true"/>
typo "flase":     <b>x</b>
upper "FALSE":    <b>x</b>
empty "":         <b>x</b>
null EL:          <b>x</b>
true:             &lt;b&gt;x&lt;/b&gt;

The fourth line contradicts the specification’s own rule that a null for an attribute with a fixed set of values "uses the default value" (here true): the EL has already coerced the null to the boolean false before the tag sees it. The practical rule is to leave escapeXml out unless you mean false, never to compute it from request data, and never to spell it with a typo.

c:out, fn:escapeXml and the output context

fn:escapeXml(string) of the functions library does the same replacements as c:out; it is the form to use inside a bigger expression or an attribute value that is built with ${...}. Both produce identical text on the Bookshelf:

escape.jsp: c:out, fn:escapeXml and a quoted attribute
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fn" uri="jakarta.tags.functions" %>
c:out:        <c:out value="${tricky.title}"/>
fn:escapeXml: ${fn:escapeXml(tricky.title)}
attr:         <input value="<c:out value="${tricky.title}"/>">
c:out:        &lt;script&gt;alert(&#039;x&#039;)&lt;/script&gt; &amp; &#034;Co&#034;
fn:escapeXml: &lt;script&gt;alert(&#039;x&#039;)&lt;/script&gt; &amp; &#034;Co&#034;
attr:         <input value="&lt;script&gt;alert(&#039;x&#039;)&lt;/script&gt; &amp; &#034;Co&#034;">

The escaping is HTML-body and quoted-attribute escaping. It is correct for text between tags and for an attribute value in single or double quotes (the quote characters are encoded). It is not correct inside a <script> element or an event handler, in a URL, in a CSS value or in an unquoted attribute, where the browser parses the text with different rules: those need the encoder for that context, such as the OWASP Java Encoder’s forJavaScript and forUriComponent, or the c:url and c:param tags for URLs (see the Security and URL pages). The OWASP cheat sheet lists the contexts and the encoding each needs.

c:set: Creating and Changing Values

c:set assigns a value, and has five forms: it can set a scoped variable from an attribute or from the tag body, set a property of a bean or an entry of a map (target and property), or map a deferred expression.

Table 8. The five syntaxes of c:set (specification 4.3)
Syntax Form Effect

1

<c:set var="x" value="${...}" [scope="..."]/>

sets the variable to the value of the expression

2

<c:set var="x" [scope="..."]>body</c:set>

sets the variable to the body text (trimmed); the body may contain other tags, so a tag’s output becomes a value

3

<c:set target="${bean}" property="p" value="..."/>

sets property p of a JavaBean, or the entry with key p of a Map

4

<c:set target="${bean}" property="p">body</c:set>

as 3 with the body as value

5

<c:set var="x" value="#{expr}"/>

maps a deferred expression into the EL variable mapper; no scope is allowed

Table 9. Attributes of c:set
Attribute Dynamic Type Meaning

value

yes

Object

the expression; the tag documentation lists it as a ValueExpression, which is what makes syntax 5 possible

var

no

String

name of the scoped variable

scope

no

String

scope of var; page by default

target

yes

Object

the bean or map whose property is set; must evaluate to a JavaBean with a setter or to a java.util.Map

property

yes

String

name of the property or of the map key

set.jsp: every form of c:set on the Bookshelf
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:set var="greeting" value="Welcome, ${profile.name}"/>
1 value:    ${greeting}
<c:set var="label">  Book: ${dune.title}  </c:set>
2 body:     [${label}]
<c:set var="n" value="42" scope="request"/>
3 request:  ${requestScope.n} | page:[${pageScope.n}] | ${n + 1}
<c:set var="n" value="page copy"/>
4 shadow:   ${n} / ${requestScope.n}
<c:set var="gone" value="x" scope="session"/>
<c:set var="gone" value="${nothing}" scope="session"/>
5 null removes: [${empty sessionScope.gone}]
<c:set target="${profile}" property="name" value="${dune.author}"/>
<c:set target="${profile}" property="pageSize" value="25"/>
<c:set target="${profile}" property="admin" value="true"/>
6 bean:     ${profile.name} | ${profile.pageSize + 1} | ${profile.admin}
<c:set target="${prefs}" property="theme" value="dark"/>
<c:set target="${prefs}" property="theme2">light</c:set>
7 map:      ${prefs.theme} | ${prefs.theme2} | size=${prefs.size()}
<c:set target="${prefs}" property="theme" value="${nothing}"/>
8 map null: size=${prefs.size()} | ${empty prefs.theme}
<c:set var="title" value="#{dune.title}"/>
9 deferred: ${title}
1 value:    Welcome, guest
2 body:     [Book: Dune]
3 request:  42 | page:[] | 43
4 shadow:   page copy / 42
5 null removes: [true]
6 bean:     Frank Herbert | 26 | true
7 map:      dark | light | size=2
8 map null: size=1 | true
9 deferred: Dune

What the output shows:

  • Line 2: the body is trimmed (the spaces inside the tag are gone) and it is processed first, so EL and other tags in the body run. This is the way to capture the output of another tag into a variable; the Tags specification’s example sets an attribute of a custom tag from nested markup.

  • Lines 3 and 4: the same name in two scopes. The page-scope n created on line 4 hides the request-scope n from the plain EL name, while requestScope.n still reaches the request one. Avoid the situation; if a tag must write a value that a later ${n} reads, give it a distinct name.

  • Line 5: assigning null removes the variable. The same happens for target: line 8 removes the map entry and a bean property is set to null.

  • Lines 6 and 7: a bean property is converted to the property’s type (the text "25" becomes the int 25, so 25 + 1 is 26), and a map entry is created if it does not exist.

  • Line 9: syntax 5 keeps the expression, not its value; the variable title behaves as an alias for #{dune.title} in the later EL of the page. It is the one case where the variable lives in the EL variable mapper rather than in a scope, and it exists so that tags of a deferred-expression library (Jakarta Faces h:inputText, for example) can share one expression.

When c:set fails

The tag is strict about the target of a property. The first form of a missing target is a JspTagException without a message, so wrap uncertain sets in c:catch (below) or check the bean first.

setbad.jsp: errors of syntax 3
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:catch var="e1"><c:set target="${nothing}" property="p" value="1"/></c:catch>
1 null target: ${e1.getClass().getSimpleName()} | [${e1.message}]
<c:catch var="e2"><c:set target="${profile}" property="readOnly" value="1"/></c:catch>
2 no setter:   ${e2.getClass().getSimpleName()} | ${e2.message}
<c:catch var="e3"><c:set target="${profile}" property="pageSize" value="many"/></c:catch>
3 bad value:   ${e3.getClass().getSimpleName()} | ${e3.message}
<c:catch var="e4"><c:set target="${profile}" property="nonsense" value="1"/></c:catch>
4 unknown:     ${e4.getClass().getSimpleName()} | ${e4.message}
1 null target: JspTagException | []
2 no setter:   JspException | No setter method in &lt;set&gt; for property "readOnly"
3 bad value:   JspTagException | jakarta.el.ELException: Cannot convert [many] of type [class java.lang.String] to [int]
4 unknown:     JspTagException | Invalid property in &lt;set&gt;:  "nonsense"

Note that the messages carry the entities <set> literally (the text is not decoded) and name the tag as <set>, not c:set. Two more combinations are worth knowing. A tag with neither var nor target raises a JspTagException when it runs, as does target without property. And a tag that has both var and target silently takes the var path:

<c:set var="a" target="${profile}" property="name" value="x"/>
a=[${a}] name=[${profile.name}]
a=[x] name=[guest]

A property name taken from the request is mass assignment

property is a dynamic attribute, so it can be an expression, and that is a vulnerability when the expression reads request data. This page is a shortcut for "let the form decide what to set":

mass.jsp: do not do this
<c:set target="${profile}" property="${param.prop}" value="${param.v}"/>
name=${profile.name} admin=${profile.admin} pageSize=${profile.pageSize}

curl "http://localhost:18080/g5jstlcore/mass.jsp?prop=admin&v=true":

name=guest admin=true pageSize=10

Any visitor can switch on the admin flag of the bean, or set any other writable property, because the tag will call any setter whose name it is given. This is the JSP form of the "bind every request parameter to the model" problem that Struts had to fix with @StrutsParameter in its own section. The rule: property is always a literal written by the developer, and the value is validated and converted by a controller before it reaches the bean.

c:remove: Removing a Scoped Variable

c:remove is the companion of c:set. Its two attributes (var required, scope optional, both static) name the variable. With a scope only that scope is touched (PageContext.removeAttribute(name, scope)); without it the variable is removed from every scope (removeAttribute(name)). Removing a variable that does not exist is not an error.

remove.jsp: the same name in three scopes
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:set var="cached" value="a" scope="request"/>
<c:set var="cached" value="b" scope="session"/>
<c:set var="cached" value="c"/>
1 before: page=${pageScope.cached} request=${requestScope.cached} session=${sessionScope.cached}
<c:remove var="cached" scope="session"/>
2 session removed: [${sessionScope.cached}] request=${requestScope.cached}
<c:remove var="cached"/>
3 no scope: page=[${pageScope.cached}] request=[${requestScope.cached}] session=[${sessionScope.cached}]
<c:remove var="neverExisted"/>
4 missing var: no error
1 before: page=c request=a session=b
2 session removed: [] request=a
3 no scope: page=[] request=[] session=[]
4 missing var: no error

Line 3 is the surprise of the tag: the one-attribute form clears the name everywhere, including a session attribute that another part of the application may own. Always give the scope of the variable you created. The typical use is a message that must be shown once (see the worked example), and clearing a cached value held in session or application scope.

c:catch: Recovering from Errors

c:catch evaluates its body and catches any Throwable thrown by it. The exception is stored in the variable named by var (always in page scope); without var it is simply swallowed. If the body completes normally the variable is removed if it existed, so a stale error from an earlier catch does not survive. The specification presents it as a complement of the JSP error page: actions that are central to the page should not be wrapped, so their exceptions reach the error page, and actions of secondary importance should be, so that they never trigger it.

catch.jsp: what is caught and what is left behind
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:catch var="err">
before
<c:set var="next" value="${param.page + 1}"/>
after: next is ${next}
</c:catch>
1 err: [${empty err ? 'none' : err.getClass().getName()}] | message: [${err.message}]
<c:catch var="err"><c:set var="ok" value="1"/></c:catch>
2 second catch without an exception: err is [${empty err ? 'removed' : 'kept'}]
<c:catch>text before | <c:set target="${nothing}" property="p" value="1"/></c:catch>
3 no var: the exception was swallowed
<c:catch var="el">${param.page + 1}</c:catch>
4 template EL in the body: [${empty el ? 'none' : el.getClass().getSimpleName()}]
<c:catch var="late"><c:set target="${nothing}" property="p" value="1"/></c:catch>
5 scope: pageScope [${empty pageScope.late ? 'empty' : 'set'}] | requestScope [${empty requestScope.late ? 'empty' : 'set'}]

curl for /g5jstlcore/catch.jsp?page=abc:

before
1 err: [java.lang.NumberFormatException] | message: [For input string: "abc"]
2 second catch without an exception: err is [removed]
text before |
3 no var: the exception was swallowed
4 template EL in the body: [NumberFormatException]
5 scope: pageScope [set] | requestScope [empty]

and for /g5jstlcore/catch.jsp?page=4 (blank lines removed):

before
after: next is 5
1 err: [none] | message: []
2 second catch without an exception: err is [removed]
text before |
3 no var: the exception was swallowed
5
4 template EL in the body: [none]
5 scope: pageScope [set] | requestScope [empty]

Observations that are not in the specification’s text but follow from how the tag is built:

  • The body is not rolled back. Whatever the body wrote before the exception (before, text before |) is already in the response, so a failed tag can leave half a sentence in the page. Put the risky tag first, or capture its output with c:set and print it afterwards.

  • A NumberFormatException raised by an expression in a tag attribute (line 1) and one raised by an expression in plain template text inside the body (line 4) are both caught, because the whole body runs inside the handler’s try.

  • The variable is in page scope (line 5), so it is visible in the rest of the page but not in an included page or in a later request; test it with ${not empty err}.

  • Only failures that happen while the page runs are caught. A translation-time error (the table above) fails the page before c:catch could exist, and a c:catch hides the errors a developer wants to see, so log what you swallow (err.message in a comment or a log call) and keep the body small.

  • c:catch is for expected failures such as "this parameter was not a number". Unexpected failures should go to an error page, see the error handling page of this section.

c:if: One Condition

c:if evaluates its body only when test is true. There is no else: for two branches use c:choose. It also exposes the result of the test, which is how a condition is computed once and used several times.

Table 10. The two syntaxes of c:if (specification 5.3)
Syntax Form Notes

1 without body

<c:if test="..." var="x" [scope="..."]/>

only stores the Boolean result; var is required

2 with body

<c:if test="..." [var="x"] [scope="..."]> body </c:if>

body processed when the test is true; the result is stored if var is given

test is a dynamic boolean. The constraint "if scope is given, var must be too" is checked at translation time.

if.jsp: truthiness, var and the Bookshelf’s stock
<%@ page contentType="text/plain;charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:if test="${dune.year lt 1970}">1 Dune is older than 1970</c:if>
<c:if test="${param.admin}">2 admin param is true</c:if>
<c:if test="${not empty param.q}" var="hasQuery" scope="request"/>
3 hasQuery=${hasQuery} request=${requestScope.hasQuery}
<c:if test="${profile.pageSize gt 5}" var="big">4 big=${big}</c:if>
<c:if test="${nothing}">5 never</c:if>
<c:if test="true">6 literal</c:if>
<c:if test="${'TRUE'}">7 upper TRUE</c:if>
<c:if test="${'yes'}">8 yes</c:if>
<c:if test="${empty books or books[0].year gt 1990}">9 first book newer</c:if>
<c:if test="${stock[param.id] gt 0}" var="inStock">10 in stock: ${stock[param.id]}</c:if>
11 inStock=${inStock}

curl for /g5jstlcore/if.jsp?admin=true&q=a&id=1 (blank lines removed):

1 Dune is older than 1970
2 admin param is true
3 hasQuery=true request=true
4 big=true
6 literal
7 upper TRUE
10 in stock: 3
11 inStock=true

and for /g5jstlcore/if.jsp?id=2&admin=1:

1 Dune is older than 1970
3 hasQuery=false request=false
4 big=true
6 literal
7 upper TRUE
11 inStock=false

How a test becomes a boolean is the EL’s coercion to boolean: a Boolean is itself, null is false, and a String is Boolean.valueOf of its text, true only for "true" in any case. That explains the output: ${param.admin} is true for ?admin=true but false for ?admin=1 (line 2 vanished in the second request), the String "TRUE" is true and "yes" is false, and a missing parameter or an undefined name is false. Prefer an explicit comparison (${param.admin eq 'true'}, or better a boolean the controller computed) to relying on the conversion of a request string. Line 10 shows var on a tag with a body: the Boolean is stored whether or not the body ran, and line 11 reuses it (for the second request, stock['2'] is 0, so the test is false and inStock is false).

The var form is the right tool when a test is needed twice: compute it once, then <c:if test="${canLoan}"> wherever it is needed, as the worked example does.

c:choose, c:when and c:otherwise: Mutually Exclusive Branches

c:choose is the if / else if / else and switch of the library. It has no attributes of its own; its body holds one or more c:when (each with a test) and at most one c:otherwise, in that order, with only white space between them. The first c:when whose test is true is processed, all others are skipped, and c:otherwise runs only when no c:when did.

flowchart TD start["c:choose entered"] --> w1{"test of the first c:when true?"} w1 -- yes --> b1["process body of first c:when"] w1 -- no --> w2{"test of the next c:when true?"} w2 -- yes --> b2["process body of that c:when"] w2 -- no --> more{"more c:when tags?"} more -- yes --> w2 more -- no --> o{"c:otherwise present?"} o -- yes --> bo["process body of c:otherwise"] o -- no --> none["nothing is written"] b1 --> done["leave c:choose, the other bodies are skipped"] b2 --> done bo --> done none --> done

The semantic difference from several c:if tags is the point of the tag: a c:if always processes its body when its test is true, so two true c:if tags both print, whereas two true c:when tags print only the first one’s body. c:when and c:otherwise are only valid as direct children of c:choose, which is also why a custom conditional tag of an application should expose its result as a variable that a c:when can test (ConditionalTagSupport is the base class the API offers for that).

choose.jsp: stock levels and first-match-wins
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:choose>
  <c:when test="${stock[param.id] gt 1}">available (${stock[param.id]} copies)</c:when>
  <c:when test="${stock[param.id] eq 1}">last copy</c:when>
  <c:when test="${stock[param.id] eq 0}">loaned out</c:when>
  <c:otherwise>unknown book ${empty param.id ? '(no id)' : param.id}</c:otherwise>
</c:choose>
fallthrough check:
<c:choose>
  <c:when test="${true}">first</c:when>
  <c:when test="${true}">second</c:when>
</c:choose>
no otherwise, no match:[<c:choose><c:when test="${false}">x</c:when></c:choose>]

curl for /g5jstlcore/choose.jsp?id=1 prints:

available (3 copies)
fallthrough check:
first
no otherwise, no match:[]

and the first line is last copy for ?id=3, loaned out for ?id=2, unknown book 9 for ?id=9 and unknown book (no id) without a parameter; the other three lines never change.

The tests of later branches are still evaluated

Only the bodies are skipped. The test of a c:when is an attribute, and the container evaluates an attribute expression before it calls the tag, so every c:when of a c:choose has its test evaluated even after an earlier branch matched. A test with a side effect runs, and a test that throws aborts the page although its branch could never have won.

lazy.jsp and lazy2.jsp: the second test is evaluated although the first branch matched
<c:choose>
  <c:when test="${true}">first</c:when>
  <c:when test="${not empty prefs.put('second', 'evaluated')}">second</c:when>
</c:choose>
second test evaluated: [${prefs.second}]
<c:if test="${false}">never<c:set target="${prefs}" property="body" value="ran"/></c:if>
body ran: [${prefs.body}]
first
second test evaluated: [evaluated]
body ran: []

With <c:when test="${param.x + 1 gt 0}"> as the second branch, ?x=1 prints first and ?x=abc fails the request (An exception occurred processing [/lazy2.jsp] at line [5], caused by the NumberFormatException of the unneeded test). The body of a c:if whose test is false, in contrast, is not evaluated (body ran: []). Keep tests free of side effects and of anything that can throw; guard a risky test with empty or compute the value in the controller.

The same rules in a table, with the error each violation gives:

Table 11. Constraints of the conditional tags
Rule On violation

c:choose has at least one c:when

translation error Illegal "choose" without child "when" tag

c:otherwise is the last child and at most one

translation error naming the offending tag (table above)

no text other than white space directly inside c:choose

translation error Illegal text inside "c:choose" tag

c:when and c:otherwise have c:choose as direct parent

request-time JspTagException: Illegal use of <when>-style tag without <choose> as its direct parent

c:when has a required, dynamic test

translation error for a missing attribute

a later c:when whose test throws, after an earlier branch matched

the exception is raised anyway (above)

Worked Example: the Book Detail Page

The page below uses every tag of this page. It reads the book id, accepts it only if it is a number and exists, shows the stock level, offers a "borrow" form only when there is a copy, shows the "edit" link only to administrators, and displays a message left by the previous request exactly once. The data it needs is the listener of the setup section and the servlet that handles the form.

book.jsp
<%@ page contentType="text/html;charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:catch var="badId"><c:set var="id" value="${param.id + 0}"/></c:catch>
<c:set target="${profile}" property="name" value="${empty param.reader ? 'guest' : param.reader}"/>
<h1>Bookshelf for <c:out value="${profile.name}"/></h1>
<c:if test="${not empty sessionScope.flash}">
  <p class="flash"><c:out value="${sessionScope.flash}"/></p>
  <c:remove var="flash" scope="session"/>
</c:if>
<c:choose>
  <c:when test="${not empty badId}">
    <p class="error">Invalid book id: <c:out value="${param.id}"/></p>
  </c:when>
  <c:when test="${empty books[id - 1]}">
    <p class="error">No book with id <c:out value="${param.id}"/></p>
  </c:when>
  <c:otherwise>
    <c:set var="book" value="${books[id - 1]}"/>
    <c:set var="copies" value="${inventory[book.id]}"/>
    <c:set var="age" value="${2026 - book.year}"/>
    <h2><c:out value="${book.title}"/> <small>by <c:out value="${book.author}"/></small></h2>
    <p>Published ${book.year}, <c:out value="${age}"/> years ago.
    <c:choose>
      <c:when test="${copies gt 1}">${copies} copies on the shelf.</c:when>
      <c:when test="${copies eq 1}">Last copy!</c:when>
      <c:otherwise>All copies are on loan.</c:otherwise>
    </c:choose></p>
    <c:if test="${copies gt 0}" var="canLoan"/>
    <c:if test="${canLoan}">
      <form method="post" action="${pageContext.request.contextPath}/loan">
        <input type="hidden" name="id" value="<c:out value="${book.id}"/>">
        <button>Borrow</button>
      </form>
    </c:if>
    <c:if test="${profile.admin}"><a href="edit?id=${book.id}">Edit</a></c:if>
  </c:otherwise>
</c:choose>
LoanServlet: the POST that leaves the message (validated id, Post/Redirect/Get)
@WebServlet("/loan")
public class LoanServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        long id;
        try {
            id = Long.parseLong(req.getParameter("id"));
        } catch (NumberFormatException e) {
            resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid book id");
            return;
        }
        req.getSession().setAttribute("flash", "Loan requested for book " + id);
        resp.sendRedirect(req.getContextPath() + "/book.jsp?id=" + id);
    }
}

Responses of the deployed page, with blank lines removed. curl "$B/book.jsp?id=3&reader=Ana", a book with one copy:

<h1>Bookshelf for Ana</h1>
    <h2>The Dispossessed <small>by Ursula K. Le Guin</small></h2>
    <p>Published 1974, 52 years ago.
      Last copy!
    </p>
      <form method="post" action="/g5jstlcore/loan">
        <input type="hidden" name="id" value="3">
        <button>Borrow</button>
      </form>

id=2 (no copy, so no form) and the failure cases:

    <p>Published 1984, 42 years ago.
      All copies are on loan.
    </p>
<p class="error">Invalid book id: abc</p>
<p class="error">No book with id 9</p>
<p class="error">No book with id </p>

for ?id=abc, ?id=9 and no parameter. A reader name is escaped by c:out (?reader=<b>Ana</b> prints <h1>Bookshelf for &lt;b&gt;Ana&lt;/b&gt;</h1>). Posting the form and following the redirect, with a cookie jar so that the session survives, shows the message once:

J=$(mktemp)
curl -s -c $J -b $J -d id=3 -o /dev/null -w '%{http_code}\n' http://localhost:18080/g5jstlcore/loan
curl -s -c $J -b $J http://localhost:18080/g5jstlcore/book.jsp?id=3
curl -s -c $J -b $J http://localhost:18080/g5jstlcore/book.jsp?id=3

The first request answers 302 (sendRedirect; a 303 is the strict Post/Redirect/Get answer, see the servlet fundamentals page of this section) and a post with id=x answers 400. The second request prints the <p class="flash">Loan requested for book 3</p> paragraph above the book, the third request does not, because c:remove cleared the session attribute.

Notes on the page:

  • c:catch as input validation. The only exception the page expects is NumberFormatException from param.id + 0; it turns a bad id into a message and a normal HTTP 200 for the page, where an uncaught exception would be a 500. The better design is a controller servlet that validates the id and answers 400 or 404; the catch keeps the example on one page.

  • c:set for derived values (book, copies, age) so that the expressions are written once and the template reads as a description. The values stay in page scope; nothing leaks to request or session.

  • c:if with var computes canLoan once; the form and any other part of the page that needs it reuse the name.

  • Every user-controlled string goes through c:out: the reader name, the id echoed in the error, the flash message. The numbers (book.year, age) are numbers and need no escaping, so the plain ${book.year} is fine.

  • The flash message is the standard use of c:remove: put a message in the session, show it, remove it with an explicit scope so that nothing else is cleared.

  • State change is a POST with a validated id and a redirect afterwards. The production version also carries a CSRF token, which the servlet fundamentals page demonstrates with a filter.

  • The action uses pageContext.request.contextPath, which is correct for the context path but does not add session rewriting; the URL page of this section covers c:url, which does both.

Security Notes

  • ${...} does not escape; c:out and fn:escapeXml do. Every value that can contain text typed by a user, including a book title read from a database or a request header, belongs in c:out unless it is deliberately markup. Use it in HTML text and in quoted attributes; use an encoder for the other contexts (above).

  • Never switch escaping off with data. escapeXml takes any text, and everything except the word "true" disables it, including a typo, an empty attribute and a null expression. Write escapeXml="false" only on a literal, for text your own code produced, and consider whether it should be a tag file instead.

  • Treat target and property of c:set as code. property must be a literal; a value from the request must be validated and converted before it is stored. target must be an object that the page is allowed to modify: a request or session attribute of the current user, never a shared application-scoped bean or map (a c:set on an application Map changes it for every user and is not thread safe).

  • Do not build an expression from input. A tag attribute such as value="${param.expr}" is safe, because the value is an operand, but passing request text to an API that evaluates it as an expression is EL injection; the library has no such tag, and the spring:eval tag that has is described on the Spring MVC with JSP page of this section.

  • Tags do not make a page safe from scripting. Use the scriptfree validator (or the scripting-invalid property group) to make sure a page that was converted to JSTL stays free of scriptlets; on Tomcat 11.0.26 a page with jakarta.tags.scriptfree declared and one scriptlet fails to translate with Validation error messages from TagLibraryValidator for [scriptfree] …​: JSP page contains 1 scriptlet.

  • JSPs stay under WEB-INF. The examples of this page sit in the context root only so that curl reaches them; a JSP in the public tree can be requested with parameters nobody expected.

  • Keep the jars current. JSTL 3.0.1 has had no release since 2023 and the Tomcat releases carry the security fixes of the container; an application should still follow the Tomcat security pages, for example CVE-2024-52318, "Incorrect JSP tag recycling leads to XSS", fixed in Tomcat 11.0.1 and 10.1.33: it concerns the way the container reuses tag handler instances, which every tag of this library depends on.

Pitfalls and Error Names

Table 12. Core library pitfalls (rows marked "observed" were reproduced on Tomcat 11.0.26)
Symptom or error Cause Fix

a title with < renders as markup, or an alert box appears (observed)

${tricky.title} is not escaped

c:out, or ${fn:escapeXml(...)}

escapeXml="flase", "" or ${nothing} prints raw markup (observed)

anything but "true" is false

omit the attribute; literal false only on trusted text

c:out prints nothing for an empty string with a default (observed)

default applies to null only

${empty x ? 'default' : x}

the second c:out of a Reader is empty (observed)

the reader is consumed

print it once, or hold a String

JasperException: ... attribute [var] does not accept any expressions (observed)

var and scope are static

a literal name

Invalid "scope" attribute in "c:set" tag: "global" (observed)

only page, request, session and application

one of the four

Illegal scope attribute without var in "c:if" tag. (observed)

scope needs var

add var, or drop scope

IllegalStateException: Cannot access session scope in page that does not participate in any session for c:set scope="session" (observed)

the page has session="false"

let the page take part in the session, or use request scope

${n} shows a different value from requestScope.n (observed)

a page-scope variable of the same name hides it

one name, one scope; read requestScope.n explicitly

c:remove without scope deleted a session attribute (observed)

the one-attribute form removes from all scopes

always give scope

JspTagException with an empty message from c:set target (observed)

target evaluated to null

check with c:if, or c:catch

JspException: No setter method in <set> for property "readOnly" (observed)

a property without a setter

add the setter, or set through a command method in the controller

JspTagException ... ELException: Cannot convert [many] ... to [int] (observed)

the value cannot be converted to the property type

validate input first

c:set with var and target changes only the variable (observed)

the var form wins silently

use one form per tag

c:set property="${param.prop}" changes a flag it should not (observed)

a request-chosen property name

literal property names

<c:set var="n" value="42"/> is a String, value="${1}${2}" is "12" (observed)

attribute values are text unless the whole value is one expression

value="${42}"

c:catch leaves half a page of output (observed)

the body is not buffered or rolled back

put the risky tag first, or capture with c:set

c:catch var="e" followed by ${e} on another page is empty

the variable has page scope

test within the same page

<c:if test="${param.admin}"> is false for ?admin=1 (observed)

only "true" is true

compare explicitly, or have the controller compute a boolean

two c:if print twice where c:choose prints once (observed)

each c:if is independent

c:choose

a later c:when with param.x + 1 fails with x=abc although an earlier branch matched (observed)

every test attribute is evaluated before its tag runs

side-effect-free, non-throwing tests; compute risky values first

JspTagException: Illegal use of <when>-style tag without <choose> as its direct parent (observed)

c:when or c:otherwise outside c:choose

wrap them

Illegal text inside "c:choose" tag (observed)

text or tags between the children

keep only white space

${a} ${b} prints ab on a page with trimDirectiveWhitespaces (observed)

the lone space is white-space-only template text

put visible text between expressions

http://java.sun.com/jstl/core rejects ${...} (observed)

the JSTL 1.0 "EL" library evaluates expressions itself

use jakarta.tags.core

Unable to get JAR resource [/jakarta.tags.core] containing TLD (observed)

the JSTL jars are not in WEB-INF/lib

add the API and the implementation

a missing javax.servlet... class (NoClassDefFoundError or ClassNotFoundException) after a move to Tomcat 10 or later

an old javax JSTL jar is still in the WAR

replace it by the jakarta.servlet.jsp.jstl artifacts

a scripting variable of a tag (<%= x %> after c:set) is missing

no JSTL tag creates a scripting variable

use ${x}

What Changed Recently

Table 13. JSTL from 1.0 to 3.1
Version Change

Jakarta Standard Tag Library 3.1 (Jakarta EE 12, under development, milestone 3.1.0-M1/M2)

the release plan names one new feature, support for java.time in fmt:formatDate and fmt:parseDate; no removals or deprecations are listed; the minimum Java version is 21. Not usable on Tomcat 11

Jakarta Standard Tag Library 3.0 (Jakarta EE 10, 2022, still current in EE 11)

the URIs are renamed to jakarta.tags.core, jakarta.tags.fmt, jakarta.tags.sql, jakarta.tags.xml and jakarta.tags.functions; Java 11 is the minimum; the API gets a JPMS module-info; the dependency on the XML Binding API is removed from the API POM

Jakarta Standard Tag Library 2.0 (Jakarta EE 9, 2020)

the package changes from javax.servlet.jsp.jstl to jakarta.servlet.jsp.jstl; applications must recompile or use the Tomcat migration tool

JSTL 1.2 (Java EE 5, 2006)

unified expression language support, which is what allows deferred expressions such as #{...} in c:set

JSTL 1.1 (JSP 2.0, 2003)

the container evaluates EL, so the _rt twin libraries are dropped; the fn: function library is added; URIs become http://java.sun.com/jsp/jstl/*

JSTL 1.0 (JSP 1.2, 2002)

the first release: http://java.sun.com/jstl/* URIs with an "EL" and an _rt library each, EL evaluated inside the tags

Three leftovers in the official material are worth knowing. The Tags 3.0 specification text still refers to the "Jakarta Server Pages 3.0 web container" and "Expression Language 4.0" and to type-conversion "Section 1.23" of the EL specification (it is section 1.25 of EL 6.0). The tag documentation (tagdocs) lists the c:out attributes with the type java.lang.String while the specification says Object, boolean and Object, and its description of c:if still speaks of a "scripting variable", which the library never creates. The Jakarta EE Tutorial calls the SQL, XML and internationalisation parts "archived", but the specification of 3.0 and the 3.0.1 jar still contain them, with the same tags as before.

Where the Books Are Outdated

The section bibliography lists JSTL in Action (Manning, 2002), a JSTL 1.0 book for page authors on JSP 1.2, and Web Development with JavaServer Pages (Manning, 2001), which mentions JSTL only as an announcement. The core-tag concepts that JSTL in Action teaches are still right: c:out with escapeXml and default, c:set and c:remove, c:if, c:choose with c:when and c:otherwise, c:catch, scoped variables and the var/scope conventions. What changed is the environment around them.

Table 14. The books' material and its current equivalent
The books' era (JSTL 1.0, JSP 1.2) Today (JSTL 3.0, Pages 4.0, EL 6.0)

http://java.sun.com/jstl/core and the core_rt twin: EL in the "EL" library, Java expressions in the _rt library

one library per area with jakarta.tags.core, and so on; the javax-era http://java.sun.com/jsp/jstl/* URIs still resolve in the 3.0.1 jar, the 1.0 "EL" URIs do not accept ${...} any more

EL works only inside JSTL attributes, not in template text

container EL everywhere since JSP 2.0, so ${...} is written directly, and c:out is needed for escaping, not for evaluation

c:out as the way to print any value

c:out for untrusted text; ${...} for numbers and trusted values

the _rt libraries for Java expressions such as <%= %>

scripting is switched off per page group with scripting-invalid, and the scriptfree validator can enforce it for pages that import it

c:set target and property on beans and maps as the way to build the model in the page

the model is built by a controller; the page changes a bean only for presentation state, and never with request-chosen names

${param.x} read straight from the request in conditions and loops

validate and convert in a servlet, filter or controller; c:catch for what remains

scripting variables or PageContext attributes read with <%= %> after a tag

scoped variables read with the EL; PageContext.findAttribute order

the 1.0 ExpressionEvaluatorManager of Jakarta Taglibs to evaluate EL in custom tags

the container evaluates the EL in attributes of every tag, classic and simple

Appendix A.1 of JSTL in Action points to chapter 2 for the expression language; the claim that EL does not work in template text

the EL is chapter 3 of the book, and the claim holds only for JSTL 1.0 on JSP 1.2

the javax-era jars of Jakarta Taglibs (standard.jar, later javax.servlet:jstl and org.apache.taglibs:taglibs-standard-*)

jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api 3.0.2 and org.glassfish.web:jakarta.servlet.jsp.jstl 3.0.1; the handler classes still live in the package org.apache.taglibs.standard

SQL and XML tags as a normal part of a page

archived by the platform; use them for prototypes only

References