Expression Utility Objects

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 (Maven wrapper, MockMvc against the Bookshelf scratch application, JVM time zone pinned to UTC, request locales en-US and es-ES). Everything on it applies to the Standard Dialect, so it works in all three environments: standalone (plain TemplateEngine), Spring MVC and Spring WebFlux. The few objects that belong to the Spring integration (#fields, #mvc, #themes) and to the Spring Security extras (#authentication, #authorization) are called out in their own section, because they only exist when the corresponding dialect is registered. Layout Dialect, htmx and the other add-ons are not involved.

What utility objects are

An expression object is a name that starts with # and is available inside any variable expression, in addition to the model variables you put in the context. Thymeleaf splits them in two groups. The basic objects (#ctx, which exposes the locale, the variable names and, in a web environment, the web exchange, and #locale) expose the evaluation context itself and are listed on the Standard Expression Syntax page. The utility objects, which this page covers, are stateless helper objects with a method per task: formatting a date, padding a number, testing a string, summing a list. They are the template-side equivalent of a small standard library, and they exist so that presentation logic such as "show the date in the reader’s locale" does not have to be computed in the controller and stored in the model.

Four properties are worth knowing before the method tables:

  • They are plain method calls on a named object, so ${#strings.toUpperCase(name)} is an ordinary variable expression. Used inside *{...}, #{...} arguments, @{...} parameters, th:if conditions and everywhere else an expression is allowed, they behave the same.

  • Most families come in four shapes. A scalar method (format), an array* variant, a list* variant and a set* variant that apply the same operation to every element (arrayFormat, listFormat, setFormat). The tables below name the scalar method and say when the other three exist, instead of repeating them.

  • They are locale-aware only where the table says so. The locale is taken from the context (the request locale in Spring MVC and WebFlux, Context.setLocale standalone), so one template renders differently for en-US and es-ES without any change.

  • Their outputs are escaped like any other value. The examples use th:text, so a & in a result is written as &. A helper such as #strings.escapeXml therefore double-escapes when its result is passed to th:text; see the pitfalls.

The utility objects are implemented by classes in the org.thymeleaf.expression package of thymeleaf core, registered by the Standard Dialect through its expression object factory. The Javadoc of each class is the authoritative method list, and Appendix B of the Using Thymeleaf tutorial is the narrative one.

Table 1. Utility objects at a glance
Object Handles Locale-aware

#execInfo

Template names and modes being processed

No

#messages

Externalised messages, same lookup as #{...}

Yes

#uris

Escaping and unescaping URI parts

No

#conversions

The configured conversion service

Depends on the converter

#dates, #calendars

java.util.Date, java.util.Calendar

Yes

#temporals

java.time types

Yes

#numbers

Formatting and sequences

Yes

#strings

Text

No

#objects, #bools

Null safety and truthiness

No

#arrays, #lists, #sets, #maps, #aggregates

Collections

No

#ids

Unique id attributes inside iterations

No

The scratch application

All outputs below come from one controller that fills the model and one template with a paragraph per example. The domain is the running Bookshelf scenario of this section, with a few extra values (a legacy java.util.Date, a Calendar, a LocalDate, a LocalDateTime and a ZonedDateTime) so that every family has something to work on.

The controller that feeds the examples
@Controller
public class UtilityController {

    @GetMapping("/util")
    public String demo(Model model) {
        model.addAttribute("title", "  the dispossessed  ");
        model.addAttribute("isbn", "978-0-06-051275-4");
        model.addAttribute("price", new BigDecimal("1234.5"));
        model.addAttribute("rating", 0.875);
        model.addAttribute("pages", 7);
        model.addAttribute("published", LocalDate.of(1974, 5, 1));
        model.addAttribute("loanedAt", LocalDateTime.of(2026, 10, 8, 14, 30, 15));
        model.addAttribute("dueAt", ZonedDateTime.of(2026, 10, 22, 9, 0, 0, 0, ZoneId.of("UTC")));
        GregorianCalendar cal = new GregorianCalendar(2026, Calendar.OCTOBER, 8, 14, 30, 15);
        cal.setTimeZone(TimeZone.getTimeZone("UTC"));
        model.addAttribute("calendar", cal);
        model.addAttribute("legacyDate", cal.getTime());
        model.addAttribute("authors", List.of("Le Guin", "Asimov", "Butler", "Asimov"));
        model.addAttribute("tags", new LinkedHashSet<>(List.of("sf", "classic")));
        model.addAttribute("stock", new LinkedHashMap<>(Map.of("dune", 3)));
        model.addAttribute("pageCounts", new int[] {300, 412, 250});
        model.addAttribute("prices", List.of(new BigDecimal("10.00"), new BigDecimal("12.50"), new BigDecimal("7.25")));
        model.addAttribute("nothing", null);
        model.addAttribute("flags", List.of(true, true, false));
        model.addAttribute("unsafeName", "Ursula K. Le Guin/Q&A #1");
        model.addAttribute("quoted", "It's a \"book\"");
        return "util/demo";
    }
}

The message bundle used by the #messages examples has two keys with parameters, one per language:

# messages.properties and messages_en.properties
books.title=Books
books.new=New book
books.count=The shelf holds {0} books by {1}

# messages_es.properties
books.title=Libros
books.new=Nuevo libro
books.count=La estanteria tiene {0} libros de {1}

Two Boot settings keep the outputs reproducible. By default Boot’s message source falls back to the JVM’s own locale when a bundle has no entry for the requested one; spring.messages.fallback-to-system-locale=false turns that off, so the output depends only on the request. The JVM default time zone decides how a java.util.Date is printed, so the test pins it to UTC.

Execution information: #execInfo

#execInfo describes the template that is currently being processed. It is the one utility object with state, because the answer changes as fragments are inserted. It has two views of the same stack: the leaf template (where the events being processed were parsed) and the root template (the one the engine was originally asked to process).

Expression Result

${#execInfo.templateName}, ${#execInfo.templateMode}

Name and mode of the leaf template

${#execInfo.processedTemplateName}, ${#execInfo.processedTemplateMode}

Name and mode of the root template

${#execInfo.templateNames}, ${#execInfo.templateModes}

Stacks (lists), root first and leaf last, with every inserted fragment’s template in between

${#execInfo.templateStack}

The same stack as a list of TemplateData, with the full metadata of each template

Leaf versus root template across a fragment insertion
<!-- util/demo.html -->
<p id="exec1" th:text="${#execInfo.templateName} + ' / ' + ${#execInfo.templateMode}">x</p>
<div th:insert="~{util/frag :: info}">x</div>
<p id="exec3" th:text="${#execInfo.templateNames}">x</p>

<!-- util/frag.html -->
<p th:fragment="info" id="exec2"
   th:text="${#execInfo.templateName} + ' in ' + ${#execInfo.processedTemplateName}">x</p>
<p id="exec1">util/demo / HTML</p>
<div><p id="exec2">util/frag in util/demo</p></div>
<p id="exec3">[util/demo]</p>

After the fragment has been inserted, the stack is back to the root template alone, which is why the last line prints one name. The typical use is a debugging footer or an HTML comment showing which template produced a block.

#execInfo is one of the objects that restricted expression mode forbids. In a th:utext attribute (and in the other contexts listed on the Template Security & XSS page) the same expression fails at runtime:

TemplateProcessingException: Access to variable '#execInfo' is forbidden in this context

Messages: #messages

#messages performs the same lookup as the #{...} message expression, but as a method call, so the key can be computed and the result can be post-processed inside a larger expression.

Method Behaviour

msg(key), msg(key, p1) up to three parameters

The message, or the placeholder ??key_locale?? when the key is missing

msgWithParams(key, Object[])

Same, with any number of parameters in an array

arrayMsg(keys), listMsg(keys), setMsg(keys)

Looks up a collection of keys and returns the same kind of collection of messages

msgOrNull(key, ...), msgOrNullWithParams, arrayMsgOrNull, listMsgOrNull, setMsgOrNull

The same family, but null for a missing key instead of the placeholder

Messages as method calls
<p id="msg1" th:text="${#messages.msg('books.title')}">x</p>
<p id="msg2" th:text="${#messages.msg('books.count', 3, 'Le Guin')}">x</p>
<p id="msg3" th:text="${#messages.msg('books.unknown')}">x</p>
<p id="msg4" th:text="${#messages.msgOrNull('books.unknown')} ?: 'no such key'">x</p>
<p id="msg5" th:text="${#messages.listMsg({'books.title', 'books.new'})}">x</p>
<p id="msg6" th:text="#{books.count(3, 'Le Guin')}">x</p>
<!-- request locale en-US -->
<p id="msg1">Books</p>
<p id="msg2">The shelf holds 3 books by Le Guin</p>
<p id="msg3">??books.unknown_en_US??</p>
<p id="msg4">no such key</p>
<p id="msg5">[Books, New book]</p>
<p id="msg6">The shelf holds 3 books by Le Guin</p>

<!-- request locale es-ES -->
<p id="msg1">Libros</p>
<p id="msg2">La estanteria tiene 3 libros de Le Guin</p>
<p id="msg3">??books.unknown_es_ES??</p>
<p id="msg4">no such key</p>
<p id="msg5">[Libros, Nuevo libro]</p>
<p id="msg6">La estanteria tiene 3 libros de Le Guin</p>

msgOrNull is the way to give a missing key a real fallback. The #{...} syntax is shorter for a fixed key and is preferred there; #messages is for the cases where the key itself is an expression or a collection of keys. The Internationalization and Validation & Error Messages pages go deeper into bundles and the Spring MessageSource.

URIs: #uris

#uris escapes and unescapes text for use in each part of a URI. The part matters because the set of characters that must be percent-encoded differs between a path, a path segment, a query parameter and a fragment identifier. Every method has an overload that takes the character encoding as a second argument.

Methods Escapes for

escapePath, unescapePath

A whole URI path (the / separators are kept)

escapePathSegment, unescapePathSegment

One segment between slashes (a / is encoded)

escapeFragmentId, unescapeFragmentId

The fragment identifier after #

escapeQueryParam, unescapeQueryParam

A query parameter name or value (& and = are encoded)

The four escapers applied to the same text
<p id="uri1" th:text="${#uris.escapePath(unsafeName)}">x</p>
<p id="uri2" th:text="${#uris.escapePathSegment(unsafeName)}">x</p>
<p id="uri3" th:text="${#uris.escapeQueryParam(unsafeName)}">x</p>
<p id="uri4" th:text="${#uris.escapeFragmentId(unsafeName)}">x</p>
<p id="uri5" th:text="${#uris.unescapeQueryParam('Le%20Guin%26Co')}">x</p>
<a id="uri6" th:href="@{/search(q=${unsafeName})}">x</a>
<p id="uri1">Ursula%20K.%20Le%20Guin/Q&amp;A%20%231</p>
<p id="uri2">Ursula%20K.%20Le%20Guin%2FQ&amp;A%20%231</p>
<p id="uri3">Ursula%20K.%20Le%20Guin/Q%26A%20%231</p>
<p id="uri4">Ursula%20K.%20Le%20Guin/Q&amp;A%20%231</p>
<p id="uri5">Le Guin&amp;Co</p>
<a id="uri6" href="/search?q=Ursula%20K.%20Le%20Guin/Q%26A%20%231">x</a>

Compare the first four lines: only escapePathSegment encodes the slash, and only escapeQueryParam encodes the &. (The & in the first, second and fourth outputs is the HTML escaping that th:text applies to a literal &; the URI escaper left it alone.) The last line shows the better habit: a link expression @{/search(q=${unsafeName})} encodes its parameters for you. Use #uris for the cases a link expression cannot cover, such as building a value for a data-* attribute or an hx-get target string; never concatenate raw user input into a URL. The link expressions and URL rewriting are covered on the Link URLs page.

Conversions: #conversions

#conversions runs the engine’s conversion service at any point of a template, with one method, convert(object, targetClass). The target may be a Class or a class name given as a string. In a Spring application the service is the Spring ConversionService (it is the same one that formats ${{...}}), so every converter and formatter the application registered is available, including the formatters Boot registers for java.time.

Converting with the Spring conversion service
<p id="conv1" th:text="${#conversions.convert('42', 'java.lang.Integer') + 1}">x</p>
<p id="conv2" th:text="${#conversions.convert('2026-10-08', 'java.time.LocalDate')}">x</p>
<p id="conv3" th:text="${{published}}">x</p>
<!-- en-US -->
<p id="conv1">43</p>
<p id="conv2">2026-10-08</p>
<p id="conv3">5/1/74</p>

<!-- es-ES -->
<p id="conv3">1/5/74</p>

The first line shows that the result is a real Integer (it can be added to); the third shows the double-brace form for contrast, whose output follows the request locale. Standalone, without Spring, the service is Thymeleaf’s own standard one, which knows far fewer conversions; the Conversion & Formatting page compares them.

Dates and calendars: #dates and #calendars

#dates works on java.util.Date and #calendars on java.util.Calendar. The two have identical method names. The legacy types remain in use in JPA entities, older libraries and java.sql values; for new code the model should carry java.time values and the next section applies.

Methods (the four shapes exist for all of them) Result

format(d)

Date and time in the standard format of the context locale

format(d, pattern)

Formatted with a java.text.SimpleDateFormat-style pattern

formatISO(d)

ISO-8601 text

day, month, year, hour, minute, second, millisecond

The numeric component

monthName, monthNameShort, dayOfWeek, dayOfWeekName, dayOfWeekNameShort

The component as a number or as locale-aware text

create(y, m, d) and the overloads with hour, minute, second and millisecond

A new Date (or Calendar); the month is 1 to 12

createNow(), createToday() (time 00:00) and createNowForTimeZone(), createTodayForTimeZone()

The current instant or day

#calendars.createForTimeZone(...)

Same creation overloads with a trailing TimeZone (calendars only)

Formatting and decomposing a Date and a Calendar
<p id="d1" th:text="${#dates.format(legacyDate)}">x</p>
<p id="d2" th:text="${#dates.format(legacyDate, 'dd/MMM/yyyy HH:mm')}">x</p>
<p id="d3" th:text="${#dates.formatISO(legacyDate)}">x</p>
<p id="d4" th:text="${#dates.year(legacyDate)} + ' ' + ${#dates.monthName(legacyDate)} + ' ' + ${#dates.dayOfWeekName(legacyDate)}">x</p>
<p id="d5" th:text="${#dates.format(#dates.create(2026, 12, 25), 'yyyy-MM-dd')}">x</p>
<p id="c1" th:text="${#calendars.format(calendar, 'dd/MMM/yyyy HH:mm')}">x</p>
<p id="c2" th:text="${#calendars.formatISO(calendar)}">x</p>
<p id="c3" th:text="${#calendars.monthName(calendar)} + ' ' + ${#calendars.dayOfWeekNameShort(calendar)}">x</p>
<!-- en-US -->
<p id="d1">October 8, 2026, 2:30:15 PM UTC</p>
<p id="d2">08/Oct/2026 14:30</p>
<p id="d3">2026-10-08T14:30:15.000+00:00</p>
<p id="d4">2026 October Thursday</p>
<p id="d5">2026-12-25</p>
<p id="c1">08/Oct/2026 14:30</p>
<p id="c2">2026-10-08T14:30:15.000+00:00</p>
<p id="c3">October Thu</p>

<!-- es-ES -->
<p id="d1">8 de octubre de 2026, 14:30:15 UTC</p>
<p id="d2">08/oct/2026 14:30</p>
<p id="d4">2026 octubre jueves</p>
<p id="c3">octubre jue</p>

The default format(d) output follows the request locale ("October 8, 2026, …​" in English, "8 de octubre de 2026, …​" in Spanish). Because the JVM’s default time zone is used to print a Date, two servers in different zones show different wall-clock text for the same instant; set the zone explicitly (a TimeZone on the JVM, or the zoned methods of the next section) when the output must be stable.

Temporals: #temporals (java.time)

#temporals is the java.time counterpart of #dates: it formats LocalDate, LocalDateTime, LocalTime, ZonedDateTime, OffsetDateTime, Instant and the other Temporal types. It is part of thymeleaf core since Thymeleaf 3.1. Before 3.1 it lived in the separate thymeleaf-extras-java8time module (last release 3.0.4), which is still found in older articles and in the books; a 3.1 project does not need it and should not declare it.

Methods (the four shapes exist for the formatters and getters) Result

format(t), format(t, locale)

The default locale format (a medium style for dates and date-times)

format(t, pattern), format(t, pattern, locale)

A DateTimeFormatter pattern, or one of the style names SHORT, MEDIUM, LONG, FULL

format(t, pattern, zoneId)

The value converted to the given time zone before formatting

formatISO(t)

ISO-8601 text

day, month, year, hour, minute, second, nanosecond

The numeric component

monthName, monthNameShort, dayOfWeek, dayOfWeekName, dayOfWeekNameShort

Number or locale-aware text

create(y, m, d)

A LocalDate; the overloads with time components return a LocalDateTime

createNow(), createNowForTimeZone(zoneId), createToday(), createTodayForTimeZone(zoneId)

LocalDateTime, ZonedDateTime or LocalDate as named

createDate(iso), createDateTime(iso), with an optional pattern as a second argument

Parse text into a LocalDate or LocalDateTime

Formatting java.time values, with a zone and with the context locale
<p id="t1" th:text="${#temporals.format(published)}">x</p>
<p id="t2" th:text="${#temporals.format(loanedAt, 'dd MMMM yyyy HH:mm')}">x</p>
<p id="t3" th:text="${#temporals.format(loanedAt, 'FULL')}">x</p>
<p id="t5" th:text="${#temporals.format(dueAt, 'dd/MM/yyyy HH:mm', 'Europe/Paris')}">x</p>
<p id="t6" th:text="${#temporals.dayOfWeekName(published)} + ' ' + ${#temporals.monthNameShort(published)} + ' ' + ${#temporals.year(published)}">x</p>
<p id="t7" th:text="${#temporals.create(2026, 12, 25)}">x</p>
<p id="t8" th:text="${#temporals.createDate('2026-10-08')}">x</p>
<p id="t9" th:text="${#temporals.createDate('08/10/2026', 'dd/MM/yyyy').dayOfYear}">x</p>
<p id="t10" th:text="${#temporals.format(loanedAt, 'EEEE d MMMM', #locale)}">x</p>
<!-- en-US -->
<p id="t1">May 1, 1974</p>
<p id="t2">08 October 2026 14:30</p>
<p id="t3">Thursday, October 8, 2026, 2:30:15 PM Coordinated Universal Time</p>
<p id="t5">22/10/2026 11:00</p>
<p id="t6">Wednesday May 1974</p>
<p id="t7">2026-12-25</p>
<p id="t8">2026-10-08</p>
<p id="t9">281</p>
<p id="t10">Thursday 8 October</p>

<!-- es-ES -->
<p id="t1">1 de mayo de 1974</p>
<p id="t2">08 octubre 2026 14:30</p>
<p id="t6">miércoles may 1974</p>
<p id="t10">jueves 8 octubre</p>

Line t5 shows the zone conversion (09:00 UTC is 11:00 in Paris on 22 October, a summer-time date), and line t10 shows how to format with the locale of the current request by passing the basic object #locale explicitly: the same template yields "Thursday 8 October" and "jueves 8 octubre". That is what a weekly calendar view needs, where a column header is the weekday name in the reader’s language. The pattern letters are those of java.time.format.DateTimeFormatter, which are not identical to the SimpleDateFormat ones used by #dates for the less common letters, so check the Javadoc of the formatter when you copy a pattern between the two.

Which family to reach for depends on the type in the model:

flowchart TD start["Value to format"] --> kind{"Type in the model"} kind -->|"java.time: LocalDate, LocalDateTime, ZonedDateTime, Instant"| temporals["#temporals"] kind -->|"java.util.Date, java.sql.Timestamp"| dates["#dates"] kind -->|"java.util.Calendar"| calendars["#calendars"] kind -->|"text or number"| convert["#conversions.convert or #strings"] temporals --> locale{"Output depends on the reader"} dates --> locale calendars --> locale locale -->|"yes"| styleNames["Style names or #locale argument"] locale -->|"no, fixed format"| pattern["Explicit pattern, or formatISO"]

Numbers: #numbers

#numbers formats integers, decimals, currency and percentages with the context locale, and generates integer sequences. The formatting methods all have the four shapes (formatInteger, arrayFormatInteger, listFormatInteger, setFormatInteger).

Method Result

formatInteger(n, minIntDigits), with an optional thousands separator

Integer, padded with zeros to a minimum number of digits

formatDecimal(n, minIntDigits, decimalDigits)

Decimal with an exact number of fraction digits

formatDecimal(n, minInt, decimalDigits, decimalSep) and formatDecimal(n, minInt, thousandsSep, decimalDigits, decimalSep)

Same, choosing separators

formatCurrency(n), formatCurrency(n, currency)

Locale currency format

formatPercent(n), formatPercent(n, minIntDigits, decimalDigits)

Percentage; the input is the fraction (0.875 is 87.5%)

sequence(from, to), sequence(from, to, step)

An Integer array, for th:each

The separator names accepted by the formatters are POINT, COMMA, WHITESPACE, NONE and DEFAULT (take the locale’s own).

Numbers in the locale of the request
<p id="n1" th:text="${#numbers.formatInteger(pages, 3)}">x</p>
<p id="n2" th:text="${#numbers.formatInteger(1234567, 1, 'POINT')}">x</p>
<p id="n3" th:text="${#numbers.formatDecimal(price, 1, 2)}">x</p>
<p id="n4" th:text="${#numbers.formatDecimal(price, 1, 'WHITESPACE', 2, 'COMMA')}">x</p>
<p id="n5" th:text="${#numbers.formatCurrency(price)}">x</p>
<p id="n6" th:text="${#numbers.formatPercent(rating, 1, 1)}">x</p>
<p id="n7" th:text="${#numbers.listFormatDecimal(prices, 2, 2)}">x</p>
<ul id="n8"><li th:each="i : ${#numbers.sequence(1, 5)}" th:text="${i}">x</li></ul>
<p id="n9" th:text="${#strings.arrayJoin(#numbers.sequence(0, 10, 5), ',')}">x</p>
<!-- en-US -->
<p id="n1">007</p>
<p id="n2">1.234.567</p>
<p id="n3">1234.50</p>
<p id="n4">1 234,50</p>
<p id="n5">$1,234.50</p>
<p id="n6">87.5%</p>
<p id="n7">[10.00, 12.50, 07.25]</p>
<ul id="n8"><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li></ul>
<p id="n9">0,5,10</p>

<!-- es-ES -->
<p id="n3">1234,50</p>
<p id="n5">1.234,50 €</p>
<p id="n6">87,5 %</p>
<p id="n7">[10,00, 12,50, 07,25]</p>

Note the third line: formatDecimal(price, 1, 2) uses no thousands separator, so 1234.50; asking for one is what the five-argument form on line n4 does. The sequence on line n8 is the idiomatic way to render a pagination bar or a star rating without a model list. When the model value is a BigDecimal money amount, keep the arithmetic in Java and use #numbers only to present it.

Strings: #strings

#strings is the largest family. Almost every method also exists with the array, list and set prefixes, in which case it maps over the collection and returns a collection.

Group Methods

Conversion and emptiness

toString, isEmpty, defaultString(text, default)

Searching and comparing

contains, containsIgnoreCase, startsWith, endsWith, indexOf, equals, equalsIgnoreCase

Extracting

substring(s, from, to), substringAfter, substringBefore, length, abbreviate(s, max)

Rewriting

replace, multipleReplace, prepend, append, concat(values...), concatReplaceNulls(nullValue, values...), trim

Case

toUpperCase, toLowerCase, capitalize, unCapitalize, capitalizeWords (optionally with a delimiter set)

Joining and splitting

arrayJoin, listJoin, setJoin (with a separator); arraySplit, listSplit, setSplit

Escaping

escapeXml, escapeJava, escapeJavaScript and the matching unescapeJava, unescapeJavaScript

Random

randomAlphanumeric(count)

A representative selection
<p id="s1" th:text="'[' + ${#strings.trim(title)} + ']'">x</p>
<p id="s2" th:text="${#strings.capitalizeWords(#strings.trim(title))}">x</p>
<p id="s3" th:text="${#strings.abbreviate(#strings.trim(title), 10)}">x</p>
<p id="s4" th:text="${#strings.substringAfter(isbn, '978-')} + ' ' + ${#strings.length(isbn)}">x</p>
<p id="s5" th:text="${#strings.listJoin(authors, ', ')}">x</p>
<p id="s6" th:text="${#strings.listSplit('a,b,c', ',')}">x</p>
<p id="s7" th:text="${#strings.isEmpty(nothing)} + ' ' + ${#strings.defaultString(nothing, 'n/a')}">x</p>
<p id="s8" th:text="${#strings.replace(isbn, '-', '')} + ' ' + ${#strings.toUpperCase(#strings.trim(title))}">x</p>
<p id="s9" th:text="${#strings.concat('Vol. ', pages)} + ' ' + ${#strings.concatReplaceNulls('-', 'a', nothing, 'c')}">x</p>
<p id="s10" th:text="${#strings.contains(title, 'dispossessed')} + ' ' + ${#strings.startsWith(#strings.trim(title), 'the')} + ' ' + ${#strings.equalsIgnoreCase('Dune', 'DUNE')}">x</p>
<p id="s11" th:text="${#strings.escapeJavaScript(quoted)}">x</p>
<p id="s12" th:text="${#strings.escapeXml('<b>&</b>')}">x</p>
<p id="s13" th:text="${#strings.length(#strings.randomAlphanumeric(8))}">x</p>
<p id="s1">[the dispossessed]</p>
<p id="s2">The Dispossessed</p>
<p id="s3">the dis...</p>
<p id="s4">0-06-051275-4 17</p>
<p id="s5">Le Guin, Asimov, Butler, Asimov</p>
<p id="s6">[a, b, c]</p>
<p id="s7">true n/a</p>
<p id="s8">9780060512754 THE DISPOSSESSED</p>
<p id="s9">Vol. 7 a-c</p>
<p id="s10">true true true</p>
<p id="s11">It\&#39;s a \&quot;book\&quot;</p>
<p id="s12">&amp;lt;b&amp;gt;&amp;amp;&amp;lt;/b&amp;gt;</p>
<p id="s13">8</p>

Three details deserve attention. abbreviate(s, 10) counts the three dots inside the limit. concatReplaceNulls is the variant that substitutes a visible replacement for null values (in the scratch run a plain concat('a', null) produced just a, so the explicit version is the one to use when a missing value must show as a marker). And lines s11 and s12 show the double escaping: th:text already escapes its result, so escapeXml in front of it turns < into &lt;. The escaping helpers are for the rare case where the target is not an HTML text node, such as building a string for an attribute that th:attr will write without further processing; for JavaScript values the safer tool is JavaScript inlining, described on the Inlining page.

Objects and booleans: #objects and #bools

#objects has one job, null safety: nullSafe(obj, default) returns the default when the object is null, with arrayNullSafe, listNullSafe and setNullSafe to replace the null elements of a collection. In an expression the Elvis operator ?: often reads better for a single value; nullSafe earns its place in the collection variants.

#bools evaluates values as booleans (it accepts, for example, the string "true") and adds aggregate checks:

Methods Result

isTrue(x), isFalse(x) (and array, list, set variants)

Whether the value evaluates to true or to false

arrayAnd, listAnd, setAnd

true when all elements are true

arrayOr, listOr, setOr

true when any element is true

Null safety and aggregate booleans
<p id="o1" th:text="${#objects.nullSafe(nothing, 'fallback')}">x</p>
<p id="o2" th:text="${#objects.listNullSafe({'a', null}, 'z')}">x</p>
<p id="b1" th:text="${#bools.isTrue('true')} + ' ' + ${#bools.isFalse(false)}">x</p>
<p id="b2" th:text="${#bools.listAnd(flags)} + ' ' + ${#bools.listOr(flags)}">x</p>
<p id="o1">fallback</p>
<p id="o2">[a, z]</p>
<p id="b1">true true</p>
<p id="b2">false true</p>

Collections: #arrays, #lists, #sets, #maps and #aggregates

The collection objects are intentionally small: size, emptiness, membership, conversion and, for lists, sorting. Anything richer (filtering, grouping, mapping) belongs in the controller or in SpEL’s own projection and selection operators when Spring EL is the expression language.

Object Methods

#arrays

toArray, toStringArray, toIntegerArray, toLongArray, toDoubleArray, toFloatArray, toBooleanArray, length, isEmpty, contains, containsAll

#lists

toList, size, isEmpty, contains, containsAll, sort(list), sort(list, comparator)

#sets

toSet, size, isEmpty, contains, containsAll

#maps

size, isEmpty, containsKey, containsAllKeys, containsValue, containsAllValues

#aggregates

sum and avg, each on an array or on a collection

Collection helpers and aggregates
<p id="a1" th:text="${#arrays.length(pageCounts)} + ' ' + ${#arrays.contains(pageCounts, 412)} + ' ' + ${#arrays.isEmpty(pageCounts)}">x</p>
<p id="a2" th:text="${#strings.arrayJoin(#arrays.toStringArray(authors), '|')}">x</p>
<p id="l1" th:text="${#lists.size(authors)} + ' ' + ${#lists.contains(authors, 'Butler')} + ' ' + ${#lists.isEmpty(authors)}">x</p>
<p id="l2" th:text="${#lists.sort(authors)}">x</p>
<p id="l3" th:text="${#lists.toList(tags)}">x</p>
<p id="se1" th:text="${#sets.size(tags)} + ' ' + ${#sets.contains(tags, 'sf')} + ' ' + ${#sets.toSet(authors)}">x</p>
<p id="m1" th:text="${#maps.size(stock)} + ' ' + ${#maps.containsKey(stock, 'dune')} + ' ' + ${#maps.containsValue(stock, 3)} + ' ' + ${#maps.isEmpty(stock)}">x</p>
<p id="ag1" th:text="${#aggregates.sum(prices)} + ' ' + ${#aggregates.avg(prices)}">x</p>
<p id="ag2" th:text="${#aggregates.sum(pageCounts)} + ' ' + ${#aggregates.avg(pageCounts)}">x</p>
<p id="a1">3 true false</p>
<p id="a2">Le Guin|Asimov|Butler|Asimov</p>
<p id="l1">4 true false</p>
<p id="l2">[Asimov, Asimov, Butler, Le Guin]</p>
<p id="l3">[sf, classic]</p>
<p id="se1">2 true [Le Guin, Asimov, Butler]</p>
<p id="m1">1 true true false</p>
<p id="ag1">29.75 9.9166666667</p>
<p id="ag2">962 320.6666666667</p>

#lists.sort returns a sorted copy and leaves the model list untouched, and #sets.toSet of the author list removes the duplicate "Asimov". #aggregates.avg on BigDecimal values returns a BigDecimal printed here with ten fraction digits, so round it with #numbers.formatDecimal before showing it. The isEmpty helpers of lists, sets, maps and arrays returned true for a null argument in the scratch run; #lists.size(null) throws (see the pitfalls).

Unique identifiers: #ids

Inside an iteration the same id would be repeated for every row, which makes HTML invalid and breaks <label for>. #ids solves that by keeping a counter per name:

  • ${#ids.seq('copy')} returns the name followed by the next counter value (copy1, copy2, …​), for th:id.

  • ${#ids.next('copy')} returns the identifier the next seq call will produce, for a <label> written before its input.

  • ${#ids.prev('copy')} returns the identifier the last seq call produced, for a <label> written after its input.

Checkbox list with matching labels, and the label-first order
<ul id="ids1">
  <li th:each="a : ${authors}">
    <input type="checkbox" th:id="${#ids.seq('author')}">
    <label th:for="${#ids.prev('author')}" th:text="${a}">x</label>
  </li>
</ul>
<div id="ids2"><label th:for="${#ids.next('copy')}">Copy</label><input th:id="${#ids.seq('copy')}"></div>
<li>
  <input type="checkbox" id="author1">
  <label for="author1">Le Guin</label>
</li>
<li>
  <input type="checkbox" id="author2">
  <label for="author2">Asimov</label>
</li>
<div id="ids2"><label for="copy1">Copy</label><input id="copy1"></div>

The Thymeleaf + Spring tutorial uses the same two methods in forms, where th:field generates the ids of repeated checkboxes and radio buttons and the label is paired with #ids.prev or #ids.next; the Forms & Data Binding page shows that pairing.

Additions from the Spring integration: #fields, #mvc, #themes

When Thymeleaf is used through thymeleaf-spring6 (Spring MVC and WebFlux) the SpringStandardDialect replaces OGNL with Spring EL and registers three more expression objects. They are not available in a standalone engine.

Object What it does

#fields

Form-binding errors. hasErrors('field'), hasErrors(), hasAnyErrors(), hasGlobalErrors(), errors('field'), errors(), allErrors(), globalErrors(), detailedErrors(), detailedErrors('field'), allDetailedErrors(), globalDetailedErrors() and idFromName(name). The constants '*' and 'all' mean every field, 'global' means the global errors

#mvc

#mvc.url('UC#demo') starts a builder for a link to a controller method, equivalent to the JSP spring:mvcUrl function; .arg(index, value) supplies a method argument and .build() produces the URL

#themes

#themes.code('key'), equivalent to the spring:theme JSP tag

#fields and #mvc were run in the scratch application. The form is bound to a Book whose title is @NotBlank, and it is posted with an empty title:

Reading binding errors with #fields, and building controller links with #mvc
<form th:object="${book}" th:action="@{/util/fields}" method="post">
  <input type="text" th:field="*{title}">
  <p id="f1" th:text="${#fields.hasErrors('title')}">x</p>
  <p id="f2" th:text="${#fields.hasAnyErrors()}">x</p>
  <ul id="f3"><li th:each="err : ${#fields.errors('title')}" th:text="${err}">x</li></ul>
  <ul id="f4"><li th:each="e : ${#fields.detailedErrors()}" th:text="${e.fieldName} + ': ' + ${e.message}">x</li></ul>
</form>
<p id="mvc1" th:text="${#mvc.url('UC#demo').build()}">x</p>
<p id="mvc2" th:text="${#mvc.url('UC#showBook').arg(0, 42L).build()}">x</p>
<p id="f1">true</p>
<p id="f2">true</p>
<ul id="f3"><li>must not be blank</li></ul>
<ul id="f4"><li>title: must not be blank</li></ul>
<p id="mvc1">/util</p>
<p id="mvc2">/util/books/42</p>

UC#demo is the name Spring MVC derives for a mapped method: the capital letters of the controller class name (UtilityController becomes UC), a # and the method name. The .arg(0, 42L) argument must be of the method’s exact parameter type, so the long parameter needs a long literal; a plain 42 is an Integer and fails with a conversion error. The Validation & Error Messages page covers #fields in depth, and the Link URLs page covers #mvc.url.

On #themes, the object is still registered by thymeleaf-spring6 3.1.5, but Spring Framework 7.0.9 no longer ships theme support (spring-context 7.0.9 has no org.springframework.ui.context package), so there is no theme source for it to read on the Boot 4 stack. The tutorials still show it; do not plan new work around it, and use message bundles and CSS for look-and-feel switching.

Additions from Spring Security: #authentication and #authorization

With thymeleaf-extras-springsecurity6 on the classpath (Spring Boot registers its dialect automatically when Spring Security is present) two more expression objects exist, plus the sec:* attributes:

  • #authentication is the Spring Security Authentication of the current request, so ${#authentication.name} is the user name and ${#authentication.authorities} the granted authorities.

  • #authorization offers expression('...') / expr('...') for a Spring Security web expression and url('...') / url('METHOD', '...') for "may the user access this URL".

Authentication and authorization in a template (run with a mock user that has the USER role)
<p id="sec1" th:text="${#authentication.name} + ' ' + ${#authorization.expression('hasRole(''USER'')')} + ' ' + ${#authorization.expression('hasRole(''ADMIN'')')}">x</p>
<p id="sec2" th:text="${#authorization.url('GET', '/util')}">x</p>
<p id="sec1">user true false</p>
<p id="sec2">true</p>

Hiding a button is presentation; the server must still enforce the rule on the endpoint. The Spring Security Integration page covers the sec:authorize attributes, CSRF and the rest of the dialect.

Where utility objects run, and restricted mode

Utility objects run during the processing phase, when the engine evaluates the attribute that holds the expression. They need no preprocessing, and a template that uses them stays a natural template: the static prototype keeps the placeholder text (<p th:text="${#temporals.format(published)}">May 1, 1974</p>) and the server replaces it with the computed value.

Most utility objects are also allowed in restricted expression mode, the stricter evaluation used for th:utext, preprocessing __...__, th:on* handlers, fragment names and URL bases (Appendix D of the tutorial lists the contexts). In the scratch run, #strings.toUpperCase(name) and #uris.escapePath(name) evaluate normally inside th:utext, but #execInfo is rejected there, as shown above. The rule of thumb is that restricted mode forbids objects that expose the engine’s internals, request parameters, static access, object instantiation and Spring bean references, and leaves pure value helpers alone. Restricted mode is defence in depth and does not replace validating data; the Template Security & XSS page treats it fully.

Version notes and where the books are outdated

  • #temporals in core. Since Thymeleaf 3.1 #temporals is part of core. Material for 3.0 (older articles and the books) asks for thymeleaf-extras-java8time; that module is frozen at 3.0.4 and is not needed with Thymeleaf 3.1 or Spring Boot 4.

  • Removed request-related basic objects. Thymeleaf 3.1 removed the servlet-bound #request, #response, #session and #servletContext expression objects, and the ${execInfo} variable form. The utility objects of this page are unaffected; #execInfo with the # is the current spelling. See the What’s Changed & Migration page.

  • Package and module names. In the Spring integration the classes behind #fields, #mvc and #themes live in org.thymeleaf.spring6.expression, in thymeleaf-spring6; there is no thymeleaf-spring7 artifact, and Spring Boot 4.1.1 uses thymeleaf-spring6.

  • Books. The Modern frontends with htmx chapter on out-of-band swaps formats dates in a weekly view with #temporals.format. The call is the same today, and on Thymeleaf 3.1 it needs no extra dependency; pass #locale (or a pattern with a locale) when weekday and month names must follow the reader, as shown above.

Pitfalls

Table 2. Common mistakes with utility objects
Mistake What happens Fix

#dates.format(localDate) on a java.time value

SpelEvaluationException: Method format(java.time.LocalDate) cannot be found on type org.thymeleaf.expression.Dates

Use #temporals for java.time

#temporals.format(legacyDate) on a java.util.Date

The mirror error: no format(java.util.Date) on Temporals

Use #dates, or convert in the controller

#lists.size(nullList)

IllegalArgumentException: Cannot get list size of null

Guard with ${list != null} or use #lists.isEmpty, which is null-safe

Formatters on null (#temporals.format(null), #numbers.formatDecimal(null, 1, 2), #strings.toUpperCase(null))

Render an empty string, no error

Intended; add ?: or th:if when an empty cell is wrong

#dates.create(2026, 13, 1)

No error: a lenient calendar rolls the month over into January 2027

Validate inputs; months are 1 to 12

#messages.msg('missing')

??missing_en_US?? appears in the page

Use msgOrNull with a fallback, and test bundles

#strings.escapeXml(x) inside th:text

Double-escaped text such as &amp;lt;

Drop the helper; th:text already escapes

#numbers.formatCurrency with a language-only locale such as en

A generic currency sign instead of $

Provide a region (en-US) or pass the currency argument

Hand-built URLs with #uris and '/path/' + value

Easy to miss an escaper for the right URI part

Use @{/path/{id}(id=${value})}

#execInfo in th:utext or th:on*

Access to variable '#execInfo' is forbidden in this context

Compute it in the controller, or move it to th:text

#fields or #mvc in a standalone engine

The expression object does not exist

They come from SpringStandardDialect; do the work in the model instead

#themes on Spring Framework 7

No theme source is available

Drop theme usage