Standalone Engine & Configuration
|
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 (the latest release on Maven Central on 2026-10-07, published 2026-04-21) and Java 21 or newer, and it
applies to Thymeleaf standalone: there is no Spring, no Spring Boot and no Spring MVC anywhere in the listings. Only org.thymeleaf:thymeleaf is on
the classpath, plus an SLF4J binding and, for the servlet examples, a Jakarta Servlet container. Every listing was compiled and run in a scratch Maven
project on JDK 26 (compiled for release 21), as a plain main method and as a jakarta.servlet.Filter served by embedded Jetty 12.0.40, and the output
shown is the real output. The Spring-specific classes that replace most of what is described here (SpringTemplateEngine,
SpringResourceTemplateResolver, ThymeleafViewResolver) are covered by the Spring Boot Auto-configuration & Properties and Spring MVC Integration pages.
Thymeleaf is a library, not a framework: it does not know what a request, a controller or a URL is. All it offers is an engine that takes a template
name and a context and writes the result to a Writer or returns it as a String. Everything around that call (finding the template, finding the
messages, caching the parsed template, converting values) is pluggable, and a Spring application merely plugs in Spring-aware implementations of the
same extension points. Knowing the standalone configuration therefore pays off twice: it is the only way to use Thymeleaf in a command-line tool, a
batch job that renders emails or reports, or a plain servlet application, and it is what Spring Boot’s auto-configuration builds for you in an MVC
application.
In a hurry: what to use for what
| I need to… | Use | Section |
|---|---|---|
Render a template to a |
|
|
Load templates from the classpath, a folder, a URL, a string or the servlet context |
|
|
Let a folder of overrides win over classpath defaults |
Two resolvers with |
Chaining resolvers: order, resolvable patterns, existence check |
Render inside a servlet filter, with request parameters and session |
|
|
Load messages from a database or a map |
A custom |
|
Format values with the double-brace syntax |
|
|
See why a template is slow, or what the engine was configured with |
The loggers |
|
Pick up an edited template, or bound the cache |
|
The dependency
Standalone Thymeleaf is a single artifact. Its own dependencies (OGNL, AttoParser, Unbescape and slf4j-api) arrive transitively, so a Maven build
needs one coordinate, plus a logging binding of your choice so that SLF4J does not fall back to a no-op logger.
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
<version>3.1.5.RELEASE</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.20</version>
</dependency>
Use 3.1.5 or newer: the 3.1.4 and 3.1.5 releases of April 2026 carry fixes for the security advisories CVE-2026-41901, CVE-2026-40478 and CVE-2026-40477
(see the Thymeleaf GitHub advisories). The thymeleaf-spring6 artifact is the Spring bridge and is not needed here.
The engine and process
The central type is org.thymeleaf.ITemplateEngine; org.thymeleaf.TemplateEngine is its implementation in the core library. A usable engine needs
exactly one thing: a template resolver. Everything else (dialect, message resolvers, cache manager) has a default. The engine is configured by
setters, initialises itself lazily on the first process call, and after that rejects any configuration change with an IllegalStateException
("Template engine has already been initialized …"). Configure it once, at start-up, and share the one instance: a configured engine is meant to be
used by many threads at the same time, while a context holds the data of a single rendering and must not be shared.
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
resolver.setPrefix("templates/");
resolver.setSuffix(".html");
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCharacterEncoding("UTF-8");
resolver.setCacheable(true);
TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(resolver);
Context ctx = new Context(Locale.forLanguageTag("es"));
ctx.setVariable("name", "Ana");
ctx.setVariable("when", java.time.LocalDate.of(2026, 10, 7));
String html = engine.process("hello", ctx); // returns the result
java.io.StringWriter out = new java.io.StringWriter();
engine.process("hello", ctx, out); // writes to a Writer
With src/main/resources/templates/hello.html, hello.properties (hello.greeting=Hello, {0}!) and hello_es.properties
(hello.greeting=¡Hola, {0}!) on the classpath, where the template is
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><title>Hello</title></head>
<body>
<p th:text="#{hello.greeting(${name})}">Hello, World!</p>
<p th:text="${when}">2026-01-01</p>
</body>
</html>
the first call prints:
<!DOCTYPE html>
<html>
<head><title>Hello</title></head>
<body>
<p>¡Hola, Ana!</p>
<p>2026-10-07</p>
</body>
</html>
process comes in three families, all final on TemplateEngine:
| Signature | Meaning |
|---|---|
|
Render the whole template and return it. |
|
Render the whole template into the writer. Use this in a web layer, so that the output streams. |
|
Render only the parts matched by markup selectors, such as |
|
The same with a |
|
Returns an |
Rendering only the paragraphs of the template above, with engine.process("hello", Set.of("p"), ctx), gives
<p>¡Hola, Ana!</p><p>2026-10-07</p>: the markup selector syntax is the one of fragment expressions, and the fragments are cached under their own
key (hello::[p]).
If no resolver can find the template, process throws org.thymeleaf.exceptions.TemplateInputException ("Error resolving template [missing],
template might not exist or might not be accessible by any of the configured Template Resolvers"), and any failure during rendering is logged by the
engine at ERROR level and rethrown as a TemplateProcessingException. A TemplateEngine that was given no resolver at all uses a
StringTemplateResolver, so the template "name" you pass is then the template itself.
Template resolvers
A template resolver implements org.thymeleaf.templateresolver.ITemplateResolver. Its job is to turn a template name (and, for fragments, the name of
the owner template) into a TemplateResolution: the resource to read, the template mode, the character encoding and the cache validity. Thymeleaf
ships six implementations; writing your own means implementing ITemplateResolver or extending AbstractTemplateResolver.
| Resolver | Where templates come from |
|---|---|
|
Resources of the web application, through an |
|
Class loader resources. |
|
Files on the file system: the prefix plus the name is a path. |
|
A URL, including remote |
|
The template name is the template: |
|
Always returns the one text set with |
The first four extend AbstractConfigurableTemplateResolver and therefore share the same options, on top of the ones every resolver has (name, order,
resolvable patterns, existence check, decoupled logic):
| Option | Effect | Default |
|---|---|---|
|
Wrapped around the template name to build the resource name: the name |
none |
|
Append the suffix even when the name already ends in a known extension ( |
|
|
Names that do not correspond to a resource name. An alias is applied before the prefix and suffix. |
none |
|
Encoding used to read the resource. |
the resource default |
|
|
|
|
Apply the configured mode even if the resource name ends in a known extension; otherwise the resolver may pick the mode from the extension. Since 3.0.6. |
|
|
Name patterns that select the mode per template and win over |
none |
|
Default for whether parsed templates from this resolver go into the cache. |
|
|
Name patterns that override the |
none |
|
Time to live of a cached template, in milliseconds. Without it an entry only leaves the cache when the maximum size is reached. |
|
|
Look for a separate decoupled-logic resource for each template. |
|
The defaults in the right column were read back from a freshly created ClassLoaderTemplateResolver:
cacheable=true ttl=null mode=HTML checkExistence=false forceSuffix=false forceTemplateMode=false order=null decoupled=false. The same options have the same
meaning in every Thymeleaf integration, because Spring’s SpringResourceTemplateResolver extends the same base class.
Context ctx = new Context(Locale.ENGLISH, Map.of("name", "Ana"));
// String: the name is the template
StringTemplateResolver sr = new StringTemplateResolver();
sr.setTemplateMode(TemplateMode.TEXT);
TemplateEngine stringEngine = new TemplateEngine();
stringEngine.setTemplateResolver(sr);
stringEngine.process("Dear [(${name})], welcome.", ctx); // Dear Ana, welcome.
// File: prefix + name is a path
Path dir = Files.createTempDirectory("thy");
Files.writeString(dir.resolve("note.txt"), "File says: [[${name}]]\n");
FileTemplateResolver fr = new FileTemplateResolver();
fr.setPrefix(dir + "/");
fr.setTemplateMode(TemplateMode.TEXT);
fr.setCacheable(false);
TemplateEngine fileEngine = new TemplateEngine();
fileEngine.setTemplateResolver(fr);
fileEngine.process("note.txt", ctx); // File says: Ana
// URL: the name is a URL (file:, http:, https:)
UrlTemplateResolver ur = new UrlTemplateResolver();
ur.setTemplateMode(TemplateMode.TEXT);
ur.setCacheable(false);
TemplateEngine urlEngine = new TemplateEngine();
urlEngine.setTemplateResolver(ur);
urlEngine.process(dir.resolve("note.txt").toUri().toString(), ctx); // File says: Ana
// Default: whatever the name, the same text
DefaultTemplateResolver dr = new DefaultTemplateResolver();
dr.setTemplate("<p th:text=\"${name}\">x</p>");
TemplateEngine defEngine = new TemplateEngine();
defEngine.setTemplateResolver(dr);
defEngine.process("whatever-name", ctx); // <p>Ana</p>
Two cautions follow from the resolver semantics. A UrlTemplateResolver and a FileTemplateResolver turn the template name into a location, so a
template name must never be built from request input: the name ../../etc/passwd or https://attacker.example/x would be a read of a file or a fetch
of a URL chosen by the visitor. And a StringTemplateResolver evaluates the text it is given as a template, so it must never receive user-supplied
text; user data goes into the context and is output with th:text. The Template Security & XSS page discusses both in depth.
Chaining resolvers: order, resolvable patterns, existence check
An engine can have several resolvers. For each template name it asks them in ascending order and uses the first one that says it can resolve the
name; resolvers without an order are asked last, and the order in which resolvers were added does not matter. Two options
decide whether a resolver "can resolve" a name:
-
setResolvablePatterns(Set<String>)(orgetResolvablePatternSpec().addPattern(...)) lists the name patterns this resolver is willing to handle. A name that matches none is skipped without touching the resource. Patterns are optional but recommended, because they let the engine discard resolvers cheaply. -
setCheckExistence(true)makes the resolver check that the resource really exists, and pass on to the next resolver if it does not. Without it, a resolver that cannot tell in advance claims every name it is asked about, breaks the chain, and then fails when it cannot read the resource.
Each existence check costs a second access to the resource (once to check, once to read), which hurts for remote URLs, although the template cache
softens it because a cached template is only resolved the first time. The same flag also makes the "does this fragment exist" idiom
~{common :: salutation} ?: _ work in templates.
pattern matches?} R1 -->|no| R2{Resolver order 2
pattern matches?} R1 -->|yes| E1{checkExistence
resource exists?} E1 -->|yes| U1[Use resolver 1] E1 -->|no| R2 R2 -->|yes| E2{checkExistence
resource exists?} R2 -->|no| X[TemplateInputException] E2 -->|yes| U2[Use resolver 2] E2 -->|no| X
// Highest priority: a directory of overrides
FileTemplateResolver overrides = new FileTemplateResolver();
overrides.setOrder(1);
overrides.setName("overrides");
overrides.setPrefix(dir + "/");
overrides.setSuffix(".html");
overrides.setCheckExistence(true);
overrides.setCacheable(false);
// Fallback: the classpath, restricted to a few patterns
ClassLoaderTemplateResolver classpath = new ClassLoaderTemplateResolver();
classpath.setOrder(2);
classpath.setName("classpath");
classpath.setPrefix("templates/");
classpath.setSuffix(".html");
classpath.setResolvablePatterns(Set.of("shared", "layout/*", "hello"));
classpath.setCheckExistence(true);
TemplateEngine engine = new TemplateEngine();
engine.addTemplateResolver(classpath); // registration order does not matter
engine.addTemplateResolver(overrides); // the "order" property decides
engine.process("shared", ctx); // the file wins: <p>file override</p>
engine.process("layout/menu", ctx); // only the classpath has it: <nav>classpath menu</nav>
engine.process("missing", ctx); // TemplateInputException
Both a shared.html in the overrides folder and one on the classpath exist, and the first call prints the file’s version; remove the file and the same
call prints the classpath’s <p>classpath shared</p>. engine.getTemplateResolvers() returns the resolvers; the names and orders printed from
them were [overrides/1, classpath/2].
Contexts, WebContext and the 3.1 web abstraction
A context carries what one rendering needs: the variables map and the locale used for messages. The interface is org.thymeleaf.context.IContext
(getLocale(), containsVariable(String), getVariableNames(), getVariable(String)), and the core offers two final implementations:
-
Context, with the constructorsContext(),Context(Locale)andContext(Locale, Map<String,Object>), plussetVariable,setVariablesandsetLocale. This is the one for the console, batch jobs and email. Without a locale, the default locale of the system is used, which you should never let happen in a real application. -
WebContext, an implementation ofIWebContext, with the constructorsWebContext(IWebExchange),WebContext(IWebExchange, Locale)andWebContext(IWebExchange, Locale, Map<String,Object>). Beyond the variables, it exposes the request parameters, the session and the application attributes to the template:${param.q}is a request parameter,${session.visits}a session attribute,${application.shop}an application attribute (a servlet context attribute), and a plain${x}is a context variable or an attribute of the exchange (a request attribute in servlet terms).
A WebApplicationTemplateResolver requires an IWebContext, so the web resolver and WebContext always go together.
IWebApplication and IWebExchange in 3.1
Until Thymeleaf 3.0 a WebContext was built from HttpServletRequest, HttpServletResponse and ServletContext, which tied the core to the javax.servlet
API. Thymeleaf 3.1 removed that dependency from the core and replaced the servlet types by its own small abstractions in org.thymeleaf.web:
| Type | Models |
|---|---|
|
The application: attributes ( |
|
One request-response interchange. Gives access to |
|
Method, scheme, server name and port, application path, |
|
The session: |
The servlet bridge lives in org.thymeleaf.web.servlet: JakartaServletWebApplication for jakarta.servlet (Servlet 5 and newer) and
JavaxServletWebApplication for the old javax.servlet. Both implement IServletWebApplication and are created with
buildApplication(ServletContext) and turn a request and response into an exchange with buildExchange(request, response). The 3.1 changes that
follow from this are that the #request, #response, #session and #servletContext expression objects are gone from the Standard Dialect (use
${param.x}, ${session.x} and ${application.x}, or add the data to the context), and that ${execInfo} no longer exists; see the archived
Thymeleaf 3.1 what’s new article.
Two behaviours of the servlet bridge surprised the author while testing and are worth knowing before the filter example. First,
IWebExchange.getLocale() is read from the response (HttpServletResponse.getLocale()), which is the default locale of the JVM unless something
set it. A filter that wants the visitor’s language must copy it with response.setLocale(request.getLocale()) before building the context, or pass
request.getLocale() to the WebContext explicitly; without that, a request with Accept-Language: es was rendered with the JVM’s locale. Second,
IWebExchange.transformURL(String) is the hook that @{...} link expressions use; in a servlet it calls response.encodeURL, and a first request
without a session cookie therefore produced /css/gtvg.css;jsessionid=... as the stylesheet URL.
The Good Thymes Virtual Grocery example
The official tutorial builds its examples on a small web application, the Good Thymes Virtual Grocery (GTVG), which exists in a javax.* and a
jakarta.* version. Its web layer is one servlet filter that maps each URL to a small controller object, and each controller creates a context and
processes a template. The shape is worth studying, because it is exactly the division of labour that Spring MVC later automates. The listings below
are a reduced version of it that was run on embedded Jetty.
public interface IGtvgController {
void process(IWebExchange webExchange, ITemplateEngine templateEngine, Writer writer) throws Exception;
}
record Product(String name, double price) { }
private static final Map<String, IGtvgController> CONTROLLERS = Map.of(
"/", (exchange, engine, writer) -> {
exchange.getSession().setAttributeValue("visits",
exchange.getSession().exists() && exchange.getSession().containsAttribute("visits")
? (Integer) exchange.getSession().getAttributeValue("visits") + 1 : 1);
WebContext ctx = new WebContext(exchange, exchange.getLocale());
ctx.setVariable("today", java.time.LocalDate.of(2026, 10, 7));
engine.process("home", ctx, writer);
},
"/products", (exchange, engine, writer) -> {
WebContext ctx = new WebContext(exchange, exchange.getLocale());
ctx.setVariable("prods", List.of(new Product("Onions", 2.41), new Product("Garlic", 1.1)));
engine.process("products", ctx, writer);
});
Source: Using Thymeleaf — Contexts
public class GtvgFilter implements Filter {
private IWebApplication application;
private ITemplateEngine templateEngine;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
this.application = JakartaServletWebApplication.buildApplication(filterConfig.getServletContext());
this.application.setAttributeValue("shop", "GTVG");
this.templateEngine = buildTemplateEngine(this.application);
}
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
if (!process((HttpServletRequest) req, (HttpServletResponse) res)) {
chain.doFilter(req, res); // not ours: let other filters and servlets run
}
}
private boolean process(HttpServletRequest request, HttpServletResponse response) throws ServletException {
try {
JakartaServletWebApplication app = (JakartaServletWebApplication) this.application;
IWebExchange webExchange = app.buildExchange(request, response);
IWebRequest webRequest = webExchange.getRequest();
IGtvgController controller = CONTROLLERS.get(webRequest.getPathWithinApplication());
if (controller == null) {
return false;
}
response.setLocale(request.getLocale()); // IWebExchange.getLocale() reads the response
response.setContentType("text/html;charset=UTF-8");
response.setHeader("Cache-Control", "no-cache");
controller.process(webExchange, this.templateEngine, response.getWriter());
return true;
} catch (Exception e) {
try {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
} catch (IOException ignored) {
// nothing else to do
}
throw new ServletException(e);
}
}
private static ITemplateEngine buildTemplateEngine(IWebApplication application) {
WebApplicationTemplateResolver templateResolver = new WebApplicationTemplateResolver(application);
templateResolver.setTemplateMode(TemplateMode.HTML);
templateResolver.setPrefix("/WEB-INF/templates/");
templateResolver.setSuffix(".html");
templateResolver.setCacheTTLMs(3_600_000L);
templateResolver.setCacheable(true);
TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(templateResolver);
return engine;
}
}
The resolver is the one from the tutorial: the template mode HTML, the prefix /WEB-INF/templates/, the suffix .html, and a one-hour TTL, after
which a cached template is parsed again. The filter can be registered in web.xml or, as in the scratch project, programmatically in an embedded
Jetty (new ServletContextHandler("/", ServletContextHandler.SESSIONS) with addFilter(new FilterHolder(new GtvgFilter()), "/*", ...); the sessions
option matters, because without a session manager the first controller above fails with "No SessionManager"). The static files under /css or
/images are not mapped by the controller map, so the filter lets them through to the container’s default servlet.
The home page template, with its externalised text, request parameter, session and application attributes:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Good Thymes Virtual Grocery</title>
<link rel="stylesheet" href="../../css/gtvg.css" th:href="@{/css/gtvg.css}" />
</head>
<body>
<p th:text="#{home.welcome}">Welcome to our grocery store!</p>
<p>Today is <span th:text="${#temporals.format(today, 'dd MMM yyyy')}">01 Jan 2026</span>,
lang=<span th:text="${#locale}">en</span>, q=<span th:text="${param.q}">-</span>,
visits=<span th:text="${session.visits}">0</span>, app=<span th:text="${application.shop}">shop</span></p>
</body>
</html>
The messages live next to the template: /WEB-INF/templates/home.properties for the default, home_es.properties for Spanish. A request
curl -H 'Accept-Language: es' 'localhost:8089/?q=a%3Cb' returned (the stylesheet URL carries the session id because that first request had no cookie):
<!DOCTYPE html>
<html>
<head>
<title>Good Thymes Virtual Grocery</title>
<link rel="stylesheet" href="/css/gtvg.css;jsessionid=node01xeodmaiz7uiy1qgv6zdkkwqhd1.node0" />
</head>
<body>
<p>¡Bienvenido a nuestra tienda de comestibles!</p>
<p>Today is <span>07 oct 2026</span>,
lang=<span>es</span>, q=<span>a<b</span>,
visits=<span>1</span>, app=<span>GTVG</span></p>
</body>
</html>
Note that the request parameter a<b came out as a<b: th:text escapes. The same request with a different Accept-Language showed the
default English text and, with the session cookie sent back, visits=2; /products returned the <li> list and an unmapped URL returned the
container’s 404.
Message resolvers
Messages (#{key} expressions) are looked up through org.thymeleaf.messageresolver.IMessageResolver, which has four methods: getName(),
getOrder(), resolveMessage(ITemplateContext, Class<?> origin, String key, Object[] messageParameters) and
createAbsentMessageRepresentation(...). If you set nothing, the engine uses a StandardMessageResolver.
The standard resolver reads .properties files that sit beside the template and have its name: for /WEB-INF/templates/home.html and the locale
gl_ES it tries, in order, home_gl_ES.properties, home_gl.properties and home.properties. Fragments inserted from other templates also
contribute their own files, in insertion order. After the template files it tries properties named after the "origin" class of the caller (for
messages requested by a processor written in Java), and finally the default messages added with addDefaultMessage(key, value) or
setDefaultMessages(Properties), which do not depend on the locale. If nothing is found, an expression #{no.such.key} renders the absent-message
representation, which marks the key and the locale so that a missing translation is visible:
StandardMessageResolver messages = new StandardMessageResolver();
messages.addDefaultMessage("app.name", "Bookshelf"); // locale independent
TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(resolver); // a TEXT-mode StringTemplateResolver
engine.setMessageResolver(messages);
Context ctx = new Context(Locale.forLanguageTag("gl-ES"));
engine.process("[(#{app.name})] / [(#{no.such.key})]", ctx);
which printed Bookshelf / ??no.such.key_gl_ES??.
A custom resolver is the way to take messages from a database, a service or a map. setMessageResolver sets exactly one; addMessageResolver
adds another to the chain, and as with template resolvers they are asked in order, so that when the first returns null the second is tried. If
every resolver returns null, a #{...} expression renders the absent-message representation, because such expressions always ask for it; in the
run below it came from the first resolver in the chain.
static class MapMessageResolver extends AbstractMessageResolver {
private final Map<String, Map<String, String>> byLanguage;
MapMessageResolver(Map<String, Map<String, String>> byLanguage) {
this.byLanguage = byLanguage;
}
@Override
public String resolveMessage(ITemplateContext ctx, Class<?> origin, String key, Object[] params) {
Map<String, String> m = byLanguage.get(ctx.getLocale().getLanguage());
String pattern = m == null ? null : m.get(key);
return pattern == null ? null : new MessageFormat(pattern, ctx.getLocale()).format(params);
}
@Override
public String createAbsentMessageRepresentation(ITemplateContext ctx, Class<?> origin, String key, Object[] params) {
return "[missing:" + key + "]";
}
}
MapMessageResolver custom = new MapMessageResolver(Map.of(
"en", Map.of("shop.hello", "Hello {0}"),
"es", Map.of("shop.hello", "Hola {0}")));
custom.setOrder(1);
StandardMessageResolver standard = new StandardMessageResolver();
standard.setOrder(2);
TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(resolver);
engine.addMessageResolver(standard);
engine.addMessageResolver(custom);
Conversion services
The double-brace syntax ${{price}} asks for the value to be converted and formatted by a conversion service. That is a feature of the Standard
Dialect, not of the engine, so a custom service is set on the StandardDialect that you configure into the engine:
StandardDialect dialect = new StandardDialect();
dialect.setConversionService(new IStandardConversionService() {
private final IStandardConversionService delegate = new StandardConversionService();
@Override
public <T> T convert(IExpressionContext ctx, Object object, Class<T> targetClass) {
if (object instanceof Double d && targetClass == String.class) {
return targetClass.cast(String.format(ctx.getLocale(), "%,.2f EUR", d));
}
return delegate.convert(ctx, object, targetClass);
}
});
engine.setDialect(dialect);
The default StandardConversionService simply calls toString() on any object it converts to a String. With the message resolvers above and the
template [(#{shop.hello(${n})})] | [(#{nope.key})] | [[${price}]] | [[${{price}}]] in TEXT mode, the two locales printed:
Hello Ana | [missing:nope.key] | 1234.5 | 1,234.50 EUR
Hola Ana | [missing:nope.key] | 1234.5 | 1.234,50 EUR
The third column, the plain ${price}, is untouched by the conversion service; only the double braces go through it. The absent message came from
the custom resolver, because it has the lower order. In a Spring application you rarely set one by hand: SpringStandardDialect
comes pre-configured with an IStandardConversionService that delegates to Spring’s own conversion service, which is why a Spring application
usually never sets one by hand.
Logging
Thymeleaf logs through SLF4J and emits TRACE, DEBUG and INFO messages. Besides the normal per-class loggers (everything under org.thymeleaf), it has
special loggers tied to the TemplateEngine class that you can switch on independently:
| Logger | Output |
|---|---|
|
The detailed configuration of the library, printed once when the engine initialises: version, cache manager, template resolvers, message resolvers, link builders, the dialect and every processor with its precedence. |
|
The time taken to process each template. Useful for benchmarking. |
|
Prefix of the cache loggers. By default |
-Dorg.slf4j.simpleLogger.log.org.thymeleaf.TemplateEngine.CONFIG=trace
-Dorg.slf4j.simpleLogger.log.org.thymeleaf.TemplateEngine.TIMER=trace
-Dorg.slf4j.simpleLogger.log.org.thymeleaf.TemplateEngine.cache.TEMPLATE_CACHE=trace
Running the first example with those flags printed, among other lines (shortened here):
TRACE org.thymeleaf.TemplateEngine.CONFIG - Initializing Thymeleaf Template engine configuration...
[THYMELEAF] * Thymeleaf version: 3.1.5.RELEASE (built 2026-04-21T20:38:36+0000)
[THYMELEAF] * Cache Manager implementation: org.thymeleaf.cache.StandardCacheManager
[THYMELEAF] * Template resolvers:
[THYMELEAF] * org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
[THYMELEAF] * Message resolvers:
[THYMELEAF] * org.thymeleaf.messageresolver.StandardMessageResolver
[THYMELEAF] * Dialect: Standard (org.thymeleaf.standard.StandardDialect)
...
TRACE org.thymeleaf.TemplateEngine.cache.TEMPLATE_CACHE - [THYMELEAF][...][CACHE_MISS] Cache miss in cache "TEMPLATE_CACHE" for key "hello".
TRACE org.thymeleaf.TemplateEngine.cache.TEMPLATE_CACHE - [THYMELEAF][...][CACHE_ADD][1] Adding cache entry in cache "TEMPLATE_CACHE" for key "hello". New size is 1.
TRACE org.thymeleaf.TemplateEngine.TIMER - [THYMELEAF][...][hello][es][46257750][46] TEMPLATE "hello" WITH LOCALE es PROCESSED IN 46257750 nanoseconds (approx. 46ms)
TRACE org.thymeleaf.TemplateEngine.cache.TEMPLATE_CACHE - [THYMELEAF][...][CACHE_HIT] Cache hit in cache "TEMPLATE_CACHE" for key "hello".
TRACE org.thymeleaf.TemplateEngine.TIMER - [THYMELEAF][...][hello][en][395584][0] TEMPLATE "hello" WITH LOCALE en PROCESSED IN 395584 nanoseconds (approx. 0ms)
The timer lines show the effect of the cache directly: the first rendering of hello took 46 ms (a cache miss: read, parse, process) and the second
under a millisecond (a cache hit). Use the TEMPLATE_CACHE logger to find templates that never hit the cache, for instance because their resolver is
not cacheable. With Log4j or Logback the same logger names go into the usual configuration, for example logger name="org.thymeleaf.TemplateEngine.TIMER" level="TRACE".
Source: Using Thymeleaf — Logging
The template cache
Thymeleaf parses a template into a sequence of events (open tag, text, close tag, comment, …) and applies the processors to that sequence. The cache stores the parsed sequence, so that cloning an in-memory event sequence replaces reading and parsing the file on each request. The bet is that a web application has a few dozen small templates that do not change while it runs, so keeping them in memory is cheap and saves most of the input and output. The cache is controlled in two places.
At the resolver (per template origin and per template): setCacheable(boolean) as the default, getCacheablePatternSpec() and
getNonCacheablePatternSpec() for specific names, and setCacheTTLMs(Long) for the lifetime of an entry. A TTL does not guarantee the entry lives
that long: the cache has a maximum size and expels the oldest entry when it is full.
At the cache manager: the default is org.thymeleaf.cache.StandardCacheManager, with one cache for parsed templates and one for parsed expressions.
Its setters (setTemplateCacheMaxSize, setTemplateCacheInitialSize, setTemplateCacheUseSoftReferences, setTemplateCacheName,
setTemplateCacheLoggerName, setTemplateCacheValidityChecker, setTemplateCacheEnableCounters, and the same six for the Expression cache)
tune it. The defaults are exposed as constants: the template cache holds 200 entries and the expression cache 500
(StandardCacheManager.DEFAULT_TEMPLATE_CACHE_MAX_SIZE and DEFAULT_EXPRESSION_CACHE_MAX_SIZE, printed as 200 and 500 in the run). To go
further, implement ICacheManager and give it to the engine with setCacheManager.
You can also empty the cache from code: engine.clearTemplateCache() removes everything, and engine.clearTemplateCacheFor("name") removes one
template (the name is the one you pass to process).
FileTemplateResolver resolver = new FileTemplateResolver();
resolver.setPrefix(dir + "/");
resolver.setTemplateMode(TemplateMode.TEXT);
resolver.setCacheable(true);
resolver.setCacheTTLMs(3_600_000L);
resolver.getNonCacheablePatternSpec().addPattern("volatile*");
StandardCacheManager cacheManager = new StandardCacheManager();
cacheManager.setTemplateCacheMaxSize(100);
cacheManager.setExpressionCacheMaxSize(200);
TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(resolver);
engine.setCacheManager(cacheManager);
engine.process("banner.txt", ctx); // file holds v1 -> v1
// now the files are edited: banner.txt becomes v2, volatile.txt a -> b
engine.process("banner.txt", ctx); // still v1 (cached)
engine.process("volatile.txt", ctx); // b (never cached)
engine.clearTemplateCacheFor("banner.txt");
engine.process("banner.txt", ctx); // v2
// edit again to v3
engine.clearTemplateCache();
engine.process("banner.txt", ctx); // v3
The program printed v1, then after edit : v1 / b, then after clearCacheFor : v2, then after clearTemplateCache: v3.
Source: Using Thymeleaf — Template Cache
The practical rule is: leave the cache on in production, and turn it off for development with setCacheable(false) on the resolver (or a
non-cacheable pattern for the templates being edited), because a cached template never notices that its file changed. A cacheable template with no
TTL stays valid until the cache is full, so a deployment that replaces template files under a running engine needs a TTL or a call to
clearTemplateCache(). The Performance & Production page returns to sizing the cache and to the decoupled-logic option
setUseDecoupledLogic(true), which this page lists but does not otherwise cover.
Pitfalls
| Symptom | Cause and fix |
|---|---|
|
A setter was called after the first |
|
No resolver could resolve the name. Check prefix, suffix and resolvable patterns, and that a resolver in front of the right one does not claim
every name (set |
A template or fragment that exists is not found when several resolvers are configured |
A resolver without |
Edits to a template are not visible |
The parsed template is cached. Use |
The page ignores the visitor’s language |
|
|
The web resolver needs an |
|
The message is missing for that locale and in the default |
A migrated template uses |
Those expression objects were removed in 3.1. Use |
Template name, URL or text taken from user input |
Read a file or URL chosen by the visitor, or evaluate attacker-supplied template text. Map user input to a fixed set of template names. |