Iteration & Conditionals

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 Thymeleaf 3.1.5.RELEASE (2026-04-21), Spring Boot 4.1.1 (Spring Framework 7.0.9) and Java 21, checked on 2026-10-08, and every example was compiled and run on that stack in a scratch Bookshelf application (Maven wrapper, H2 in memory, JDK 26 with --release 21, MockMvc and the plain SpringTemplateEngine). th:each, th:if, th:unless, th:switch and th:case belong to the Standard Dialect, so everything here applies to Thymeleaf standalone, with Spring MVC and with Spring WebFlux alike; the examples use the Spring dialect (SpEL), and the only differences from the standalone engine (OGNL) are the expression language and the Spring-only features named where they appear. The running scenario is the Bookshelf introduced on the Getting Started page: this page builds its catalogue (a table of titles with author, year, copies, status and tags) step by step.

The model used by the examples

The examples render a small in-memory catalogue so that every output can be reproduced without a database. A Title is a plain class with getters (the Bookshelf’s persistent entities would work the same way), and CatalogueData builds four titles and three genres, one of which is empty.

public class Title {
    public enum Status { AVAILABLE, ON_LOAN, LOST }

    private final String name;
    private final String author;
    private final int year;
    private final int copies;
    private final Status status;
    private final List<String> tags;

    public Title(String name, String author, int year, int copies, Status status, List<String> tags) {
        this.name = name; this.author = author; this.year = year;
        this.copies = copies; this.status = status; this.tags = tags;
    }
    public String getName() { return name; }
    public String getAuthor() { return author; }
    public int getYear() { return year; }
    public int getCopies() { return copies; }
    public Status getStatus() { return status; }
    public List<String> getTags() { return tags; }
}

public class Genre {
    private final String name;
    private final List<Title> titles;

    public Genre(String name, List<Title> titles) { this.name = name; this.titles = titles; }
    public String getName() { return name; }
    public List<Title> getTitles() { return titles; }
}

public final class CatalogueData {
    public static List<Title> titles() {
        return List.of(
            new Title("Dune", "Frank Herbert", 1965, 3, Status.AVAILABLE, List.of("classic", "space")),
            new Title("Neuromancer", "William Gibson", 1984, 0, Status.ON_LOAN, List.of("cyberpunk")),
            new Title("Solaris", "Stanislaw Lem", 1961, 1, Status.AVAILABLE, List.of()),
            new Title("Hyperion", "Dan Simmons", 1989, 0, Status.LOST, List.of("space")));
    }

    public static List<Genre> genres() {
        List<Title> all = titles();
        return List.of(new Genre("Space opera", List.of(all.get(0), all.get(3))),
                       new Genre("Cyberpunk", List.of(all.get(1))),
                       new Genre("Hard SF", List.of()));
    }
}

Iteration with th:each

A page that lists things needs a way to say "repeat this piece of markup once per element". Thymeleaf keeps the template natural by letting you write one exemplar row, as a designer would mock it up, and attach th:each to it. The attribute value has the shape iterVar : ${iteratedExpression}, read as "for each element in the result of the iterated expression, repeat this element, binding the current element to the iteration variable". The iteration variable is scoped to the element that carries th:each and everything inside it, so the <td> cells see it but a sibling element does not.

The attribute is processed by the Standard Dialect’s th:each attribute processor at template processing time (not at parse time). It repeats the host element together with its body, so on a table you put it on the <tr>, on a list on the <li>. th:each has a higher precedence than th:if, which matters when both are on one element (see Attribute precedence below). It is not one of the attributes evaluated in the restricted expression mode: the iterated expression runs in the general mode, where only the general restrictions apply (static access to most JDK, Spring and Thymeleaf infrastructure classes is refused). The template stays valid HTML and viewable in a browser, because the other rows are simply absent from the prototype.

Controller side, the Bookshelf controller only puts the data in the model; the template does the repetition.

@Controller
public class CatalogueController {

