JSTL Iteration
|
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 (also called Jakarta Tags) 3.0, whose API is
jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api 3.0.2 with the GlassFish implementation
org.glassfish.web:jakarta.servlet.jsp.jstl 3.0.1, running under Jakarta Pages 4.0, Jakarta Expression Language 6.0 and
Jakarta Servlet 6.1 on Apache Tomcat 11.0.26 and Java 21. Everything was verified on 2026-10-08: the rules against chapter 6
("Iterator Actions") and appendix B of the Tags 3.0 specification, the tag reference and the LoopTagStatus Javadoc, and every
example by building it into a Maven WAR (context path /g5jstliteration) and requesting it with curl on Tomcat 11.0.26;
the outputs and error messages shown are the ones that run produced. Iteration is the job that made the tag libraries
worth having: before them a page that listed rows needed a for loop in a scriptlet, a cast of the attribute to its real
type and braces that opened in one scriptlet and closed in another. Two tags replace all of that. c:forEach repeats its body
over an array, a collection, a map, an iterator, an enumeration or a count, and c:forTokens repeats it over the pieces of a
string. Both can hand the body a status object that knows which round is running, which is the raw material of row numbers,
zebra stripes, "first" and "last" styling, comma-separated lists and page links. The running scenario is the Bookshelf, a
small catalogue of books: a servlet puts the books in the request and forwards to a view under WEB-INF. The tags that
sit next to c:forEach in nearly every loop, c:out, c:if and c:choose, are covered with the rest of the core library on
the JSTL Core page of this section; the Expression Language operators the examples use are on the
EL operators and type coercion page, and the ${...} syntax
itself is on Expression Language basics.
The Bookshelf Used on This Page
All the examples are views of one WAR, deployed as g5jstliteration.war under the context path /g5jstliteration. Its Maven
dependencies are the ones of the section’s example application: jakarta.servlet-api 6.1.0, jakarta.servlet.jsp-api 4.0.0
and jakarta.el-api 6.0.1 as provided, and the JSTL API 3.0.2 with the GlassFish implementation 3.0.1 as ordinary
compile-scope dependencies, because Tomcat does not ship JSTL. A servlet mapped to /view/* puts the catalogue and a few
other collections in the request and forwards to /WEB-INF/views/<name>.jsp. A record is a bean for EL 6.0, so the loop variable
reads the Book components as ${b.title}.
package org.example.bookshelf;
public record Book(long id, String title, String author, int year, String category, double price) {
}
@WebServlet("/view/*")
public class ViewServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String view = req.getPathInfo().substring(1);
List<Book> books = List.of(
new Book(1, "Dune", "Frank Herbert", 1965, "fiction", 9.50),
new Book(2, "Neuromancer", "William Gibson", 1984, "fiction", 8.25),
new Book(3, "The Selfish Gene", "Richard Dawkins", 1976, "science", 12.00),
new Book(4, "Cosmos", "Carl Sagan", 1980, "science", 14.90),
new Book(5, "Emma", "Jane Austen", 1815, "fiction", 6.00),
new Book(6, "Pale Blue Dot", "Carl Sagan", 1994, "science", 11.00));
req.setAttribute("books", books); // the six books above
req.setAttribute("emptyBooks", new ArrayList<Book>()); // an empty list
req.setAttribute("titles", new String[] {"Dune", "Emma", "Cosmos"}); // an array of objects
req.setAttribute("sizes", new int[] {3, 1, 2}); // an array of a primitive type
req.setAttribute("categories", new LinkedHashSet<>(List.of("fiction", "science")));
req.setAttribute("books2", books.iterator()); // an Iterator: usable once
req.setAttribute("enumeration", Collections.enumeration(List.of("Dune", "Emma", "Cosmos")));
req.setAttribute("csv", "Dune,Emma,Cosmos"); // a comma separated String
req.setAttribute("tags", "scifi, classic;cyberpunk,,romance"); // a String with mixed delimiters
req.setAttribute("stockHash", hash); // three maps with the same four entries:
req.setAttribute("stockLinked", linked); // science=3, fiction=3, poetry=0, history=2
req.setAttribute("stockTree", tree); // in a HashMap, a LinkedHashMap and a TreeMap
req.setAttribute("byCategory", byCategory); // LinkedHashMap<String, List<Book>>
req.getRequestDispatcher("/WEB-INF/views/" + view + ".jsp").forward(req, resp);
}
}
Every JSP listing below omits the page directive and the taglib directive that start each file
(<%@ taglib prefix="c" uri="jakarta.tags.core" %>). Most views use contentType="text/plain" so that the output can be shown
exactly; the views that produce HTML say so. Because the output is plain text, those listings do not escape anything; in a real
HTML page every value that can contain user text goes through c:out, as the worked example shows.
The Iteration Tags
Iteration lives in the core library, jakarta.tags.core, conventionally with the prefix c. The library has two iteration
tags, and a third one in the XML library that belongs to the legacy part of JSTL.
| Tag | What it repeats the body over | Where to read more |
|---|---|---|
|
an array, a collection, a map, an iterator, an enumeration, a comma separated string, or a range of integers |
this page |
|
the tokens of a string, split with a set of delimiter characters |
this page |
|
the nodes an XPath expression selects in an XML document |
the JSTL SQL and XML page (legacy) |
Both core tags share their range attributes (begin, end, step), their var and varStatus attributes and their
status object. The specification describes c:forEach as the base iteration action and says other iteration actions,
c:forTokens and x:forEach among them, exist for "specific, specialized functionality". The flow below is what the tag does on
every request; the sections that follow take its steps one at a time.
c:forEach: the Attributes
c:forEach has two syntaxes, one that walks a collection and one that counts. They share every attribute except items.
<%-- Syntax 1: iterate over a collection --%>
<c:forEach [var="varName"] items="collection" [varStatus="varStatusName"] [begin="begin"] [end="end"] [step="step"]>
body content
</c:forEach>
<%-- Syntax 2: iterate a fixed number of times --%>
<c:forEach [var="varName"] [varStatus="varStatusName"] begin="begin" end="end" [step="step"]>
body content
</c:forEach>
| Attribute | Type | Expression allowed | Meaning |
|---|---|---|---|
|
any supported collection type |
yes |
the collection to walk; with it the tag uses syntax 1, without it syntax 2. A |
|
|
no, a literal name |
the page-scope variable that holds the current item; its visibility is "nested", that is, the body only. Optional |
|
|
no, a literal name |
the page-scope variable that holds the |
|
|
yes |
with |
|
|
yes |
with |
|
|
yes |
process every |
The tag reference lists begin, end and step as int and items as a jakarta.el.ValueExpression; the specification
adds the constraints, which the container enforces at request time (see the pitfalls table for the exact
exceptions). Three consequences are worth keeping in mind. First, var and varStatus are names, not expressions: writing
var="${name}" is a translation error (observed: "attribute [var] does not accept any expressions"). Second, both are optional, so a loop
that only needs to repeat its body (c:forEach begin="1" end="3" printing a star) can omit them. Third, an attribute that is
an expression is coerced to int by the Expression Language rules, so a request parameter such as ${param.page} works without
parsing, as long as it holds a number.
What items Can Be
The specification lists the types items must support. The container does not make the page author care which one it is: the
body sees "the item", whatever the collection holds.
| Type of the value | How it is walked | The current item |
|---|---|---|
an array of objects |
in index order |
the element |
an array of a primitive type ( |
in index order |
the element wrapped in its wrapper class ( |
a |
in the order of its |
the element |
a |
the order of |
a |
a |
in its own order, once only |
the element |
a |
in its own order, once only |
the element |
a |
as a list of comma separated values, in order |
the token, a |
nothing (syntax 2) |
counting from |
an |
list: <c:forEach var="b" items="${books}" varStatus="s">${b.title}<c:if test="${!s.last}">, </c:if></c:forEach>
array: <c:forEach var="t" items="${titles}" varStatus="s">${t}<c:if test="${!s.last}">, </c:if></c:forEach>
int[]: <c:forEach var="n" items="${sizes}" varStatus="s">${n + 1}<c:if test="${!s.last}">, </c:if></c:forEach>
set: <c:forEach var="k" items="${categories}" varStatus="s">${k}<c:if test="${!s.last}">, </c:if></c:forEach>
enumeration: <c:forEach var="t" items="${enumeration}" varStatus="s">${t}<c:if test="${!s.last}">, </c:if></c:forEach>
empty list: <c:forEach var="b" items="${emptyBooks}">never</c:forEach>(nothing)
missing: <c:forEach var="b" items="${noSuchAttribute}">never</c:forEach>(nothing)
The output of /g5jstliteration/view/collections is below. The int[] loop shows the primitive wrapping, because n + 1 is
arithmetic on an Integer, and the last two lines show the null rule: an empty list and an attribute that does not exist
both iterate zero times, without an error.
list: Dune, Neuromancer, The Selfish Gene, Cosmos, Emma, Pale Blue Dot
array: Dune, Emma, Cosmos
int[]: 4, 2, 3
set: fiction, science
enumeration: Dune, Emma, Cosmos
empty list: (nothing)
missing: (nothing)
Maps: the Item Is an Entry
A map is walked through its entrySet(), so the loop variable is a Map.Entry and the page reads its two properties, key and
value. The order is the order of the map, which the tag does not change: a HashMap has no promised order, a LinkedHashMap
keeps insertion order and a TreeMap sorts by key. The three maps below hold the same four entries.
HashMap: <c:forEach var="e" items="${stockHash}" varStatus="s">${e.key}=${e.value}<c:if test="${!s.last}">, </c:if></c:forEach>
LinkedHashMap: <c:forEach var="e" items="${stockLinked}" varStatus="s">${e.key}=${e.value}<c:if test="${!s.last}">, </c:if></c:forEach>
TreeMap: <c:forEach var="e" items="${stockTree}" varStatus="s">${e.key}=${e.value}<c:if test="${!s.last}">, </c:if></c:forEach>
item class: <c:forEach var="e" items="${stockLinked}" end="0">${e.getClass().name}</c:forEach>
HashMap: fiction=3, science=3, history=2, poetry=0
LinkedHashMap: science=3, fiction=3, poetry=0, history=2
TreeMap: fiction=3, history=2, poetry=0, science=3
item class: java.util.LinkedHashMap$Entry
The HashMap order is neither the insertion order nor the sorted order, and it can differ between Java versions, so a page that
shows a map to people should be given a LinkedHashMap or a TreeMap by the servlet. The same output shows that the item
is the real Map.Entry of the map, not a copy: begin, end and step count entries, and an entry whose value is a list (the
byCategory map of the nested example below) can be iterated again with a second c:forEach.
Iterator and Enumeration: Use Once
An Iterator or an Enumeration cannot be rewound. The specification says so in one sentence, "must be used with caution",
and the next view shows what it means: the second loop over the same Iterator finds it used up and prints nothing, without an
error.
first loop: <c:forEach var="b" items="${books2}" varStatus="s">${b.id}<c:if test="${!s.last}">, </c:if></c:forEach>
second loop: <c:forEach var="b" items="${books2}" varStatus="s">${b.id}<c:if test="${!s.last}">, </c:if></c:forEach>(nothing, the Iterator is used up)
first loop: 1, 2, 3, 4, 5, 6
second loop: (nothing, the Iterator is used up)
The practical rule is to put a List or another Collection in the request, not an Iterator. The one place an Iterator
is natural is a very large result that must not be held in memory; even there, one loop per request is the limit, and an
included fragment that loops again over the same attribute prints nothing. Note that varStatus still reports last
correctly on an Iterator and an Enumeration (the separators above are right): the tag looks ahead.
Types That Are Not Supported
Anything outside the table raises a JspTagException at request time. These three were observed on Tomcat 11.0.26, and all
raise the same message, Don't know how to iterate over supplied "items" in <forEach>.
| items value | What it is | Fix |
|---|---|---|
|
a single |
loop over a collection, or do not loop |
|
a |
end the pipeline with |
|
a number |
use syntax 2, |
The same section explains one absence on purpose: a java.sql.ResultSet is not supported. The SQL tags use their own
jakarta.servlet.jsp.jstl.sql.Result object, and ResultSupport.toResult(resultSet) converts a result set into one; a modern
application does not hand a ResultSet to a page at all but maps rows to beans or records in the servlet or a service. The SQL
tags are on the JSTL SQL and XML page of this section.
A String as items (Deprecated)
When items is a String, c:forEach splits it at commas. The specification marks this deprecated and gives the reason in
a footnote: the proper way to process strings of tokens is c:forTokens, or the split and join functions. It still works
in the 3.0.1 implementation.
string with forEach: <c:forEach var="t" items="${csv}" varStatus="s">[${t}]</c:forEach>
string with forEach: [Dune][Emma][Cosmos]
Use c:forTokens for this, which can split at any characters and not only commas (see
the section on c:forTokens).
Counting Without a Collection
Leave out items and c:forEach becomes a counted loop: begin and end are both required and the current value is an
Integer. The range is inclusive at both ends, which is the opposite of a Java for (int i = 1; i < 5; i++).
counted 1..5: <c:forEach var="i" begin="1" end="5">${i} </c:forEach>
counted step: <c:forEach var="i" begin="0" end="10" step="5">${i} </c:forEach>
item class: <c:forEach var="i" begin="1" end="1">${i.getClass().name}</c:forEach>
counted 1..5: 1 2 3 4 5
counted step: 0 5 10
item class: java.lang.Integer
A counted loop is the right tool for page numbers (begin="1" end="${pages}"), for a fixed number of empty form rows and for
the stars of a rating. It is the wrong tool for walking a list by index (books[i]), because the collection can do that itself.
Taking Part of a Collection: begin, end and step
With items, the range attributes select a slice of the collection without copying it. begin and end are indexes into
the collection, counted from 0, and end is included; step keeps every step-th item of the slice, starting with the first
one. The six books have the ids 1 to 6 and the indexes 0 to 5, so the item with index i prints as the id i + 1 in the table
below.
| Attributes on the six books | Prints | Why |
|---|---|---|
|
2 3 4 |
the indexes 1, 2 and 3: |
|
1 3 5 |
the indexes 0, 2 and 4 |
|
2 4 |
the indexes 1 and 3; 5 is not reached by the step |
|
5 6 |
|
|
nothing |
|
|
nothing |
|
|
1 |
an empty string is coerced to 0, so |
The last row is the one that surprises: an end that is empty, for example end="${param.last}" for a request without that
parameter, is not the same as leaving out end: it is 0. If a limit is optional, test for it in the page or the servlet and leave the attribute
out, or give the expression a default.
varStatus and LoopTagStatus
varStatus names a page-scope variable that holds, for the round that is running, a
jakarta.servlet.jsp.jstl.core.LoopTagStatus. The interface is small, and the page reads it with the JavaBean properties below.
| Property | Type | Meaning |
|---|---|---|
|
|
the 0-based index of the current item in the underlying collection. It is absolute: with |
|
|
the 1-based number of the round, relative to the loop. With |
|
|
true on the first round. It can be true when |
|
|
true on the last round that will run, which depends on |
|
|
the current item, the same object as |
|
|
the value of the |
|
|
the value of the |
|
|
the value of the |
begin=2 end=5 step=2 over six books
index count first last current begin end step
<c:forEach var="b" items="${books}" begin="2" end="5" step="2" varStatus="s">${s.index} ${s.count} ${s.first} ${s.last} ${s.current.title} ${s.begin} ${s.end} ${s.step}
</c:forEach>
no range attributes: <c:forEach items="${books}" varStatus="s"><c:if test="${s.first}">begin=[${s.begin}] end=[${s.end}] step=[${s.step}] empty(begin)=${empty s.begin}</c:if></c:forEach>
last with end=2: <c:forEach var="b" items="${books}" end="2" varStatus="s">${b.id}:${s.last} </c:forEach>
status class: <c:forEach items="${books}" end="0" varStatus="s">${s.getClass().name}</c:forEach>
begin=2 end=5 step=2 over six books
index count first last current begin end step
2 1 true false The Selfish Gene 2 5 2
4 2 false true Emma 2 5 2
no range attributes: begin=[] end=[] step=[] empty(begin)=true
last with end=2: 1:false 2:false 3:true
status class: jakarta.servlet.jsp.jstl.core.LoopTagSupport$1Status
The output shows the four things that trip people. The two rounds are the indexes 2 and 4, so index and count differ (index
is for "which item of the list", count is for "which row of the output"). last is true on the index 4 round even though
end is 5, because the next step would be index 6. begin, end and step are null when the attributes were not given,
and null prints as an empty string, so a page that needs a default writes ${empty s.end ? 'none' : s.end}. Finally, the object
is an instance of an internal class of the implementation: depend on the interface and its properties, not on that class.
The status variable is a convenience, not a requirement: the loop does not pay for it unless varStatus is present, and
nothing in LoopTagStatus can be changed from the page. The interface is the same one that custom loop tags expose, as the
section on tag collaboration explains.
A Worked Example: the Bookshelf Catalogue
Most pages use c:forEach for exactly one job, a table. The catalogue below shows the pieces together: an empty-state branch
with c:choose, a table whose row classes come from varStatus (zebra stripes through count, and the first and last
rows marked for the style sheet), values escaped with c:out, and a second loop that builds a comma-separated list with
last deciding where the separators go.
<c:choose>
<c:when test="${empty books}">
<p>The shelf is empty.</p>
</c:when>
<c:otherwise>
<table>
<tr><th>#</th><th>Title</th><th>Author</th></tr>
<c:forEach var="b" items="${books}" varStatus="s">
<tr class="${s.count % 2 == 0 ? 'even' : 'odd'}${s.first ? ' first' : ''}${s.last ? ' last' : ''}">
<td>${s.count}</td>
<td><c:out value="${b.title}"/></td>
<td><c:out value="${b.author}"/></td>
</tr>
</c:forEach>
</table>
<p>${books.size()} books by
<c:forEach var="a" items="${books}" varStatus="s"><c:out value="${a.author}"/><c:if test="${!s.last}">, </c:if></c:forEach>
</p>
</c:otherwise>
</c:choose>
The rendered HTML for the six books (the rows 3 to 5, which are the same as the others with their own values, are left out). The
<tr> classes alternate odd and even, and the first and last rows carry the extra class.
<table>
<tr><th>#</th><th>Title</th><th>Author</th></tr>
<tr class="odd first">
<td>1</td>
<td>Dune</td>
<td>Frank Herbert</td>
</tr>
<tr class="even">
<td>2</td>
<td>Neuromancer</td>
<td>William Gibson</td>
</tr>
... rows 3 to 5 ...
<tr class="even last">
<td>6</td>
<td>Pale Blue Dot</td>
<td>Carl Sagan</td>
</tr>
</table>
<p>6 books by
Frank Herbert, William Gibson, Richard Dawkins, Carl Sagan, Jane Austen, Carl Sagan</p>
With the books replaced by the empty list the same page prints the other branch, <p>The shelf is empty.</p>. A few details
of the page are worth naming. The stripes use count, not index, so they stay correct when the loop starts at a begin
other than 0. The first and last classes are the clean way to put a border on the edges of a list; no loop counter has to be
compared with the size. The row number is the count too. And the second loop is the standard separator idiom, with the comma
printed for every round except the last one. A zebra pattern with CSS (tr:nth-child(even)) needs no JSP at all and is the
better choice for a plain table, but a class on the row is what a page needs when the stripes depend on something other than the
position, such as a group boundary.
Paging
A catalogue of six books fits on a page; a catalogue of six thousand does not. The two designs below differ in where the slice is taken, and the difference decides whether the application scales.
Paging in the View With begin and end
The view receives the whole list and uses begin and end to show one page of it. The page number and size come from request
parameters, and the page links are a counted loop.
<c:set var="size" value="${empty param.size ? 4 : param.size}"/>
<c:set var="page" value="${empty param.page ? 1 : param.page}"/>
<c:set var="total" value="${books.size()}"/>
<c:set var="pages" value="${Math.ceil(total div size)}"/>
<ul>
<c:forEach var="b" items="${books}" begin="${(page - 1) * size}" end="${page * size - 1}" varStatus="s">
<li>${s.index + 1}. <c:out value="${b.title}"/></li>
</c:forEach>
</ul>
<p>Page ${page} of ${pages.intValue()}:
<c:forEach var="p" begin="1" end="${pages}">
<c:choose>
<c:when test="${p == page}"><strong>${p}</strong></c:when>
<c:otherwise><a href="?page=${p}&size=${size}">${p}</a></c:otherwise>
</c:choose>
</c:forEach>
</p>
GET /g5jstliteration/view/paging?page=2&size=4 returns the second page, and ?page=2&size=2 the second of three pages.
<ul>
<li>5. Emma</li>
<li>6. Pale Blue Dot</li>
</ul>
<p>Page 2 of 2:
<a href="?page=1&size=4">1</a><strong>2</strong></p>
The page is short, but it is fragile in three ways, all observed. A page or size that is not a number ends in a
jakarta.el.ELException (Cannot convert [x] of type [class java.lang.String] to [int]), because the parameters feed begin and
end directly. A page beyond the last, ?page=9, gives an empty list with the heading "Page 9 of 2". And the servlet had to
load every book to show four of them, which is fine for an in-memory list and a disaster for a table with a million rows. The
Math.ceil call uses the EL 3.0 rule that the classes of java.lang are imported by default; it needs no tag.
Paging in the Servlet, the View Only Loops
The design that scales takes the slice before the view: the servlet validates the page and size, asks the data source (a SQL
LIMIT/OFFSET, a Spring Data Pageable, a subList) for one page, and puts only that page in the request. The view then has
no begin or end at all, because the list is already the page.
@WebServlet("/paged")
public class PagedServlet extends HttpServlet {
private static final int MAX_SIZE = 50;
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
List<Book> all = /* the six books */;
int size = clamp(parse(req.getParameter("size"), 4), 1, MAX_SIZE);
int pages = Math.max(1, (all.size() + size - 1) / size);
int page = clamp(parse(req.getParameter("page"), 1), 1, pages);
int from = (page - 1) * size;
req.setAttribute("books", all.subList(from, Math.min(from + size, all.size())));
req.setAttribute("from", from);
req.setAttribute("page", page);
req.setAttribute("pages", pages);
req.setAttribute("size", size);
req.getRequestDispatcher("/WEB-INF/views/paged.jsp").forward(req, resp);
}
private static int parse(String value, int fallback) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return fallback;
}
}
private static int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
}
<ol start="${from + 1}">
<c:forEach var="b" items="${books}">
<li><c:out value="${b.title}"/></li>
</c:forEach>
</ol>
<p>Page ${page} of ${pages}:
<c:forEach var="p" begin="1" end="${pages}">
<c:choose>
<c:when test="${p == page}"><strong>${p}</strong></c:when>
<c:otherwise><a href="?page=${p}&size=${size}">${p}</a></c:otherwise>
</c:choose>
</c:forEach>
</p>
GET /g5jstliteration/paged?page=2&size=4 prints the same second page as above, now with the list number taken from the servlet.
A hostile ?page=x&size=-3 is clamped to the first page of one-book pages, and the view cannot be made to run away:
<ol start="5">
<li>Emma</li>
<li>Pale Blue Dot</li>
</ol>
<p>Page 2 of 2:
<a href="?page=1&size=4">1</a><strong>2</strong></p>
<ol start="1">
<li>Dune</li>
</ol>
<p>Page 1 of 6:
<strong>1</strong><a href="?page=2&size=1">2</a><a href="?page=3&size=1">3</a>
... pages 4 to 6 ...
The second pattern is the one to copy. The first remains useful for what it is: slicing a list the view already has (a "show
the first five" teaser, begin="0" end="4"), where the slice is a presentation choice rather than a query. The Thymeleaf
equivalent of this whole section is on the displaying data, pagination and sorting page.
c:forTokens
c:forTokens splits a string and repeats its body once per token. It has the same begin, end, step, var and
varStatus attributes as c:forEach, with two more that matter: items is the string, and delims is the set of
delimiter characters.
| Attribute | Required | Meaning |
|---|---|---|
|
yes |
the string to split. A |
|
yes |
the characters that separate tokens; every character in the string is a delimiter on its own. A |
The tokens come from a java.util.StringTokenizer built with items and delims, as the specification states. Two properties
follow from that. delims is a set of characters, not a separator string, so delims=", " splits at a comma and at a space
and not at the two characters together. And a token is "a maximal sequence of consecutive characters that are not delimiters":
two delimiters in a row do not produce an empty token between them. The empty field in scifi, classic;cyberpunk,,romance
disappears, and so does the space after the first comma, because the space is a delimiter too.
simple: <c:forTokens var="t" items="a,b,c" delims=",">[${t}]</c:forTokens>
two delimiters: <c:forTokens var="t" items="${tags}" delims=",; ">[${t}]</c:forTokens>
range: <c:forTokens var="t" items="${tags}" delims=",; " begin="1" end="2">[${t}]</c:forTokens>
step: <c:forTokens var="t" items="${tags}" delims=",; " step="2">[${t}]</c:forTokens>
null delims: <c:forTokens var="t" items="${tags}" delims="${nothing}">[${t}]</c:forTokens>
null items: <c:forTokens var="t" items="${nothing}" delims=",">[${t}]</c:forTokens>(nothing)
status: <c:forTokens var="t" items="${tags}" delims=",; " varStatus="s">${s.count}/${s.index}/${s.last}:${t} </c:forTokens>
simple: [a][b][c]
two delimiters: [scifi][classic][cyberpunk][romance]
range: [classic][cyberpunk]
step: [scifi][cyberpunk]
null delims: [scifi, classic;cyberpunk,,romance]
null items: (nothing)
status: 1/0/false:scifi 2/1/false:classic 3/2/false:cyberpunk 4/3/true:romance
The range and step lines show that begin, end and step count tokens, and the null delims line shows the single
token. Leaving delims out is not a null, it is a mistake the translator reports: According to the TLD or the tag file,
attribute [delims] is mandatory for tag [forTokens] (observed). In the Bookshelf the tag fits the places where one column
holds a small list: a tags or keywords column, a space separated CSS class list, a path split at /. For anything
where an empty field is meaningful, such as a CSV row with a blank column, c:forTokens is the wrong tool: the fn:split
function also drops the empty field (fn:split('a,b,,c', ',') gave [a][b][c] in the same run), and real CSV needs a parser in
Java, not a tag. The JSTL Functions page of this section covers split and join.
Nested Loops and the Scope of var
A loop inside a loop is the normal way to show grouped data. The servlet builds a Map<String, List<Book>>; the outer loop walks
the entries and the inner loop walks each entry’s list. Each loop has its own varStatus, which is how a heading can say
"1. fiction" and the rows below it "1.1", "1.2" and so on.
<c:forEach var="entry" items="${byCategory}" varStatus="outer">
${outer.count}. ${entry.key} (${entry.value.size()} books)
<c:forEach var="b" items="${entry.value}" varStatus="inner">
${outer.count}.${inner.count}: ${b.title} (${b.price})
</c:forEach>
</c:forEach>
1. fiction (3 books)
1.1: Dune (9.5)
1.2: Neuromancer (8.25)
1.3: Emma (6.0)
2. science (3 books)
2.1: The Selfish Gene (12.0)
2.2: Cosmos (14.9)
2.3: Pale Blue Dot (11.0)
(The view uses trimDirectiveWhitespaces, which is why the output has no blank lines.)
The variables var and varStatus are exposed in page scope with "nested visibility": they are there while the body runs and
gone after the end tag. The specification adds a warning that matters for names. The nested variable always goes in page
scope, and a variable with the same name in another scope may or may not be affected, depending on the container, which is
why page authors should avoid the same name in different scopes. On Tomcat 11.0.26 with the 3.0.1 implementation the
behaviour is the following, all observed.
<c:set var="b" value="outer value"/>
<c:set var="b" value="request value" scope="request"/>
before: ${pageScope.b} / ${requestScope.b}
inside: <c:forEach var="b" items="${books}" end="1" varStatus="s">${b.id}${s.last ? '' : ','}</c:forEach>
after: ${pageScope.b} / ${requestScope.b}
var after the loop: <c:forEach var="x" items="${books}" end="1">${x.id}</c:forEach> x=[${x}] pageScope.x=[${pageScope.x}]
before: outer value / request value
inside: 1,2
after: / request value
var after the loop: 12 x=[] pageScope.x=[]
The loop used the name b while a page-scope b already existed, and when the loop ended the page-scope variable was gone: it was
not restored to "outer value". The request-scope b was untouched. The same thing happens when two loops nest and reuse a name,
which is an easy mistake in a copy-pasted template:
<c:forEach var="x" items="${categories}" varStatus="s">outer=${x}; <c:forEach var="x" items="${sizes}">inner=${x} </c:forEach>after inner: x=[${x}]
</c:forEach>
outer=fiction; inner=3 inner=1 inner=2 after inner: x=[]
outer=science; inner=3 inner=1 inner=2 after inner: x=[]
The inner loop overwrote x and, when it finished, removed it, so the outer x is empty after the inner loop although the outer
loop is still running. Choose distinct names for every loop in a view, including the varStatus names, and a var name that no
other attribute uses.
Deferred Expressions in items
The specification has a section for items written as a deferred expression, #{customer.children}. The reason is Jakarta
Faces: a component such as h:inputText inside the loop needs the iteration variable as a deferred value with the right index,
and the tag can only provide that when it sees the original expression. The specification also notes that the number of items
must be the same when Faces builds the component tree and when the loop runs. In a plain JSP page there is no such component,
and an immediate ${books} is what to write. For the record, items="#{books}" is accepted by the 3.0.1 implementation on Tomcat
11.0.26 and prints the same six books (observed: 123456 for ${b.id}), so a deferred items does not fail in a JSP page, but it
gains nothing there.
Tag Collaboration and Custom Loop Tags
c:forEach implements jakarta.servlet.jsp.jstl.core.LoopTag, and its status object is the LoopTagStatus described above. This
is what the specification calls tag collaboration: a custom tag in the body of the loop can find the loop with
TagSupport.findAncestorWithClass(this, LoopTag.class) and ask it for the current item (getCurrent()) or the status
(getLoopStatus()) without the page author passing anything. The specification’s example is a pair of tags, acme:odd and
acme:even, that print their body only on odd or even rounds, replacing a c:choose on ${status.count % 2 == 0}. The same
interface lets a developer write a new iteration tag that collaborates in the same way: the base class LoopTagSupport does the
bookkeeping of begin, end, step, var and varStatus, and the subclass only supplies the items (prepare(),
hasNext() and next()). The LoopTag Javadoc warns not to confuse the interface with the Pages interface
jakarta.servlet.jsp.tagext.IterationTag; they are different things. How to write a tag that uses either one is the subject of
the classic and SimpleTag custom tag pages of this section.
Alternatives to a Loop in the View
A loop tag is not the only way to turn a collection into markup, and sometimes it is not the best one.
| Technique | When it fits | Status |
|---|---|---|
|
the normal case: tables, lists, option lists, page links |
current, the recommended way |
|
a short string with delimiters |
current |
an EL stream, |
filtering or sorting a small list for one place in a page |
current since EL 3.0, see below |
the Struts |
iterating the value stack in a Struts 2 view |
current in Struts 7, on the Struts tags page of this section |
a scriptlet |
only in code that cannot be changed yet |
legacy, disabled by |
Thymeleaf |
the same job in a Thymeleaf template |
current, see Iteration and conditionals |
The EL route deserves a worked example, because it removes the need for a helper in the servlet when a view wants a derived
list. A stream is not a supported items type (see unsupported types), but an EL pipeline that ends in
toList() is a List, and c:forEach accepts it.
cheap, cheapest first: <c:forEach var="b" items="${books.stream().filter(b -> b.price lt 10).sorted((a, b) -> a.price lt b.price ? -1 : 1).toList()}" varStatus="s">${b.title} (${b.price})<c:if test="${!s.last}">, </c:if></c:forEach>
cheap, cheapest first: Emma (6.0), Neuromancer (8.25), Dune (9.5)
The comparator returns an int, so a - of two prices would be coerced to an integer and lose the fraction; the conditional
returns -1 or 1 instead. Stream pipelines in a view are best kept this short; anything longer is business logic that belongs in the
servlet, which is argued, with every operation, on the EL lambdas, collections and streams page.
For comparison, this is the same loop as a scriptlet, the form that c:forEach replaced. It is shown only as a "before" snippet:
the scriptlet needs the page directive’s import, an unchecked cast, a loop that is open across three scriptlet blocks and
output through <%= %>, and it cannot be styled or maintained by a front-end developer.
<%@ page import="java.util.List,org.example.bookshelf.Book" %>
<% for (Book b : (List<Book>) request.getAttribute("books")) { %><%= b.title() %>;<% } %>
Dune;Neuromancer;The Selfish Gene;Cosmos;Emma;Pale Blue Dot;
The scriptlet works, which is why so many old pages still have one, but the tag form is shorter, type-safe in the sense that no
cast is written, and can be switched to by setting scripting-invalid in the jsp-config of the application. The scripting page
of this JSP section covers the migration.
Security and Performance
Two things go wrong with loops in views, and both have the same cause: letting the request decide how much work the page does.
-
Request-controlled limits. In
c:forEach begin="1" end="${param.n}"the loop count is whatever the visitor sends. The counted form (noitems) has no collection to stop it:?n=100000printed 100,000 stars (a body of 100,002 bytes with the line breaks) in a single request on Tomcat 11.0.26, and a larger value costs proportionally more time and bandwidth. Validate and clamp the number in the servlet, asPagedServletdoes withMAX_SIZE, and do not feed a raw parameter toend. -
Unescaped output. The Expression Language writes a value as it is.
${b.title}in an HTML page breaks the markup for a title such asFish & Chipsand runs script for a title that contains a tag. Inside a loop, which repeats the mistake for every row, usec:out(which escapes<,>,&,'and"by default) or the escaping the application standardises on. The worked example does this for every title and author. -
Work in the view. A loop that calls a method per item, a lazy association per item or a query per item is the N+1-queries problem in its worst place, because the view hides it. Load what the page needs in the servlet or a service and hand it over as plain beans or records.
-
Memory.
c:forEachover a 100,000-element list is just 100,000 iterations of the body, but the list has to exist first. Page before the view (see paging in the servlet) instead of paging inside it.
Pitfalls and Error Names
| Symptom or error | Cause | Fix |
|---|---|---|
a second loop over the same attribute prints nothing (observed) |
the attribute is an |
put a |
|
a negative |
compute the value and clamp it at 0 |
|
a |
|
|
a literal |
use a number, or an expression |
|
|
parse and clamp the parameter in the servlet |
|
|
use a |
the loop prints one item for an empty |
an empty attribute is coerced to 0 |
leave |
the last page of a list is empty or missing |
a page size that does not divide the total, with |
use |
a variable disappeared after a loop (observed) |
the loop |
give every loop its own |
|
|
parse real CSV in Java |
|
|
always give |
|
|
use one character, or accept the set |
the stripes break when the loop starts at |
the page used |
use |
a gap or missing space between items |
|
put the separator in a text that has other characters, or use |
a map in a different order after an upgrade |
the servlet passed a |
use a |
|
a JSF-era Facelets namespace (JSF 2.2 and later) used as a JSP taglib URI; JSP pages of Java EE 7 and 8 used |
use |
broken HTML or script in a row |
a loop printed |
|
a huge page or a slow request |
a loop bound taken from the request, or a whole table loaded to show one page |
clamp the limits and page in the servlet |
What Changed Recently
| Release | Change |
|---|---|
JSTL 1.0 / 1.1 / 1.2 |
the iteration tags ( |
Jakarta Standard Tag Library 2.0 (Jakarta EE 9) |
the API moved from the |
Jakarta Standard Tag Library 3.0 (Jakarta EE 10) |
the tag URIs were renamed to |
Jakarta Tags 3.1.0-M2 (milestone) |
published on Maven Central, not final |
GlassFish implementation 3.0.1 |
the latest implementation, dated 2023-01-19, still the one used on this page |
Jakarta Expression Language 3.0 and later |
streams, lambdas and collection literals in EL, which give |
Jakarta Expression Language 6.0 |
records are beans, so a |
The old URI http://java.sun.com/jsp/jstl/core still resolved on this stack: the 3.0.1 JAR ships the TLDs of earlier releases
(c-1_2.tld for http://java.sun.com/jsp/jstl/core and the JSTL 1.0 files c-1_0.tld and c-1_0-rt.tld), as well as the current
c.tld with the URI jakarta.tags.core. That is an implementation courtesy, not something the 3.0 specification promises. The
http://xmlns.jcp.org/jsp/jstl/core URI, the JSF-era namespace that Facelets (JSF 2.2 and later) used and never a JSP taglib URI,
does not resolve: JSP pages of Java EE 7 and 8 still used http://java.sun.com/jsp/jstl/core. Use jakarta.tags.core in new pages.
Where the Books Are Outdated
The books this section draws on are about JSTL 1.0, 1.1 and 1.2 on Servlet 2.3 to 3.x containers. Their description of the loop tags is still correct as behaviour; what has moved is everything around it.
| In the books | Today |
|---|---|
|
|
the rt-library prefix, |
only the one library, whose attributes take EL expressions; the rt TLDs of the 1.0 era are historical |
the package |
|
a |
beans or records prepared by the servlet or a service; the SQL tags are legacy |
a comma separated |
deprecated by the specification; use |
paging by |
the servlet slices the data (a query with a limit, a |
"zebra striping" by alternating |
a conditional expression in the class attribute, or CSS |
iterating a |
|
scriptlets as the alternative to compare with |
EL, EL streams, and |
varStatus properties |
unchanged, with |
Publisher pages of the two books that cover the loops are listed in the references for readers who want the original explanations; their code needs the changes in the table before it will run on Tomcat 11.
References
-
Jakarta Standard Tag Library 3.0 — 6 Iterator Actions: core tag library
-
Jakarta Standard Tag Library 3.0 — 6.1.1 Collections of Objects to Iterate Over
-
Jakarta Standard Tag Library 3.0 — B.2 Changed between 2.0 and JSR-52
-
Jakarta Expression Language 6.0 — 2.3.1 Streams and Pipelines
-
Manning — Web Development with JavaServer Pages, Second Edition