Standard Expression Syntax
|
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-07, and every example was compiled and run on that stack in a scratch Bookshelf project (MockMvc for the Spring examples, a plain
TemplateEngine for the standalone ones). The Standard Expression language is shared by all the ways of using the engine, so the page
applies to Thymeleaf standalone, with Spring MVC and with Spring WebFlux; the differences are the expression engine (OGNL
standalone, Spring EL in Spring) and the web objects, and the text says where each one applies. The page is the overview of the language:
the five expression types, how the variable and selection expressions navigate objects, what is available without being added to the
model, and where the language stops being permissive. Literals and operators, preprocessing, link URLs, fragments and the utility
objects have pages of their own in this section and are only previewed here.
The five expression types
Almost every value of a th:* attribute is a Standard Expression. Thymeleaf’s own "5 minutes" article defines five kinds, and the
tutorial’s quick summary adds the literals and operators that can be combined with them. Each kind has its own delimiter and its own
resolver, which is the important thing to remember: the delimiter tells the engine where the value will come from.
| Type | Syntax | Resolved by | What it produces |
|---|---|---|---|
Variable |
|
OGNL (standalone) or Spring EL (Spring), run against the context variables |
Any object: a property, a map entry, a method result, a utility object |
Selection |
|
The same engine, run against the object chosen with |
The same kind of value, relative to the selection |
Message |
|
A message resolver (Spring’s |
A localized text, optionally with parameters |
Link |
|
The configured link builder ( |
A URL with context path, path variables, encoded parameters and session information |
Fragment |
|
The template resolvers and the fragment selector |
A piece of markup that can be inserted, replaced or passed around like any other value |
Source: Getting started with the Standard dialects in 5 minutes, Using Thymeleaf — Standard Expression Syntax
The figure puts the types side by side with their resolvers. Everything on the left is text inside an attribute value; everything on the right runs on the server, while the template is being processed.
model attributes, request attributes,
param, session, application, #objects")] sel["Selected object
set by th:object"] msg["Message resolver
MessageSource + locale"] lnk["Link builder
context path, rewriting, encoding"] tpl["Template resolvers
+ fragment selector"] end v -->|"OGNL or Spring EL"| ctx s -->|"OGNL or Spring EL"| sel sel -.->|"no selection: falls back to"| ctx m --> msg l --> lnk f --> tpl ctx --> out["Text written into the HTML"] sel --> out msg --> out lnk --> out tpl --> out
The simple expressions nest and combine with the literals and operators. The tutorial’s own example mixes a conditional, a method call, a
property and a literal in one value, and the rest of the language (concatenation, arithmetic, comparisons, the Elvis operator, the no-operation
token, |…| substitution, double-brace conversion and __…__ preprocessing) is covered by the Literals, Operators & Preprocessing page
of this section. The next example shows all five types in one small piece of the Bookshelf.
<article th:object="${volume}">
<h2 th:text="*{title}">Prototype title</h2>
<p th:text="#{expr.volume.written(*{writer.name})}">Written by Someone</p>
<a href="detail.html" th:href="@{/volumes/{id}(id=${volume.id},lang='en')}">Details</a>
<div th:replace="~{expr/fragments :: badge(*{title})}">Badge</div>
</article>
The controller only has to put a volume in the model; the messages come from messages.properties (expr.volume.written=Written by {0}, and
a Spanish messages_es.properties with Escrito por {0}) and the fragment is a one-line th:fragment="badge(label)". Rendered for a Spanish
request, the page is:
<article>
<h2>Dune</h2>
<p>Escrito por Frank Herbert</p>
<a href="/volumes/7?lang=en">Details</a>
<span class="badge">Dune</span>
</article>
Four things happened that are worth reading twice. The th:object attribute did not appear in the output, because attributes of the
th prefix are consumed by the engine. The selection *{title} found title on the volume, and the message parameter *{writer.name} used
the same selection. The link expression added the path variable 7 and a query parameter, and, in an application deployed under a context path,
would have prefixed it. The fragment expression replaced the whole div by the span of the fragment.
Variable expressions: OGNL and Spring EL
A variable expression ${…} is evaluated against the context variables, the map that the engine receives with the template. In a
Spring MVC controller that map is the Model: Spring calls its entries model attributes, Thymeleaf calls them context variables, and they are
the same thing. The tutorial’s equivalent in plain Java is ctx.getVariable("today"), and ${volume.writer.name} is a
getWriter().getName() chain on the object found under the name volume.
The language behind the delimiters depends on the dialect:
-
The Standard dialect (a plain
TemplateEngine, the standalone case) evaluates${…}and*{…}as OGNL. -
The SpringStandard dialect, which
SpringTemplateEngineand therefore Spring Boot use, evaluates them as Spring EL (SpEL), with the syntax identical to OGNL for the common cases. Spring’s own tutorial lists this, with the${@myBean.doSomething()}bean syntax, as the first features SpringStandard adds. The Spring EL compiler can be switched on with thespring.thymeleaf.enable-spring-el-compilerproperty, which defaults tofalse(see the Spring Boot Auto-configuration & Properties page).
Navigating objects
Both languages navigate a property path with dots (a getter call), with brackets and a quoted name, and with brackets and an unquoted index for
lists and arrays. Maps accept both the dot and the bracket form, and methods can be called with or without arguments. SpEL adds a few operators
that OGNL does not have, the most useful being the safe-navigation operator ?. and the bean reference @name.
<ul>
<li id="property" th:text="${volume.writer.name}">Someone</li>
<li id="bracket" th:text="${volume['writer']['name']}">Someone</li>
<li id="map" th:text="${volume.stock.main}">0</li>
<li id="mapKey" th:text="${volume.stock['annex']}">0</li>
<li id="list" th:text="${volume.tags[0]}">tag</li>
<li id="array" th:text="${shelves[1]}">shelf</li>
<li id="call" th:text="${volume.writer.signed('-')}">x</li>
<li id="noargs" th:text="${volume.writer.initials()}">x</li>
<li id="safe" th:text="${orphan.writer?.name}">no writer</li>
<li id="safeDefault" th:text="${orphan.writer?.name} ?: 'unknown'">no writer</li>
<li id="bean" th:text="${@catalog.featuredTitle()}">title</li>
<li id="selection" th:text="${volumes.?[writer.born lt 1950].size()}">0</li>
<li id="projection" th:text="${#strings.listJoin(volumes.![title], ', ')}">titles</li>
<li id="static" th:text="${T(java.lang.Math).max(2, 5)}">0</li>
</ul>
With a volume whose writer is Frank Herbert (born 1920) and an orphan volume that has no writer, the page renders the following values.
The catalog bean is an ordinary @Component("catalog") with a featuredTitle() method.
| Item | Rendered value |
|---|---|
|
Frank Herbert |
|
4 and 0 (the |
|
sci-fi, poetry |
|
Frank Herbert-1920 and F |
|
Empty text: the |
|
unknown |
|
Dune |
|
1 (the collection selection |
|
Dune (the projection |
|
5 ( |
Source: Using Thymeleaf — Variables, Spring Framework — Safe Navigation Operator, Spring Framework — Bean References
Dereferencing a null in the middle of a path is an error in both languages unless the safe-navigation operator is used. Spring EL raises
EL1007E: Property or field 'name' cannot be found on null, and the SpEL ?. form avoids it. A missing orphan.writer is a normal situation for
an optional relation, so the pattern worth remembering is ${orphan.writer?.name} ?: 'unknown', safe navigation followed by the Elvis operator
(described on the Literals, Operators & Preprocessing page).
The same navigation in OGNL
In a standalone engine the expressions are OGNL. The next test builds a TemplateEngine with a StringTemplateResolver, fills a Context and
processes small templates, so the behaviour of each expression can be seen directly.
StringTemplateResolver resolver = new StringTemplateResolver();
resolver.setTemplateMode(TemplateMode.HTML);
TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(resolver);
Context ctx = new Context(Locale.forLanguageTag("es"));
ctx.setVariable("volume", volume);
ctx.setVariable("orphan", orphanVolume);
ctx.setVariable("volumes", List.of(volume));
String html = engine.process("<p th:text=\"${volume.writer.name}\">x</p>", ctx);
Running a list of expressions through that engine gave these results, which differ from Spring EL in the places that matter:
| Expression | Result |
|---|---|
|
Work as in Spring EL |
|
Method calls work as in Spring EL |
|
|
|
Template parsing error: OGNL has no |
|
Template parsing error: there are no bean references outside Spring |
|
1 (OGNL’s own selection syntax) |
|
|
|
5 (OGNL’s syntax for a static method) |
OGNL is documented in its own language guide; in a Spring application the guide is not needed, because the Spring EL reference applies. Choose one language per project and keep to the common subset (dotted paths, brackets, method calls) when templates must stay portable between standalone and Spring rendering.
Spring beans from a template
${@catalog.featuredTitle()} calls a method on the bean named catalog of the application context. The "how to access data" article shows the
same pattern with a urlService bean. It is convenient for small look-ups (the current application URL, a feature flag), but it puts a service
call in the view: a template that calls beans is harder to test and can run queries while rendering. Prefer to put the data into the model in
the controller, and use @bean for stateless helpers. The restricted contexts below forbid bean access.
Expression basic objects and web namespaces
Besides the context variables, a few objects are always available to variable expressions, referenced with a leading #. Appendix A of the
tutorial calls them the base objects:
-
#ctxis the context object itself: an implementation ofIContext, or ofIWebContextin a web environment. It is the recommended entry point. -
#varsand#rootare synonyms of the same object in the tutorial, which recommends#ctx. -
#localegives direct access to thejava.util.Localeof the current request.
In a web environment there are also three namespaces. They are not context objects but maps added to the context as variables, so they are
written without a #:
-
paramholds the request parameters.${param.foo}is aString[], so${param.foo[0]}is normally the first value. -
sessionholds the session attributes:${session.foo}. -
applicationholds the servlet context attributes:${application.foo}.
Request attributes need no namespace: all of them are added to the context root, so a model attribute named volume is ${volume}. Each
namespace also supports size(), isEmpty() and containsKey('foo').
<ul>
<li id="locale" th:text="${#locale.language}">en</li>
<li id="ctxLocale" th:text="${#ctx.getLocale()}">en</li>
<li id="varNames" th:text="${#ctx.containsVariable('volume')}">x</li>
<li id="vars" th:text="${#vars.getVariable('volume').title}">x</li>
<li id="root" th:text="${#root.volume.title}">x</li>
<li id="reqAttr" th:text="${volume.title}">x</li>
<li id="param" th:text="${param.q}">q</li>
<li id="param0" th:text="${param.q[0]}">q</li>
<li id="paramNone" th:text="${param.missing}">none</li>
<li id="paramSize" th:text="${param.size()}">0</li>
<li id="paramHas" th:text="${param.containsKey('q')}">x</li>
<li id="session" th:text="${session.reader}">x</li>
<li id="application" th:text="${application.siteName}">x</li>
<li id="exReq" th:text="${#ctx.getExchange().getRequest().getMethod()}">x</li>
</ul>
The controller stores reader in the session and a siteName attribute in the servlet context at start-up. A request to
/expr/objects?q=a&q=b renders:
<ul>
<li id="locale">en</li>
<li id="ctxLocale">en</li>
<li id="varNames">true</li>
<li id="vars">Dune</li>
<li id="root">Dune</li>
<li id="reqAttr">Dune</li>
<li id="param">[a, b]</li>
<li id="param0">a</li>
<li id="paramNone"></li>
<li id="paramSize">1</li>
<li id="paramHas">true</li>
<li id="session">Ada</li>
<li id="application">Bookshelf</li>
<li id="exReq">GET</li>
</ul>
Three details of that output come from running it rather than from the tutorial:
-
${param.q}is the array, printed as[a, b]. Use an index for one value, and remember${param.missing}is simply empty. -
In Spring EL,
#varsis the context (sogetVariable('volume')works) while#rootis the map of variables (so#root.volumeworks andgetVariabledoes not). In OGNL both forms work. This is one more reason to follow the tutorial and write#ctx. -
In both languages
${#ctx.locale}, which Appendix A lists, rendered as an empty string on 3.1.5: a property read on the context object is looked up as a context variable calledlocale.${#locale}or the method form${#ctx.getLocale()}returns the locale. The same applies to#ctx.exchange: in Spring EL write#ctx.getExchange().
Source: Using Thymeleaf — Expression Basic Objects, Using Thymeleaf — Appendix A: Expression Basic Objects, Using Thymeleaf — Web context namespaces
The figure lists what a ${…} expression can reach, from the most specific to the shared sources.
${volume}, ${requestPath}
(model attributes = request attributes)"] expr --> ns["Web namespaces
${param.q}, ${session.reader}, ${application.siteName}"] expr --> base["Base objects
${#ctx}, ${#vars}, ${#root}, ${#locale}"] expr --> util["Utility objects
${#strings}, ${#dates}, ${#lists}, ..."] expr --> bean["Spring beans (SpEL only)
${@catalog.featuredTitle()}"] base --> web["#ctx.getExchange()
IWebExchange: request, session, application"]
What 3.1 removed: the web-API expression objects
Before Thymeleaf 3.1, templates could write ${#request.requestURI}, ${#session.id} or ${#servletContext.contextPath}. The 3.1 "what’s
new" article (now only available as an archived copy, since the page has disappeared from thymeleaf.org) lists the removal of the web-API based
expression utility objects: #request, #response, #session and #servletContext are no longer available to expressions in Thymeleaf 3.1.
The reason is the new web abstraction of that release: the engine no longer talks to the Servlet API (or its javax.* and jakarta.* variants)
directly, but through the IWebApplication, IWebExchange, IWebRequest and IWebSession interfaces, and a template should not depend on a
particular web API. The release also tightened which classes expressions may use, so reaching for HttpServletRequest internals is no longer
the supported route anyway.
A template that still uses the old objects fails when it is rendered:
<p th:text="${#request.requestURI}">/path</p>
On 3.1.5 the request ends with a TemplateProcessingException whose root cause is an IllegalArgumentException: The 'request', 'session',
'servletContext' and 'response' expression utility objects are no longer available by default for template expressions and their use is not
recommended. In cases where they are really needed, they should be manually added as context variables.
The replacements, from most to least recommended, are the following. They were all run on the Spring MVC example, with a request to
/expr/replacement carrying an Accept-Language: es header and a session attribute reader.
-
Put the value in the model. If a page needs the request URI, the controller adds it, which also makes the controller easy to test.
-
Use the namespaces
param,sessionandapplicationfor parameters, session attributes and servlet context attributes, exactly as the "how to access data" article does. -
Use the exchange from
#ctxwhen the template really needs request data:IWebExchangeexposes the request (method, path, headers, parameters, cookies), the session and the application, withgetAttributeValue(…)on each. -
Add the object to the model yourself, as the error message says, when a legacy template cannot be rewritten. The object is then a plain variable, and it is the application’s responsibility to restrict what it exposes.
@GetMapping("/expr/replacement")
public String replacement(Model model, HttpServletRequest request) {
model.addAttribute("requestPath", request.getRequestURI());
return "expr/replacement";
}
<ul>
<li id="model" th:text="${requestPath}">/path</li>
<li id="path" th:text="${#ctx.getExchange().getRequest().getPathWithinApplication()}">/path</li>
<li id="method" th:text="${#ctx.getExchange().getRequest().getMethod()}">GET</li>
<li id="header" th:text="${#ctx.getExchange().getRequest().getHeaderValue('Accept-Language')}">x</li>
<li id="exSession" th:text="${#ctx.getExchange().getSession().getAttributeValue('reader')}">x</li>
<li id="exApp" th:text="${#ctx.getExchange().getApplication().getAttributeValue('siteName')}">x</li>
<li id="sessionMap" th:text="${session.reader}">x</li>
</ul>
<ul>
<li id="model">/expr/replacement</li>
<li id="path">/expr/replacement</li>
<li id="method">GET</li>
<li id="header">es</li>
<li id="exSession">Ada</li>
<li id="exApp">Bookshelf</li>
<li id="sessionMap">Ada</li>
</ul>
The IWebRequest methods used above (getMethod, getPathWithinApplication, getHeaderValue) and the getAttributeValue of IWebSession and
IWebApplication are the public interface of the 3.1 abstraction. The Spring forms are written as method calls because, as seen above, a
property read on the context object is looked up as a variable.
Selection expressions and th:object
A selection expression *{…} is a variable expression that is evaluated on a selected object instead of the whole context. The object is
chosen by the result of a th:object attribute, and it is selected for the element that carries the attribute and everything inside it. As long
as nothing is selected, *{…} and ${…} do the same thing, because the selection defaults to the whole context. Inside a selection the chosen
object is also available as ${#object}, and ${…} and *{…} can be mixed freely.
<div th:object="${volume}">
<p id="a" th:text="*{title}">t</p>
<p id="b" th:text="${volume.writer.name}">w</p>
<p id="c" th:text="${#object.title}">t</p>
<div th:object="${#object.writer}">
<p id="d" th:text="*{name}">n</p>
<p id="e" th:text="${#object.born}">n</p>
</div>
<p id="f" th:text="*{title}">t</p>
</div>
<p id="g" th:text="*{volume.title}">t</p>
The nested th:object replaces the selection only for its own subtree, so after the inner div the paragraph f is again about the volume, and the
paragraph g, outside any selection, resolves *{volume.title} against the whole context.
<div>
<p id="a">Dune</p>
<p id="b">Frank Herbert</p>
<p id="c">Dune</p>
<div>
<p id="d">Frank Herbert</p>
<p id="e">1920</p>
</div>
<p id="f">Dune</p>
</div>
<p id="g">Dune</p>
In Spring applications th:object has a second job: SpringStandard replaces it with an implementation that selects the form-backing (command)
object, and it is then paired with th:field="*{title}", th:errors and the other form attributes. That implementation only accepts a
variable expression. Writing th:object="*{writer}" in Spring fails while the template is parsed, with the message that only variable
expressions (${…}) are allowed in th:object attributes in Spring-enabled environments, which is why the nested example above uses
${#object.writer}. The form attributes are the subject of the Forms & Data Binding page; its Bookshelf form is simply:
<form th:action="@{/books}" th:object="${book}" method="post">
<input type="text" th:field="*{title}">
<span th:if="${#fields.hasErrors('title')}" th:errors="*{title}">err</span>
<input type="text" th:field="*{author}">
<button type="submit">Save</button>
</form>
How and when expressions are evaluated
Expressions are not evaluated by the browser, and they are not evaluated all at once. The engine reads the template, finds each attribute that a processor handles, and the processor evaluates its own value when its turn comes. A few consequences follow.
-
Per attribute.
th:text="${a}"andth:class="${b}"on the same element are two separate evaluations, run in the order set by the attributes' precedence (described on the Local Variables, Attribute Precedence, Comments & Blocks page of this section). A local variable defined by an earlier attribute, such asth:eachorth:with, is visible to the later ones on the same element. -
Parsed once, evaluated each time. The engine parses the text of an expression into an expression tree and caches it (the engine’s expression cache), so the cost on a request is evaluation, not parsing. Each
Contextsupplies the variables of one execution. -
Server-side only. The
th:*attributes are consumed and never reach the browser, and the values in the prototype stay in the file: the textPrototype titleor/pathabove is what a designer sees when the file is opened directly, which is what makes templates natural. -
Every attribute knows its own kind of value.
th:textaccepts any expression;th:hrefandth:srcexpect a link expression (a variable expression can be wrapped as the base of one,@{${url}(id=${o.id})});th:insertandth:replaceexpect a fragment expression;th:objectin Spring expects a variable expression;th:fieldexpects a selection expression.
Restricted mode
Thymeleaf does not let an expression do everything. Appendix D of the tutorial describes two kinds of restrictions:
-
General restrictions, applied to every expression: static use (instantiation or static calls) of most classes from Thymeleaf’s, Spring’s and Java/Jakarta’s base infrastructure is forbidden, together with a list of well-known third-party packages. The 3.1 article names the core packages (
java.*,javax.*,jakarta.*,jdk.*and similar) and the exceptions that stay allowed, such as the basicjava.langandjava.mathclasses, the collection interfaces andjava.util.Date,Calendar,Locale,OptionalandProperties. This is whyT(java.lang.Math).max(2, 5)worked above. -
Restricted expression evaluation mode, stricter and applied only in specific contexts. The tutorial states that it is an additional defence in depth and not a substitute for validating and sanitizing user input.
Restricted mode applies when an expression is evaluated in one of these places:
-
preprocessing expressions,
__…__ -
unescaped output:
th:utextand unescaped inlined expressions -
th:on*JavaScript event handler attributes whose value is a Standard Expression -
th:attrwhen it creates attributes with arbitrary names, and the default attribute processor, which renders any attribute with an arbitrary name -
template names and fragment parameters in fragment expressions and in
th:insert,th:replace,th:includeandth:substituteby -
the base (non-parameter) part of link URLs:
@{…},th:hrefandth:src -
every output expression in TEXT template mode, escaped or not
In that mode the following is forbidden: reading request parameters with ${param.*}, instantiating objects, static classes and their members
(T(...) or OGNL’s @identifier@), some utility objects such as #execInfo, in th:on* attributes a variable expression whose result is not a
number or boolean, and, in Spring applications, bean access with @beanName. A violation raises a template processing exception at runtime. The
tutorial adds that these checks are a security feature, cannot be switched off per expression or per template, and that an expression which worked
before an upgrade and now fails was most likely using the engine in a risky way; the recommended fix is to compute the data in the application code.
The 3.1 archive lists th:substituteby as removed, although Appendix D still names it among the attributes; do not rely on that attribute.
<p id="ok" th:text="${@catalog.featuredTitle()}">title</p>
<p id="bad" th:utext="${@catalog.featuredTitle()}">title</p>
The first paragraph renders Dune. The second one fails when the template is parsed with a TemplateProcessingException: Instantiation of new
objects and access to static classes or parameters is forbidden in this context (the same message is used for the bean access, the param access
and the static access). The same happens with th:utext="${param.q}" and with a request parameter passed to a fragment, while a request parameter
inside the parameter part of a link, or in a plain th:text, is not restricted:
| Template fragment | Result |
|---|---|
|
Restricted: fails |
|
Restricted: fails with Access to variable '#execInfo' is forbidden in this context |
|
Restricted: fails |
|
Works: the URL parameter is not restricted ( |
|
Works: renders the value, HTML-escaped |
|
Works: renders 2, because |
The Template Security & XSS page returns to this topic with the escaping rules and the 3.1.x advisories;
the practical rule for now is that a user-controlled value belongs in th:text, never in th:utext, a preprocessing expression or an event
handler.
Pitfalls
| Mistake | What happens | Fix |
|---|---|---|
|
|
Put the value in the model, or use |
|
Parse error: only variable expressions are allowed in |
Write |
|
The value is a |
Use |
|
Empty result or |
Use |
|
|
Use |
|
Template parsing error: both are Spring EL features |
Add the value to the |
|
Fails: bean access is restricted |
Put the value in the model and print it with |
|
The two are not the same object: |
Use |
Calling services from the template |
Queries run during rendering, and the template becomes hard to test |
Load the data in the controller and expose it as model attributes |
Where to go next
Two pages of this section build directly on this one and exist already: Introduction & Architecture
explains the Standard and SpringStandard dialects and the processing pipeline in which these expressions are evaluated, and
Spring Boot Auto-configuration & Properties documents the
spring.thymeleaf.* properties, including the Spring EL compiler switch. The Literals, Operators & Preprocessing and Expression Utility Objects pages
and the Link URLs and Fragments pages take each of the pieces previewed in the first table to full depth.
References
-
Tutorial: Using Thymeleaf 3.1 — 4 Standard Expression Syntax
-
Tutorial: Using Thymeleaf 3.1 — Appendix A: Expression Basic Objects
-
Tutorial: Using Thymeleaf 3.1 — Appendix D: Expression Restrictions
-
Tutorial: Thymeleaf + Spring 3.1 — The SpringStandard Dialect
-
Thymeleaf 3.1: What’s new and how to migrate (archived copy)