    @GetMapping("/catalogue")
    public String catalogue(Model model, @RequestParam(defaultValue = "false") boolean empty) {
        model.addAttribute("titles", empty ? List.of() : CatalogueData.titles());
        model.addAttribute("genres", CatalogueData.genres());
        return "iter/catalogue";
    }
}

A first, minimal template repeats a list row:

<table>
  <tr th:each="title : ${titles}">
    <td th:text="${title.name}">Dune</td>
    <td th:text="${title.author}">Frank Herbert</td>
  </tr>
</table>

Rendered (one <tr> per title, and the prototype row has disappeared into the repetition):

<table>
  <tr><td>Dune</td><td>Frank Herbert</td></tr>
  <tr><td>Neuromancer</td><td>William Gibson</td></tr>
  <tr><td>Solaris</td><td>Stanislaw Lem</td></tr>
  <tr><td>Hyperion</td><td>Dan Simmons</td></tr>
</table>

If the iterated expression evaluates to null, the loop simply runs zero times; no exception is raised and the element is not output. A different th:each on a <th:block> repeats several sibling elements without adding a wrapper (the synthetic block element is described on the local variables page of this section), for instance a definition list:

<dl>
  <th:block th:each="title : ${titles}">
    <dt th:text="${title.name}">Dune</dt>
    <dd th:text="${title.author}">Frank Herbert</dd>
  </th:block>
</dl>

Rendered: <dt>Dune</dt><dd>Frank Herbert</dd><dt>Neuromancer</dt><dd>William Gibson</dd> and so on, with no th:block left in the output.

What can be iterated

th:each is not limited to java.util.List. The tutorial lists the supported kinds, and the table adds what the iteration variable holds and what was observed on 3.1.5 when the status variable is used with each kind (see The iteration status variable).

The iterated expression is The iteration variable is Notes

Any java.lang.Iterable (List, Set, …​)

The element

A Collection knows its size, so every status property works. A custom Iterable that is not a Collection has no size.

A java.util.Map

A java.util.Map.Entry

Use entry.key and entry.value. The map’s own iteration order is kept (use a LinkedHashMap or a TreeMap for a stable page).

An array (including primitive arrays)

The element

The size is the array length.

A java.util.Iterator

The element

Values are used as the iterator returns them, without caching them in memory. The size is unknown (see the pitfalls below).

A java.util.Enumeration

The element

Same streaming behaviour as an iterator.

A java.util.stream.Stream (3.1 documents it among the iterable kinds)

The element

A stream can be consumed only once, so a second th:each over the same variable fails.

Any other object, or a single value

The object itself

It is treated as a one-element list, so th:each over a lone Title renders once.

null

(none)

Zero iterations.

The next template iterates one of each kind. The model variables are set as follows (a standalone-style Context, the same names a controller would put in a Model):

Context ctx = new Context();
ctx.setVariable("list", List.of("Dune", "Solaris"));
Map<String, Integer> copies = new LinkedHashMap<>();
copies.put("Dune", 3);
copies.put("Solaris", 1);
ctx.setVariable("map", copies);
ctx.setVariable("array", new String[] {"x", "y"});
ctx.setVariable("iterator", List.of("i1", "i2", "i3").iterator());
ctx.setVariable("enumeration", Collections.enumeration(List.of("e1", "e2")));
ctx.setVariable("stream", Stream.of("s1", "s2", "s3"));
ctx.setVariable("single", "just one");
ctx.setVariable("nothing", null);
<li th:each="t : ${list}" th:text="${t}">t</li>
<li th:each="e : ${map}" th:text="${e.key} + ' = ' + ${e.value}">e</li>
<li th:each="t : ${array}" th:text="${t}">t</li>
<li th:each="t : ${iterator}" th:text="${t}">t</li>
<li th:each="t : ${enumeration}" th:text="${t}">t</li>
<li th:each="t : ${stream}" th:text="${t}">t</li>
<li th:each="t : ${single}" th:text="${t}">t</li>
<li th:each="t : ${nothing}" th:text="${t}">t</li>
<li th:each="n : ${#numbers.sequence(1,3)}" th:text="${n}">n</li>

