Literals, Operators & Preprocessing

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 (a Spring Boot MVC scratch application rendered through MockMvc, plus a standalone TemplateEngine for the conversion service, JDK 26 with --release 21). The syntax described here belongs to the Standard Expression language, so it works in the same way standalone, with Spring MVC and with Spring WebFlux; the only differences are which object evaluates the ${...} parts (OGNL standalone, Spring EL with Spring) and which conversion service backs the double-brace syntax. The page covers sections 4.6 to 4.15 of Using Thymeleaf: everything you can write inside a th:* attribute value around the variable (${...}), selection (*{...}), message (#{...}), link (@{...}) and fragment (~{...}) expressions: literals, text joining, operators, defaults, data conversion and preprocessing.

Two languages in one attribute

A Standard Expression is evaluated by Thymeleaf’s own parser. Only the part between ${ and } is handed to OGNL (or Spring EL in a Spring application). That is why the same operator can be written in two places with the same visible result but different engines:

<div th:if="${edition.available} == false">...</div>   <!-- == is evaluated by Thymeleaf -->
<div th:if="${edition.available == false}">...</div>   <!-- == is evaluated by OGNL / Spring EL -->

The first form follows Thymeleaf’s rules (null handling, comparison of numbers of different types, the textual aliases below). The second follows the rules of the expression language, which for example has its own Elvis (?:), safe navigation (?.) and collection projection syntax. Both are valid; choose one style per project and remember the difference when an expression behaves differently from what you expect. The sections below use the operators outside the braces unless stated otherwise, because that is the portable, documented Standard Expression syntax.

The running data of this page is a small Edition record, the book the Bookshelf shows on a detail page, and a controller that puts two of them in the model: edition (Dune, three copies in stock, no subtitle, no rating) and sold (Emma, sold out, empty subtitle, rating 5):

public record Edition(String title, String subtitle, BigDecimal price, int pages, int stock, Integer rating,
                      boolean available, LocalDate publishedOn, Status status, Isbn isbn) {

    public enum Status { NEW, USED }
}

public record Isbn(String digits) {
}

@Controller
class LiteralsController {

    @GetMapping("/literals/conditional")
    String conditional(Model model) {
        model.addAttribute("edition", new Edition("Dune", null, new BigDecimal("12.50"), 412, 3, null, true,
                LocalDate.of(1965, 8, 1), Edition.Status.NEW, new Isbn("9780441013593")));
        model.addAttribute("sold", new Edition("Emma", "", new BigDecimal("8.00"), 300, 0, 5, false,
                LocalDate.of(1815, 12, 23), Edition.Status.USED, new Isbn("9780141439587")));
        model.addAttribute("user", "reader");
        model.addAttribute("mode", "dev");
        return "literals/conditional";
    }
}

Literals

A literal is a fixed value written directly in the expression. Thymeleaf has four kinds, plus a looser notation called a literal token.

Table 1. Literal kinds
Kind Written as Notes

Text

'The Bookshelf'

Single quotes only. Escape a single quote inside the text with a backslash: 'It\'s on the shelf'.

Number

1965, 12.5, -3

Integers and decimals. A number is a number, so 1965 + 2 is 1967.

Boolean

true, false

Compared with ==/!= or used as a whole expression (th:text="true" prints true).

Null

null

Used with ==, != and the Elvis operator; th:text="null" prints nothing.

Token

content, shelf-item_1.0[x]

Letters, digits, [, ], ., - and _ only; no quotes needed. See below.

<p>Now showing <span th:text="'The Bookshelf'">a catalogue</span>.</p>
<p th:text="'It\'s on the shelf'">x</p>
<p>Year <span th:text="1965">0</span>, next edition <span th:text="1965 + 2">0</span>,
   price <span th:text="12.5">0</span>, delta <span th:text="-3">0</span>.</p>
<div th:if="${edition.available} == false">out of print</div>
<div th:if="${edition.subtitle} == null">no subtitle</div>
<div th:class="content">token class</div>
<div th:class="shelf-item_1.0[x]">token with hyphen, dot and brackets</div>
Rendered output
<p>Now showing <span>The Bookshelf</span>.</p>
<p>It&#39;s on the shelf</p>
<p>Year <span>1965</span>, next edition <span>1967</span>,
   price <span>12.5</span>, delta <span>-3</span>.</p>
<div class="content">token class</div>
<div class="shelf-item_1.0[x]">token with hyphen, dot and brackets</div>

The first th:if printed nothing because edition.available is true; the second rendered its div (<div>no subtitle</div>, left out of the listing above) because edition.subtitle is null. Notice the prototype text inside each element (a catalogue, 0) was replaced; that text is only there so the file reads well when opened in a browser (natural templating).

Literal tokens

Numbers, booleans and null are in fact special cases of literal tokens: unquoted words that behave exactly like a quoted text literal but may only contain letters, digits, square brackets, dots, hyphens and underscores. So th:class="content" is the same as th:class="'content'". A token cannot contain a space or a comma, and the engine tells you so:

<p th:class="a b">tokens cannot contain spaces</p>
org.thymeleaf.exceptions.TemplateProcessingException: Could not parse as expression: "a b" (template: "literals/x2" - line 4, col 4)

Quote anything that is not a plain word (th:class="'btn btn-primary'"). Tokens are convenient in short fixed values such as CSS classes, but a quoted literal is clearer once an expression grows, and it is the safe default.

Equality with literals is Thymeleaf’s equality

Because == outside the braces is evaluated by Thymeleaf, it compares numbers by value whatever their Java type and tolerates a number on one side and a quoted number on the other. The run of the page’s template produced:

<p th:if="${edition.pages} == '412'">number equals string</p>   <!-- rendered -->
<p th:if="${edition.price} == 12.5">decimal equals 12.5</p>      <!-- rendered, BigDecimal 12.50 -->
<p th:if="${edition.status} == 'NEW'">enum equals string</p>     <!-- NOT rendered -->
<p th:if="${edition.status.name()} == 'NEW'">name() works</p>    <!-- rendered -->

An enum is not equal to its name as a string; compare name() (or add a boolean method to the model, such as isNew()) instead. The same comparison inside the braces (${edition.pages == '412'}) would be OGNL’s or Spring EL’s, with their own, stricter rules.

Joining text and literal substitution

Any text-like expressions, whether literals, variables or messages, are appended with the plus operator, +. It is also the arithmetic addition, so the type of the operands decides which one is applied: if either side is text, the result is a string.

<p th:text="'Title: ' + ${edition.title}">x</p>
<p th:text="${edition.pages} + ' pages'">x</p>
<p th:text="${edition.pages} + ${edition.stock}">numbers: added</p>
<p th:text="'Total: ' + (${edition.pages} + ${edition.stock})">parenthesised: added first</p>
<p th:text="'Total: ' + ${edition.pages} + ${edition.stock}">text first: appended</p>
Rendered output
<p>Title: Dune</p>
<p>412 pages</p>
<p>415</p>
<p>Total: 415</p>
<p>Total: 4123</p>

The last line is the classic surprise: once the left side is text, every following plus sign appends, so 412 and 3 become 4123. Parenthesise the arithmetic.

Literal substitution

When most of a string is fixed text with a few values in it, chaining quotes and plus signs becomes hard to read. Literal substitution wraps the whole text between vertical bars (|...|), and any ${...}, *{...} or #{...} inside it is replaced by its value:

<p th:text="|Welcome, ${user}!|">x</p>
<p th:text="${edition.title} + ' ' + |${edition.pages} pages, ${edition.stock} left|">x</p>
<p th:text="|Total: ${edition.pages + edition.stock}|">x</p>
<a th:href="@{|/books/${edition.title}/reviews|}">reviews</a>
Rendered output
<p>Welcome, reader!</p>
<p>Dune 412 pages, 3 left</p>
<p>Total: 415</p>
<a href="/books/Dune/reviews">reviews</a>

The first example is equivalent to 'Welcome, ' + ${user} + '!'. The substitution can be combined with other expressions with a plus sign (second line) and used inside a link expression (fourth line). Within the bars only variable, selection and message expressions are interpreted; a quote there is just text (|'x' ${edition.title}| renders as the text 'x' Dune), there is no conditional or numeric-literal syntax, and the arithmetic of the third line happens inside the braces, which is OGNL’s or Spring EL’s. The bars delimit the text, so a bar character in the text itself has to be built with a concatenation.

Arithmetic

Thymeleaf offers +, -, *, / and %, with the textual aliases div for / and mod for %. A leading minus negates, and parentheses group. Numeric operands keep their precision (BigDecimal stays BigDecimal).

<p th:text="7 + 2">9</p>
<p th:text="7 / 2">3.5</p>
<p th:text="7 div 2">3.5</p>
<p th:text="7 % 2">1</p>
<p th:text="7 mod 2">1</p>
<p th:text="${edition.price} * 2">25.00</p>
<p th:text="${edition.price} * ${edition.stock}">37.50</p>
<p th:text="-${edition.stock}">-3</p>
<p th:text="(${edition.stock} + 1) * 2">8</p>
<p th:text="${edition.pages * 2}">824 (computed by OGNL / Spring EL)</p>
<ul>
  <li th:each="n : ${#numbers.sequence(1, 4)}" th:with="odd=(${n} % 2 == 1)"
      th:class="${odd}? 'odd' : 'even'" th:text="${n}">1</li>
</ul>

The first ten paragraphs render 9, 3.5, 3.5, 1, 1, 25.00, 37.50, -3, 8 and 824, and the list renders alternating odd and even classes:

<ul>
  <li class="odd">1</li>
  <li class="even">2</li>
  <li class="odd">3</li>
  <li class="even">4</li>
</ul>

The aliases div and mod read as words where / and % would be symbols inside an attribute value. The parentheses around ${n} % 2 == 1 in th:with make the whole comparison the value assigned to odd; keep grouping explicit. As with ==, the same arithmetic is also available inside the braces, where OGNL or Spring EL runs it (${edition.pages * 2}); only the form outside the braces is evaluated by Thymeleaf itself.

Comparators, equality and boolean operators

Values are compared with >, <, >= and <=, and equality is tested with == and !=. In an XML/HTML attribute the symbols < and > should not appear raw, so write < and >, or use the textual aliases:

Table 2. Comparator and operator aliases
Symbol Alias Meaning

>

gt

greater than

<

lt

less than

>=

ge

greater than or equal

<=

le

less than or equal

==

eq

equal

!=

neq or ne

not equal

!

not

negation

(none)

and

both are true

(none)

or

at least one is true

The boolean operators are and, or and !/not. Use parentheses to group, and note that not applies to the expression that follows it:

<p th:if="${edition.stock} &gt; 0">in stock</p>
<p th:if="${edition.stock} gt 0">in stock, alias</p>
<p th:if="${edition.stock} lt 5 and ${edition.stock} ge 3">few left</p>
<p th:if="${edition.pages} le 412">le</p>
<p th:if="${edition.title} eq 'Dune'">eq</p>
<p th:if="${edition.title} neq 'Emma'">neq</p>
<p th:if="${edition.available} and ${admin}">and</p>
<p th:if="${edition.available} or ${sold.available}">or</p>
<p th:if="not ${sold.available}">not</p>
<p th:if="!${sold.available}">bang</p>
<p th:if="${admin} and not (${edition.stock} eq 0 or ${sold.available})">grouped</p>

Every th:if above rendered (with admin set to true in the model). The > form and the alias are equivalent; the aliases are easier to read in a template that is also opened as a design prototype. th:if and th:unless and the truthiness rules for values that are not booleans are the subject of the Iteration & Conditionals page.

Conditional expressions

A conditional expression picks one of two expressions according to a third: condition ? then : else. Each of the three parts is a Standard Expression, so it can be a variable, a message, a link or a literal. It can be nested with parentheses, and the else part can be omitted, in which case a false condition yields null.

<p th:class="${edition.stock} gt 0 ? 'in-stock' : 'sold-out'" th:text="${edition.title}">x</p>
<p th:class="${sold.stock} gt 0 ? 'in-stock' : 'sold-out'" th:text="${sold.title}">x</p>
<p th:class="${edition.stock} gt 0 ? (${edition.stock} lt 5 ? 'low' : 'plenty') : 'sold-out'">x</p>
<p th:class="${sold.stock} gt 0 ? 'in-stock'">no else part</p>
<p th:text="'Mode: ' + ((${mode} == 'dev') ? 'Development' : 'Production')">x</p>
<p th:text="${edition.stock > 0 ? 'Available' : 'Sold out'}">condition evaluated by OGNL / Spring EL</p>
Rendered output
<p class="in-stock">Dune</p>
<p class="sold-out">Emma</p>
<p class="low">x</p>
<p>no else part</p>
<p>Mode: Development</p>
<p>Available</p>

When the condition is false and there is no else, the result is null, and th:class with a null value removes the attribute (the fourth paragraph has no class at all). Wrap the conditional in parentheses when it is an operand of +, as in the fifth paragraph: the question mark binds loosely.

Default expressions: the Elvis operator

The Elvis operator ?: is a conditional without a then part: use the first expression unless it is null, otherwise the second.

<p th:text="${edition.subtitle} ?: 'No subtitle'">x</p>
<p th:text="${sold.subtitle} ?: 'No subtitle'">empty string stays</p>
<p th:text="${sold.subtitle ?: 'No subtitle'}">Spring EL's own Elvis</p>
<p th:object="${edition}"><span th:text="*{rating} ?: '(not rated)'">x</span></p>
<p th:object="${sold}">
  <span th:text="*{subtitle} ?: (*{rating} gt 3 ? 'Well rated' : #{shelf.welcome})">x</span>
</p>
Rendered output
<p>No subtitle</p>
<p></p>
<p>No subtitle</p>
<p><span>(not rated)</span></p>
<p>
  <span></span>
</p>

Two details are visible in the output. Thymeleaf’s own ?: (outside the braces) tests only for null: an empty string is a value, so the second paragraph prints an empty string, and the fifth (an empty subtitle again) never reaches its fallback. Spring EL’s ?: inside the braces (third paragraph) treats both null and the empty string as missing. Use the form outside the braces when only null should trigger the default, and nested parentheses when the default is itself a conditional, as the fifth paragraph shows.

The no-operation token

The no-operation token is the underscore, _. As the result of an expression, it means "do nothing", as if the processing attribute were not there at all, so the prototype content of the element stays. This lets a template carry real placeholder text as its default instead of repeating it in a literal:

<p th:text="${edition.title} ?: _">prototype title</p>
<p th:text="${edition.rating} ?: _">prototype rating</p>
<p th:class="${sold.stock} gt 0 ? 'in-stock' : _" class="prototype">no-op else</p>
<p th:text="_">kept</p>
Rendered output
<p>Dune</p>
<p>prototype rating</p>
<p class="prototype">no-op else</p>
<p>kept</p>

Compare the second paragraph with ${edition.rating} ?: 'no rating', which would hold the same words twice (once in the attribute and once as prototype text). The fallback of the no-op token fires on null only, like the Elvis operator: ${sold.subtitle} ?: _ with the empty subtitle renders an empty p, not the prototype text. In the third paragraph, the else part is the token, so the attribute keeps the class already written in the file. The same token is used in fragment expressions (th:insert="${cond} ? ~{x} : _") on the Fragments page.

Double-brace conversion

Writing ${edition.publishedOn} prints the value with toString(); writing ${{edition.publishedOn}} (and *{{publishedOn}} inside a th:object) hands the value to the conversion service and asks it for a String, which is how dates, numbers and your own types get their display format. What happens depends on who provides that service:

  • Standalone Thymeleaf has an IStandardConversionService; the default StandardConversionService just calls toString(), so the double braces make no visible difference until you register your own implementation.

  • Thymeleaf with Spring replaces it with an adapter on Spring’s own ConversionService, so every Converter and Formatter registered in the application, and Spring Boot’s date and time settings, are used by ${{...}} and *{{...}}. The th:field attribute applies the same service without double braces (the Forms & Data Binding page).

The Spring Boot run on the Bookshelf (Locale en for the first column, es for the second):

<p th:text="${edition.publishedOn}">x</p>
<p th:text="${{edition.publishedOn}}">x</p>
<p th:text="${edition.isbn}">x</p>
<p th:text="${{edition.isbn}}">x</p>
<p th:object="${edition}"><span th:text="*{{isbn}}">x</span> <span th:text="*{{price}}">x</span></p>
<p th:text="|Published ${{edition.publishedOn}}|">x</p>
Rendered output
1965-08-01                          (toString of LocalDate, both locales)
8/1/65     (en)    1/8/65    (es)   (the Spring default style for java.time: locale based, SHORT)
Isbn[digits=9780441013593]          (toString of the record)
978-0-4410-1359-3                   (the Formatter below)
978-0-4410-1359-3 12.50
Published 8/1/65     (en)

Two things come from the Spring side. Without any configuration, Spring formats a LocalDate in the locale’s short style (month first in English, day first in Spanish). Adding one Formatter bean teaches the engine how to print Isbn; Spring Boot registers every Formatter and Converter bean in the MVC conversion service on its own:

@Component
class IsbnFormatter implements Formatter<Isbn> {

    @Override
    public String print(Isbn isbn, Locale locale) {
        String d = isbn.digits();
        return d.substring(0, 3) + "-" + d.substring(3, 4) + "-" + d.substring(4, 8) + "-" + d.substring(8, 12)
                + "-" + d.substring(12);
    }

    @Override
    public Isbn parse(String text, Locale locale) {
        return new Isbn(text.replace("-", ""));
    }
}

To change the default date style for the whole application, set the Boot property instead of writing a formatter:

spring.mvc.format.date=yyyy-MM-dd

With that property, the second and sixth paragraphs above both render 1965-08-01 and Published 1965-08-01, and spring.mvc.format.date-time and spring.mvc.format.time do the same for LocalDateTime and LocalTime. For a one-off pattern, #temporals.format(edition.publishedOn, 'dd MMM yyyy') (Expression Utility Objects page) prints 01 Aug 1965 (01 ago 1965 in Spanish) and ignores the conversion service.

Outside Spring, register your own conversion service by extending AbstractStandardConversionService and installing it in the StandardDialect. Override convertToString for the string conversions (the convertOther hook handles targets other than String):

static class IsoConversionService extends AbstractStandardConversionService {
    @Override
    protected String convertToString(IExpressionContext context, Object object) {
        if (object instanceof LocalDate date) {
            return date.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"));
        }
        return super.convertToString(context, object);
    }
}

StandardDialect dialect = new StandardDialect();
dialect.setConversionService(new IsoConversionService());
TemplateEngine engine = new TemplateEngine();
engine.setDialect(dialect);

The template <p th:text="${d}">x</p><p th:text="${{d}}">x</p> for 1965-08-01 renders <p>1965-08-01</p><p>01/08/1965</p>: the single braces still print toString(), only the double braces go through the conversion service. The Standalone Engine & Configuration page covers how the engine and its dialects are assembled.

Preprocessing

Preprocessing is a second evaluation pass that runs before the normal one. Any part of an expression written between double underscores, __...__, is evaluated first, and its result is pasted into the expression text; the engine then evaluates the rewritten expression. The preprocessed part is an ordinary expression (usually a variable, selection or message expression), so the feature lets data decide which expression is run: a property name, a message key, a form field name.

<p th:text="${edition.__${prop}__}">x</p>
<table><tr>
  <th:block th:each="col : ${columns}"><td th:text="${edition.__${col}__}">x</td></th:block>
</tr></table>
<p th:text="#{status.__${edition.status}__}">x</p>

With prop set to title, columns to the list title, pages and the message files below:

# messages.properties
status.NEW=Brand new
status.USED=Second hand
# messages_es.properties
status.NEW=Nuevo
status.USED=De segunda mano

the output (Spanish locale for the last paragraph) is:

<p>Dune</p>
<table><tr><td>Dune</td><td>412</td></tr></table>
<p>Nuevo</p>

The first line is preprocessed into ${edition.title} and then evaluated; inside the loop the preprocessing runs again for every col, yielding ${edition.title} and ${edition.pages}; the message key status.__${edition.status}__ becomes status.NEW, a lookup in the localized bundle. Typical uses are exactly these: a configurable list of columns, enum-driven message keys and reusable form-field fragments (th:field="*{__${field}__}", shown on the Reusable UI Components page).

Escaping the double underscore

Text that really contains two underscores in an attribute is not preprocessed if each underscore is escaped with a backslash:

<p th:text="'snake\_\_case'">x</p>
<p th:text="'plain __ underscores'">x</p>
<p>snake__case</p>
<p>plain __ underscores</p>

The second paragraph shows that a lone __ with no closing pair is left alone, but do not count on it for text you do not control: escape every pair. The tutorial documents this escape for attribute values, which is where preprocessing happens.

Restricted mode and why preprocessing is dangerous with request data

A preprocessed expression is rewritten text: whatever the inner expression returns becomes part of the next expression, so it is interpreted, not merely printed. For that reason preprocessing is one of the contexts where Thymeleaf applies restricted expression evaluation mode (Appendix D of the tutorial). The contexts are:

  • preprocessing expressions, __...__

  • unescaped output: th:utext and unescaped inlined expressions

  • the th:on* event handler attributes, th:attr with arbitrary attribute names and the default attribute processor

  • template and fragment names and fragment parameters, in ~{...} and in th:insert/th:replace

  • the base part of URL expressions (the path, not the parameters)

  • every output expression in TEXT template mode

In restricted mode the engine refuses ${param.*}, instantiating objects with new, static access (OGNL’s @class@member or Spring EL’s T(class)), some context utility objects such as #execInfo and, in Spring applications, @beanName bean references. The attempt does not render something else, it fails with a TemplateProcessingException. For example, Math.max through T() is fine as a normal expression, and forbidden inside preprocessing and th:utext:

<p th:text="${T(java.lang.Math).max(1, 2)}">unrestricted</p>
<p th:text="${edition.__${T(java.lang.Math).max(1, 2)}__}">restricted</p>
<p th:text="${edition.__${param.col}__}">restricted</p>
<p th:utext="${T(java.lang.Math).max(1, 2)}">restricted</p>

The first line renders 2; each of the other three fails with the same message:

org.thymeleaf.exceptions.TemplateProcessingException: Instantiation of new objects and access to static classes or parameters is
forbidden in this context (template: "literals/restricted2" - line 4, col 11)

The same happens with a bean reference: ${@environment.getProperty('spring.application.name')} is accepted in th:text and refused inside __...__.

The documentation is emphatic that the restrictions are defence in depth, not input validation. Treat this as the rule for preprocessing:

  • the value substituted must come from your own code (a fixed list of column names, an enum), never from a request parameter, header, cookie, path variable or database field a user can write

  • when the choice depends on user input, map the input to a fixed set of keys in the controller and put the result in the model

  • prefer th:text and escaped output for everything that is not an expression, because th:utext is itself a restricted context

  • if an expression that used to work fails with a restriction error after an upgrade, the documentation says it was probably unsafe: compute the value in Java and put it in the model

The Template Security & XSS page covers the 2026 advisories; use at least Thymeleaf 3.1.5, the version this page was written against.

Testing the expressions

Because these rules are easy to get subtly wrong (the Total: 4123 case, the empty-string Elvis case), pin the interesting ones with a MockMvc test. With spring-boot-starter-thymeleaf-test and the web MVC test starter on the classpath (Boot 4 package org.springframework.boot.webmvc.test.autoconfigure):

@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
class StockLabelTest {

    @Autowired
    MockMvc mvc;

    @Test
    void conditionalClassAndElvisDefault() throws Exception {
        mvc.perform(get("/literals/conditional"))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("<p id=\"a\" class=\"in-stock\">Dune</p>")))
                .andExpect(content().string(containsString("<p id=\"g\">No subtitle</p>")));
    }

    @Test
    void doubleBraceUsesTheFormatter() throws Exception {
        mvc.perform(get("/literals/conversion"))
                .andExpect(content().string(containsString("<p id=\"d\">978-0-4410-1359-3</p>")));
    }
}

