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

Book, the record the loops read
package org.example.bookshelf;

public record Book(long id, String title, String author, int year, String category, double price) {
}
ViewServlet, the request attributes the views below read (imports and the view-name check omitted)
@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.

Table 1. The iteration tags of Jakarta Tags 3.0
Tag What it repeats the body over Where to read more

c:forEach

an array, a collection, a map, an iterator, an enumeration, a comma separated string, or a range of integers

this page

c:forTokens

the tokens of a string, split with a set of delimiter characters

this page

x:forEach

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.

flowchart TD start["<c:forEach> starts"] --> hasItems{"items attribute present?"} hasItems -- yes --> pick["Pick the iterator by type: array, Collection, Map entries, Iterator, Enumeration, String of commas"] hasItems -- no --> count["Count from begin to end (both required), the item is an Integer"] pick --> range["Skip to begin, stop after end, take every step-th item"] count --> round range --> round["Each round: set var and varStatus in page scope, run the body"] round --> more{"More items?"} more -- yes --> round more -- no --> clean["Remove var and varStatus from page scope"]

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>
Table 2. The attributes of c:forEach (the Dyn column of the specification is the "accepts an expression" column)
Attribute Type Expression allowed Meaning

items

any supported collection type

yes

the collection to walk; with it the tag uses syntax 1, without it syntax 2. A null value is an empty collection and nothing is iterated

var

String

no, a literal name

the page-scope variable that holds the current item; its visibility is "nested", that is, the body only. Optional

varStatus

String

no, a literal name

the page-scope variable that holds the LoopTagStatus of the current round; also nested. Optional

begin

int

yes

with items: the index of the first item to process (the first item has index 0). Without items: the first value of the counter. Must be 0 or more

end

int

yes

with items: the index of the last item to process, inclusive. Without items: the last value of the counter, inclusive. If it is less than begin the body never runs

step

int

yes

process every step-th item, starting with the first one. Must be 1 or more

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.

Table 3. The types accepted by items, and what the current item is
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 (int[])

in index order

the element wrapped in its wrapper class (Integer for int)

a java.util.Collection (List, Set, Queue, …​)

in the order of its iterator()

the element

a java.util.Map

the order of entrySet().iterator()

a java.util.Map.Entry, read as ${e.key} and ${e.value}

a java.util.Iterator

in its own order, once only

the element

a java.util.Enumeration

in its own order, once only

the element

a String (deprecated)

as a list of comma separated values, in order

the token, a String

nothing (syntax 2)

counting from begin to end

an Integer

collections.jsp, one loop per kind of items (varStatus is used only to write the separators)
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.

maps.jsp
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.

iterator.jsp
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

${books.get(0)}

a single Book, not a collection

loop over a collection, or do not loop

${books.stream()}

a java.util.stream.Stream

end the pipeline with toList() in the servlet or in EL (see the alternatives section)

${5}

a number

use syntax 2, begin="1" end="5"

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

range.jsp (first lines)
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.

Table 4. The slices observed (the "prints" column lists the book ids, which are the indexes plus one)
Attributes on the six books Prints Why

begin="1" end="3"

2 3 4

the indexes 1, 2 and 3: end is inclusive

step="2"

1 3 5

the indexes 0, 2 and 4

begin="1" end="4" step="2"

2 4

the indexes 1 and 3; 5 is not reached by the step

begin="4" end="99"

5 6

end past the size just stops at the last item

begin="3" end="1"

nothing

end is less than begin: the loop does not run, no error

begin="6"

nothing

begin is the size of the list or more: the loop does not run

end="" (an empty attribute)

1

an empty string is coerced to 0, so end is 0: one item, index 0

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.

Table 5. The properties of LoopTagStatus
Property Type Meaning

index

int

the 0-based index of the current item in the underlying collection. It is absolute: with begin="2" the first round has index 2

count

int

the 1-based number of the round, relative to the loop. With begin="5" end="15" step="5" the rounds count 1, 2 and 3

first

boolean

true on the first round. It can be true when index is not 0, and a true first always means count is 1

last

boolean

true on the last round that will run, which depends on end and step, not only on the size of the collection

current

Object

the current item, the same object as var. Reading it does not advance the loop

begin

Integer

the value of the begin attribute, or null if there was none

end

Integer

the value of the end attribute, or null if there was none

step

Integer

the value of the step attribute, or null if there was none

status.jsp, every property for a loop over a slice
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.

catalogue.jsp, an HTML view
<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.

paging.jsp, an HTML view that pages an in-memory list
<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}&amp;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&amp;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.

PagedServlet, validating the parameters and taking the slice (the data and imports omitted)
@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));
    }
}
paged.jsp, the view of an already sliced list
<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}&amp;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&amp;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&amp;size=1">2</a><a href="?page=3&amp;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.

Table 6. The attributes of c:forTokens that differ from c:forEach
Attribute Required Meaning

items

yes

the string to split. A null value iterates zero times

delims

yes

the characters that separate tokens; every character in the string is a delimiter on its own. A null value makes the whole items a single token

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.

tokens.jsp
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.

nested.jsp
<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.

scope.jsp, a loop variable with the name of an existing page-scope variable
<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:

samevar.jsp
<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.

Table 7. Ways to repeat markup in a JSP page and where they stand
Technique When it fits Status

c:forEach

the normal case: tables, lists, option lists, page links

current, the recommended way

c:forTokens

a short string with delimiters

current

an EL stream, ${books.stream().filter(...).toList()} as items

filtering or sorting a small list for one place in a page

current since EL 3.0, see below

the Struts s:iterator tag

iterating the value stack in a Struts 2 view

current in Struts 7, on the Struts tags page of this section

a scriptlet for loop