Each line rendered on its own (the null line renders nothing):

Iterated expression Rendered output

${list}

<li>Dune</li><li>Solaris</li>

${map}

<li>Dune = 3</li><li>Solaris = 1</li>

${array}

<li>x</li><li>y</li>

${iterator}

<li>i1</li><li>i2</li><li>i3</li>

${enumeration}

<li>e1</li><li>e2</li>

${stream}

<li>s1</li><li>s2</li><li>s3</li>

${single}

<li>just one</li>

${nothing}

(nothing)

${#numbers.sequence(1,3)}

<li>1</li><li>2</li><li>3</li>

A numeric loop (a rating of stars, page buttons) has no dedicated attribute: iterate the list returned by the #numbers.sequence(from, to) utility object, which is described with the other expression utility objects in this section.

Two cautions come from the streaming kinds. A Stream variable that is read by two th:each attributes fails on the second one, because the stream was already consumed; the failure reaches you as a TemplateInputException whose root cause is java.lang.IllegalStateException: stream has already been operated upon or closed. Put a Stream in the model only when exactly one loop reads it, or collect it to a List in the controller. Because an Iterator, Enumeration or Stream can be read only once and has no size, a List is the safer default for a model attribute.

The iteration status variable

Many layouts need more than the element: a row number, a separator between items but not after the last, a zebra stripe. th:each offers a status variable for this. It is declared by adding a second name after the iteration variable, separated by a comma: th:each="title, stat : ${titles}". The status object has these properties:

Property Meaning

index

The current iteration index, starting with 0.

count

The current iteration index, starting with 1.

size

The total number of elements of the iterated variable, when it is known.

current

The iteration variable of the current iteration (the current element).

even, odd

Whether the current iteration is even or odd. They follow count, so the first row is odd.

first

True on the first iteration.

last

True on the last iteration. It is computed from size.

If you do not name a status variable, Thymeleaf creates one for you named after the iteration variable with the suffix Stat, so th:each="title : ${titles}" makes titleStat available. The status variable has the same scope as the iteration variable. The following template prints every property and the implicit variable for the Bookshelf titles:

<tr th:each="t, s : ${titles}">
  <td th:text="${s.index} + ',' + ${s.count} + ',' + ${s.size} + ',' + ${s.current.name} + ',' + ${s.even} + ',' + ${s.odd} + ',' + ${s.first} + ',' + ${s.last}">x</td>
</tr>
<tr th:each="t : ${titles}">
  <td th:text="${tStat.count} + '/' + ${tStat.size}">x</td>
</tr>

Rendered (index, count, size, current name, even, odd, first, last):

<tr><td>0,1,4,Dune,false,true,true,false</td></tr>
<tr><td>1,2,4,Neuromancer,true,false,false,false</td></tr>
<tr><td>2,3,4,Solaris,false,true,false,false</td></tr>
<tr><td>3,4,4,Hyperion,true,false,false,true</td></tr>
<tr><td>1/4</td></tr>
<tr><td>2/4</td></tr>
<tr><td>3/4</td></tr>
<tr><td>4/4</td></tr>

The most useful last pattern is a separator that is not printed after the final element. The tags of a title are joined with commas without a trailing comma by testing last of an inner loop:

<span th:each="tag, tagStat : ${title.tags}"
      th:text="${tag} + (${tagStat.last} ? '' : ', ')">tag</span>

For the tags of Dune this renders <span>classic, </span><span>space</span>.

When size and last are not available

size and last need the size of the iterated object. Thymeleaf knows it for a Collection, a Map, an array and a single object. For the lazily traversed kinds the behaviour observed on 3.1.5 is the following, and it is worth knowing before you use last on a stream of data:

Iterated kind size last

List, Set, Map, array

The real size

Correct

A single object

1

True

Iterator

null (nothing is printed)

Throws: SpEL reports EL1021E ("Unable to access property 'last'"), caused by a NullPointerException on the missing size

Enumeration, Stream

1 (not the real size)

True on the first element and false afterwards (not reliable)

A custom Iterable that is not a Collection

null

Throws, as for an iterator

The status object also has a hasSize() method that is false when the size is unknown, so a template can guard last with it, but the simplest rule is to collect an Iterator, Enumeration or Stream into a List in the controller whenever the page needs size or last. index, count, first, even, odd and current work for every kind.

Striped tables

A zebra stripe is the classic use of the status variable: give odd rows a CSS class. There are two idiomatic forms. The first sets the whole class attribute only for odd rows (an expression that yields null removes the attribute), which is the form in the tutorial:

<tr th:each="t, s : ${titles}" th:class="${s.odd} ? 'odd'">
  <td th:text="${t.name}">Dune</td>
</tr>

Rendered: <tr class="odd"><td>Dune</td></tr><tr><td>Neuromancer</td></tr><tr class="odd"><td>Solaris</td></tr><tr><td>Hyperion</td></tr>, so even rows carry no class attribute at all. The second keeps the static classes of the prototype and appends one of two classes, which is what you want when the row already has a class such as row:

<tr class="row" th:each="t, s : ${titles}" th:classappend="${s.even} ? 'even' : 'odd'">
  <td th:text="${t.name}">Dune</td>
</tr>

Rendered: <tr class="row odd"> for Dune and Solaris and <tr class="row even"> for Neuromancer and Hyperion. The th:class, th:classappend and related attributes are described on the attribute-setting page of this section. When the stripe is purely decorative, CSS does it with no template logic (tbody tr:nth-child(odd)), which is the better choice for a static table. Use the status variable when the class must be computed, or when the same list is rendered in a context without your stylesheet.

Lazy retrieval with LazyContextVariable

A page often reads a large collection only under a condition: a "show details" flag, an authorisation check, a collapsed panel. Loading the collection in the controller costs a query even when the page ends up not using it. Thymeleaf offers lazily resolved context variables for this. A variable that implements org.thymeleaf.context.ILazyContextVariable, usually by extending the default implementation org.thymeleaf.context.LazyContextVariable<T> and implementing loadValue(), is resolved only at the moment an expression reads it. Templates use it exactly like an ordinary variable and need no knowledge of its laziness.

In Spring MVC the lazy variable is simply a model attribute:

@Controller
public class LazyController {

    private final AtomicInteger loads = new AtomicInteger();

    @GetMapping("/lazy")
    public String lazy(Model model, @RequestParam(defaultValue = "false") boolean showTitles) {
        model.addAttribute("showTitles", showTitles);
        // loadValue() runs only if a template expression actually reads ${titles}
        model.addAttribute("titles", new LazyContextVariable<List<Title>>() {
            @Override
            protected List<Title> loadValue() {
                loads.incrementAndGet();
                return CatalogueData.titles();
            }
        });
        return "iter/lazy";
    }

    int loads() {
        return loads.get();
    }
}
<ul th:if="${showTitles}">
  <li th:each="title : ${titles}" th:text="${title.name}">Dune</li>
</ul>

With /lazy (flag false) the list is not rendered and loadValue() is never called; with /lazy?showTitles=true the <ul> contains the four titles and loadValue() ran once. A test can assert both facts through the counter:

@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
class CatalogueMvcTest {

    @Autowired MockMvc mvc;
    @Autowired LazyController lazyController;

    @Test
    void lazyVariableIsLoadedOnlyWhenRead() throws Exception {
        int before = lazyController.loads();
        mvc.perform(get("/lazy")).andExpect(status().isOk());
        assertThat(lazyController.loads()).isEqualTo(before);
        mvc.perform(get("/lazy").param("showTitles", "true")).andExpect(status().isOk());
        assertThat(lazyController.loads()).isEqualTo(before + 1);
    }
}

Two properties of the implementation are worth knowing. The value is memoized per variable instance: when the same lazy variable is read by two expressions in one rendering (two copies of the list above in a standalone test) loadValue() still ran once. And the laziness only covers the loading; it does not make iteration streaming. Once the variable is read, the whole returned value exists in memory. Keep loadValue() free of side effects and do not close over a request-scoped resource that may be gone by the time the view renders (with Spring Boot, remember that spring.jpa.open-in-view decides whether lazy JPA associations can still be loaded while the view renders).

Conditionals with th:if and th:unless

th:if renders the element, with its body, only when its expression is true; th:unless is the inverse and renders it only when the expression is false. Both are processed at template processing time, and when the condition is not met the element is simply not output (it is not hidden with CSS). They evaluate their value in the general expression mode, which is why you can write ordinary boolean expressions with and, or, not, the comparison operators and the utility objects. Their value does not have to be a boolean.

The truthiness rules

th:if evaluates the expression as true following these rules, taken from the tutorial and verified on 3.1.5:

  • If the value is null, it is false.

  • If the value is a boolean, it is that boolean.

  • If the value is a number, it is true when non-zero.

  • If the value is a character, it is true when non-zero.

  • If the value is a String, it is true unless it is "false", "off" or "no" (the comparison ignores case: "FALSE" and "No" are false too).

  • If the value is none of boolean, number, character or string, it is true (because it is not null).

flowchart TD value["Value of the th:if expression"] --> isNull{"null?"} isNull -- yes --> no["false: th:if drops the element"] isNull -- no --> kind{"What type is it?"} kind -- Boolean --> bool{"true?"} bool -- yes --> yes["true: th:if keeps the element"] bool -- no --> no kind -- Number --> num{"non-zero?"} num -- yes --> yes num -- no --> no kind -- Character --> chr{"non-zero char?"} chr -- yes --> yes chr -- no --> no kind -- String --> str{"false, off or no, ignoring case?"} str -- yes --> no str -- no --> yes kind -- "anything else" --> yes

The consequences are easy to get wrong, so the table lists what a probe template (<p th:if="${v}">T</p><p th:unless="${v}">F</p>) printed for common values:

Value of ${v} Result Remark

true / false

T / F

A boolean is itself.

null

F

A missing model attribute or a null property is false.

0, 0.0

F

Zero numbers are false; 1 and -1 are true.

'a' / the NUL character

T / F

Only the zero character is false.

"text"

T

Any string except the three words below.

"false", "FALSE", "off", "no", "No"

F

The case-insensitive words false, off and no.

"" (empty string)

T

Not false. Test ${#strings.isEmpty(v)} to detect blanks.

"0", "null"

T

Only the three words are false.

An empty List

T

A non-null object is true even when empty. Test ${#lists.isEmpty(v)} or ${v.empty}.

Optional.empty()

T

It is an object. Unwrap it in the controller instead of testing it in the template.

Any other object

T

True because it is not null.

Because of the empty-string and empty-collection rows, the idiomatic emptiness tests are explicit: th:if="${not #lists.isEmpty(titles)}", th:if="${!titles.isEmpty()}" or th:unless="${titles.empty}". Both not and ! negate, and th:unless avoids the negation. A fragment of the catalogue that shows a link only for titles that have copies:

<a th:if="${title.copies > 0}" th:href="@{/loans/new(titleName=${title.name})}">Borrow</a>
<span th:unless="${title.copies > 0}">No copies left</span>

The > in ${title.copies > 0} is fine inside an attribute value in HTML mode; gt is the alternative that never needs escaping in XML and XHTML templates. The expression syntax page of this section describes the other Standard Expression forms that can be used as conditions.

Branching with th:switch and th:case

When one value selects among several outputs, a chain of th:if is noisy. th:switch sets the value on a container element and th:case marks the alternatives inside it:

<td th:switch="${title.status.name()}">
  <span th:case="'AVAILABLE'">Available</span>
  <span th:case="'ON_LOAN'">On loan</span>
  <span th:case="*">Unavailable</span>
</td>

For Dune, Neuromancer and Hyperion this renders <span>Available</span>, <span>On loan</span> and <span>Unavailable</span>. The rules, all verified:

  • As soon as one th:case is true, every other th:case in the same switch is evaluated as false: the first match wins, even if a later case would match too (two th:case="'manager'" branches print only the first).

  • The default branch is th:case="*". It matches whatever is left, so it must come last: when it was placed before a specific case, the default branch was rendered for a matching value and the specific case was not.

  • th:switch and th:case compare with the Standard Expression equality, so numbers compare as numbers (th:case="3" matches a value of 3), and a null switch value matches th:case="null".

  • An enum switch value does not match a string literal. th:switch="${title.status}" with th:case="'AVAILABLE'" rendered the default branch for every title. Either switch on the name (${title.status.name()}, as above) or compare against the constant itself with a T() expression on your own enum type, which worked because the type is application code (the general restrictions refuse such access to most JDK and framework classes):

<div th:switch="${title.status}">
  <b th:case="${T(com.example.bookshelf.iter.Title.Status).AVAILABLE}">Available</b>
  <b th:case="*">Other</b>
</div>

The T() form is a convenience for the examples. In a real application a status label is better looked up in a message bundle, as the next section shows, and the th:switch is reserved for markup that really differs between values.

The complete catalogue: stripes, status, tags and an empty state

The full Bookshelf catalogue template puts the pieces together. It renders a table only when there are titles, stripes the rows with the status variable, switches the status label, joins the tags with the last property of a nested loop and shows an "untagged" marker when a title has none. When the list is empty, a plain paragraph replaces the whole table, so the page never shows a headline with empty column headers.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><title>Catalogue</title></head>
<body>
  <h1>Catalogue</h1>
  <table th:unless="${#lists.isEmpty(titles)}">
    <thead>
      <tr><th>#</th><th>Title</th><th>Author</th><th>Year</th><th>Copies</th><th>Status</th><th>Tags</th></tr>
    </thead>
    <tbody>
      <tr th:each="title, stat : ${titles}" th:class="${stat.odd} ? 'odd' : 'even'">
        <td th:text="${stat.count}">1</td>
        <td th:text="${title.name}">Dune</td>
        <td th:text="${title.author}">Frank Herbert</td>
        <td th:text="${title.year}">1965</td>
        <td th:text="${title.copies}">3</td>
        <td th:switch="${title.status.name()}">
          <span th:case="'AVAILABLE'">Available</span>
          <span th:case="'ON_LOAN'">On loan</span>
          <span th:case="*">Unavailable</span>
        </td>
        <td>
          <span th:each="tag, tagStat : ${title.tags}"
                th:text="${tag} + (${tagStat.last} ? '' : ', ')">tag</span>
          <em th:if="${#lists.isEmpty(title.tags)}">untagged</em>
        </td>
      </tr>
    </tbody>
  </table>
  <p th:if="${#lists.isEmpty(titles)}">The catalogue is empty.</p>
</body>
</html>

GET /catalogue renders (white space condensed; the first and third rows are odd, the second and fourth even):

<table>
  <thead>
    <tr><th>#</th><th>Title</th><th>Author</th><th>Year</th><th>Copies</th><th>Status</th><th>Tags</th></tr>
  </thead>
  <tbody>
    <tr class="odd">
      <td>1</td><td>Dune</td><td>Frank Herbert</td><td>1965</td><td>3</td>
      <td><span>Available</span></td>
      <td><span>classic, </span><span>space</span></td>
    </tr>
    <tr class="even">
      <td>2</td><td>Neuromancer</td><td>William Gibson</td><td>1984</td><td>0</td>
      <td><span>On loan</span></td>
      <td><span>cyberpunk</span></td>
    </tr>
    <tr class="odd">
      <td>3</td><td>Solaris</td><td>Stanislaw Lem</td><td>1961</td><td>1</td>
      <td><span>Available</span></td>
      <td><em>untagged</em></td>
    </tr>
    <tr class="even">
      <td>4</td><td>Hyperion</td><td>Dan Simmons</td><td>1989</td><td>0</td>
      <td><span>Unavailable</span></td>
      <td><span>space</span></td>
    </tr>
  </tbody>
</table>

GET /catalogue?empty=true renders only <h1>Catalogue</h1> followed by <p>The catalogue is empty.</p>: the th:unless removed the table and the th:if kept the message. A test for the three behaviours uses MockMvc:

@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
class CatalogueMvcTest {

    @Autowired MockMvc mvc;

    @Test
    void rowsAreStripedAndStatusIsSwitched() throws Exception {
        String html = mvc.perform(get("/catalogue"))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("<tr class=\"odd\">")))
                .andReturn().getResponse().getContentAsString();
        assertThat(html).contains("<span>On loan</span>", "<span>Unavailable</span>", "<em>untagged</em>");
        assertThat(html).containsOnlyOnce("<span>classic, </span>");
    }

    @Test
    void emptyCatalogueShowsMessageInsteadOfTable() throws Exception {
        mvc.perform(get("/catalogue").param("empty", "true"))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("The catalogue is empty.")))
                .andExpect(content().string(not(containsString("<table"))));
    }
}