Both tests passed in the scratch application. The @WithMockUser is needed only because the Bookshelf is secured.

Pitfalls

Symptom Cause Fix

'Total: ' + ${a} + ${b} prints Total: 4123

Once the left operand is text, every + appends.

Parenthesise the arithmetic: 'Total: ' + (${a} + ${b}).

Could not parse as expression: "a b"

A literal token contains a space or other character outside letters, digits, brackets, dots, hyphens and underscores.

Quote it as a text literal: th:class="'a b'".

${edition.status} == 'NEW' is never true

An enum is not equal to a string with its name.

Compare name(), a boolean method or a constant exposed in the model.

Default text missing for an empty string

Thymeleaf’s ?: (and the no-operation token) fire on null only.

Check emptiness explicitly (${#strings.isEmpty(x)}) or use Spring EL’s ?: inside the braces.

class attribute disappears

A conditional with no else yields null when false.

Add an else (or _ to keep the prototype value).

${{date}} prints the same as ${date} outside Spring

The default StandardConversionService calls toString().

Register a custom IStandardConversionService, or use Spring so the Spring conversion service is used.

TemplateProcessingException: Instantiation of new objects and access to static classes or parameters is forbidden in this context

T(...), @class@, new, ${param.*} or a bean reference inside preprocessing, th:utext, th:on* and the other restricted contexts.

Compute the value in Java and expose it in the model.

Preprocessing with a request parameter or a user-editable field

The substituted text is evaluated as an expression.

Use values from your own code only, and map any input to a fixed key set.

__ in text becomes a preprocessing expression

An attribute value contains two matching __ pairs.

Escape each underscore as \_.

What changed recently and where the books are outdated

The expression syntax of this page is the one documented in the 3.1 tutorial. What deserves attention on a current version is Appendix D: templates that call static methods or beans from th:utext, th:on*, URL bases or preprocessing fail with a TemplateProcessingException, and the documented fix is to compute the value in the controller. With Spring Framework 7 the thymeleaf-spring6 module is still the one in use (no thymeleaf-spring7 exists). The consulted book Taming Thymeleaf (Leanpub, consulted PDF v1.1.1 from 2021) covers literal substitution and preprocessing, and its explanations of the operators still apply; examples from that generation that put T(...), beans or request parameters into preprocessing or th:utext need the restructuring described above.