only in code that cannot be changed yet

legacy, disabled by scripting-invalid in many applications

Thymeleaf th:each

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.

elstream.jsp, the books under ten euros, cheapest first
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.

scriptlet.jsp (legacy, for comparison only)
<%@ 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 (no items) has no collection to stop it: ?n=100000 printed 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, as PagedServlet does with MAX_SIZE, and do not feed a raw parameter to end.

  • Unescaped output. The Expression Language writes a value as it is. ${b.title} in an HTML page breaks the markup for a title such as Fish & Chips and runs script for a title that contains a tag. Inside a loop, which repeats the mistake for every row, use c: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:forEach over 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

Table 8. c:forEach and c:forTokens pitfalls, their symptoms and fixes (rows marked "observed" were reproduced on Tomcat 11.0.26)
Symptom or error Cause Fix

a second loop over the same attribute prints nothing (observed)

the attribute is an Iterator or Enumeration, which can be used once

put a List or other Collection in the request

jakarta.servlet.jsp.JspTagException: 'begin' < 0 (observed)

a negative begin

compute the value and clamp it at 0

jakarta.servlet.jsp.JspTagException: 'step' <= 0 (observed)

a step of 0 or less

step is 1 or more; to walk backwards, reverse the collection in the servlet

NumberFormatException: For input string: "x" and "Unable to compile class for JSP" (observed)

a literal begin="x" that is not a number: it fails when the page is translated

use a number, or an expression

jakarta.el.ELException: Cannot convert [x] of type [class java.lang.String] to [int] (observed)

end="${param.n}" with a request parameter that is not a number

parse and clamp the parameter in the servlet

JspTagException: Don't know how to iterate over supplied "items" in <forEach> (observed)

items is a Stream, a single object or a number

use a Collection or array; end an EL stream with toList()

the loop prints one item for an empty end (observed)

an empty attribute is coerced to 0

leave end out, or compute a real value

the last page of a list is empty or missing

a page size that does not divide the total, with end computed wrongly

use end="${page * size - 1}" (inclusive) and a ceiling for the page count

a variable disappeared after a loop (observed)

the loop var used the name of a page-scope variable, or nested loops reused one name

give every loop its own var and varStatus name

[a][b][c] for a,b,,c (observed)

c:forTokens and fn:split skip empty tokens

parse real CSV in Java

delims mandatory error at translation (observed)

c:forTokens without delims

always give delims; null makes the whole string one token

delims=", " splits at a space as well as a comma

delims is a set of characters

use one character, or accept the set

the stripes break when the loop starts at begin="3"

the page used index, which is absolute

use count, which starts at 1 every time

a gap or missing space between items

trimDirectiveWhitespaces drops template text that is only whitespace (observed: ${i} followed by a space printed 12345)

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 HashMap

use a LinkedHashMap or a TreeMap

HTTP 500 on http://xmlns.jcp.org/jsp/jstl/core (observed: "The absolute uri …​ cannot be resolved")

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 http://java.sun.com/jsp/jstl/core

use jakarta.tags.core

broken HTML or script in a row

a loop printed ${b.title} unescaped

c:out value="${b.title}"

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

Table 9. Changes that affect loops, by release of the specification
Release Change

JSTL 1.0 / 1.1 / 1.2

the iteration tags (c:forEach, c:forTokens) and the varStatus object are there from the first release, with the 1.1 and 1.2 releases following the Expression Language of the Pages version they ran on; the 3.0 specification still describes deferred (#{}) values for items

Jakarta Standard Tag Library 2.0 (Jakarta EE 9)

the API moved from the javax.servlet.jsp.jstl package to jakarta.servlet.jsp.jstl: LoopTagStatus, LoopTag and LoopTagSupport have the new names

Jakarta Standard Tag Library 3.0 (Jakarta EE 10)

the tag URIs were renamed to jakarta.tags.* (jakarta.tags.core for the prefix c), the minimum Java is 11, the API gained a JPMS module descriptor and the dependency on XML Binding was removed. The tags themselves are unchanged

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 items a filtered or sorted list without Java (see the alternatives)

Jakarta Expression Language 6.0

records are beans, so a record is the natural loop item

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.

Table 10. What the books say and what to write now
In the books Today

<%@ taglib prefix="c" uri="http://java.sun.com/jstl/core" %> (JSTL 1.0) or http://java.sun.com/jsp/jstl/core (1.1 and 1.2)

<%@ taglib prefix="c" uri="jakarta.tags.core" %>

the rt-library prefix, c_rt:forEach items="<%= books %>", and the JSTL 1.0 attribute syntax ${} evaluated by the library itself

only the one library, whose attributes take EL expressions; the rt TLDs of the 1.0 era are historical

the package javax.servlet.jsp.jstl.core.LoopTagStatus, LoopTag and LoopTagSupport

jakarta.servlet.jsp.jstl.core.*

a java.sql.ResultSet or a javax.servlet.jsp.jstl.sql.Result as the usual source of rows

beans or records prepared by the servlet or a service; the SQL tags are legacy

a comma separated String as items shown as a normal feature

deprecated by the specification; use c:forTokens or fn:split

paging by begin and end over the whole result, with the scriptlet or tag deciding the page

the servlet slices the data (a query with a limit, a Pageable) and the view only loops

"zebra striping" by alternating <tr class="..."> with a c:choose on ${status.count % 2}

a conditional expression in the class attribute, or CSS :nth-child

iterating a Vector, Hashtable or Enumeration as the typical cases

List, Map and records; Enumeration is only for old APIs

scriptlets as the alternative to compare with

EL, EL streams, and scripting-invalid

varStatus properties index, count, first, last and current

unchanged, with begin, end and step also there since the first release

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.