Three empty-state patterns are worth distinguishing:

  • Hide the container, show a message (above): one th:unless on the table and one th:if on the message, using the same emptiness test. Compute the test once with a local variable (th:with, described on the local variables page of this section) if it is expensive.

  • Keep the table, add a row: an extra <tr th:if="${#lists.isEmpty(titles)}"><td colspan="7">Nothing here</td></tr> inside the <tbody>. The headers stay visible, which suits filtered searches.

  • Decide in the controller: return a different view name (or add a boolean) when there is nothing to show, which keeps the template free of branching but spreads presentation decisions into Java.

Nested iteration

Data that has a parent and children, such as genres that contain titles, needs one th:each inside another. Each loop has its own iteration and status variable; use distinct names, and the inner loop can read the outer variable.

<section th:each="genre, genreStat : ${genres}">
  <h2 th:text="${genre.name}">Space opera</h2>
  <ul th:if="${!genre.titles.empty}">
    <li th:each="title, titleStat : ${genre.titles}"
        th:text="${titleStat.count} + '. ' + ${title.name}">1. Dune</li>
  </ul>
  <p th:if="${genre.titles.empty}">No titles</p>
</section>

Rendered (the empty genre falls through to the message):

<section><h2>Space opera</h2><ul><li>1. Dune</li><li>2. Hyperion</li></ul></section>
<section><h2>Cyberpunk</h2><ul><li>1. Neuromancer</li></ul></section>
<section><h2>Hard SF</h2><p>No titles</p></section>

Each loop numbers independently, and the two status variables can be combined: ${genreStat.index} + ':' + ${titleStat.index} yields 0:0 and 0:1 in the first genre and 1:0 in the second. An iteration variable shadows a model or local variable of the same name for the duration of the loop and the outer value is visible again after it: a th:with="t='outer'" paragraph containing a th:each="t : ${titles}" loop printed the title names inside the loop and outer after it. When an inner and outer variable need to coexist, give them different names rather than relying on shadowing.

The order of evaluation inside a nested structure is outer first. The outer element’s th:each runs before the inner element’s attributes, so the inner loop and any th:if on it see the current outer element, and a condition on the outer element can drop the inner loop altogether, as the lazy-loading example does.

Attribute precedence with th:each and th:if

When several th:* attributes sit on the same element Thymeleaf applies them in a fixed order, not the order they are written. In the tutorial’s precedence table, fragment iteration (th:each) comes before conditional evaluation (th:if, th:unless, th:switch, th:case), so the condition is evaluated once per iteration and can read the iteration variable. A filter on the same element works as expected:

<li th:each="title : ${titles}" th:if="${title.copies > 0}" th:text="${title.name}">Dune</li>

Rendered: <li>Dune</li><li>Solaris</li> (the two titles with copies). The complete precedence table, with every attribute, is on the local variables, precedence, comments and blocks page of this section. Two practical choices follow. Filtering in the template is acceptable for a short list, but for a large one it iterates every element only to discard most of them, so filter in the controller or the service (or in the query) instead. When you do want a template-side filter without a condition per element, a SpEL collection selection is a compact alternative: th:each="title : ${titles.?[copies > 0]}" rendered the same two titles, and a projection ${titles.![name]} iterates just the names. Collection selection and projection are Spring EL features; in a standalone engine that uses OGNL the equivalent syntax differs.

Listing data in a Spring application

The Thymeleaf + Spring tutorial builds its first screen as a listing and combines th:unless, th:each, nested loops, a status variable and the message bundle. The same techniques apply to the Bookshelf. The wrapping th:unless="${#lists.isEmpty(...)}" is the empty-state pattern above; a nested loop reads th:each="row, rowStat : ${sb.rows}" with ${rowStat.count} as the row number, exactly like the genre example.

Two Spring-specific details belong to that listing. First, an enum is translated through the message bundle with a literal-substitution key built from the value: th:text="#{|status.${title.status}|}", with the keys status.AVAILABLE, status.ON_LOAN and status.LOST in messages.properties, which is the better way to label a status than a th:switch because the texts become translatable. Second, the double-brace form ${{value}} applies the Spring conversion service to a value (dates, numbers, your own types) at output time; it is the subject of the conversion and formatting page of this section.

status.AVAILABLE=Available
status.ON_LOAN=On loan
status.LOST=Lost
<ul>
  <li th:each="title : ${titles}">
    <span th:text="${title.name}">Dune</span>:
    <span th:text="#{|status.${title.status}|}">Available</span>
  </li>
</ul>

With a messages_es.properties containing status.AVAILABLE=Disponible, status.ON_LOAN=Prestado and status.LOST=Perdido, a request with Accept-Language: es rendered <span>Disponible</span>, <span>Prestado</span>, <span>Disponible</span> and <span>Perdido</span> after the titles Dune, Neuromancer, Solaris and Hyperion. (Spring Boot falls back to the JVM’s default locale when no bundle matches the requested language, so check what spring.messages.fallback-to-system-locale does on your machine when a test unexpectedly returns another language.)

The natural template and the prototype rows

A natural template should look right when opened directly in a browser. A th:each row shows only one prototype row, which is usually enough, but a designer may want to see a realistic table with several rows. Add extra mock rows with th:remove="all" so that the engine deletes them while the browser still shows them (the attribute is covered with the other removal options on the fragments page of this section):

<table>
  <tr th:each="t : ${titles}"><td th:text="${t.name}">Dune</td></tr>
  <tr th:remove="all"><td>Mock row</td></tr>
</table>

Rendered: <table><tr><td>Dune</td></tr><tr><td>Neuromancer</td></tr><tr><td>Solaris</td></tr><tr><td>Hyperion</td></tr></table>, with the mock row gone.

Pitfalls

Symptom Cause Fix

th:if="${v}" is true for an empty string, an empty list or Optional.empty()

Only null, false, zero numbers, the zero character and the strings false, off, no are false

Test emptiness explicitly with #strings.isEmpty, #lists.isEmpty or .empty

The second loop over the same Stream fails with stream has already been operated upon or closed

A stream can be consumed once

Collect to a List in the controller, or read the stream in one loop only

${stat.last} throws EL1021E / NullPointerException on an Iterator

The size of an iterator is unknown, and last is computed from it

Use a List, or avoid last and size with streaming kinds

size is 1 for an Enumeration or a Stream

The size is only known for collections, maps, arrays and single objects

Do not use size or last with those kinds

th:switch on an enum always renders the default branch

An enum is not equal to a string literal in the Standard Expression equality

Switch on ${x.name()}, or compare with a T() constant of your own enum

A th:case after a matching one does not render

Only the first matching case is rendered

Order the cases from specific to general, with th:case="*" as the default

The lazy list is loaded although the template seems not to use it

An earlier expression, for instance ${titles.size()} in a heading, read the variable

Search the whole template; the first read loads it

An outer variable has the wrong value inside a loop

The iteration variable shadows a variable of the same name

Use distinct names for the outer and inner variables

Rows are numbered from 1 but index shows 0

index starts at 0 and count at 1

Use count for display and index for zero-based lookups

Heavy filtering inside th:each with th:if

Every element is visited and discarded one by one

Filter in the service or the query, or use a collection selection