Conversion & Formatting
|
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. The Spring MVC examples were compiled and run on that stack (Maven wrapper, MockMvc, JDK 26 with --release 21),
and the rendered output shown is the real output. The double-brace syntax and the #conversions utility object belong to the Standard
Dialect, so they work with Thymeleaf standalone, with Spring MVC and with Spring WebFlux; what changes is where the
conversion service comes from. The form-binding parts (Formatter, @DateTimeFormat, @InitBinder) are Spring MVC features, and the
spring.webflux.format.* properties are the WebFlux counterpart of the spring.mvc.format.* ones shown here. The WebFlux side was not
run for this page.
A web page moves values in two directions. A form field arrives as text and must become a LocalDate, a BigDecimal or an Isbn
(parsing); a value in the model must become text again before it is written into the HTML (printing). If every template and every
controller did that by hand, the same date pattern would be copied in a dozen places and each copy would disagree about the locale.
Spring centralises both directions in one ConversionService, and Thymeleaf plugs into it, so that the template can say format this
value the way the application formats it instead of saying how. This page continues the
Bookshelf scenario: books get an ISBN, a publication date, a price and an author.
Two conversion services, one bridge
There are two different things called a conversion service, and the Spring integration connects them.
-
org.thymeleaf.standard.expression.IStandardConversionServiceis a Standard Dialect feature. It has a single method,convert(IExpressionContext, Object, Class<T>), and it is what the double-brace expressions and#conversionscall. The default implementation,StandardConversionService, converts an object to aStringwithtoString()and does nothing else. -
Spring’s
org.springframework.core.convert.ConversionServiceis the application-wide registry ofConverterandFormatterobjects, used by data binding,@PathVariable,@RequestParamand@Value.
With Spring MVC the second one is exposed to Thymeleaf. Spring MVC publishes its ConversionService as a request attribute,
ThymeleafView puts it in the evaluation context, and SpringStandardDialect is pre-configured with
org.thymeleaf.spring6.expression.SpringStandardConversionService, which asks that conversion service whether it can convert the
object and falls back to toString() when it cannot. There is no Thymeleaf-specific registration step: everything registered in
Spring MVC’s conversion service is automatically available to the template. The tutorial describes the same arrangement for
its thymeleaf-spring packages.
The double-brace syntax
A normal ${book.publishedOn} writes the value as the evaluator returns it. Doubling the braces, ${{book.publishedOn}}, passes
the result of the expression to the conversion service and asks it for a String. The selection form is *{{...}}, which is the
same thing relative to a th:object. The test below posts a book form and renders the template with both forms side by side.
<body th:object="${bookForm}">
<p id="plain" th:text="*{publishedOn}">2026-10-08</p>
<p id="converted" th:text="*{{publishedOn}}">08/10/2026</p>
<p id="price" th:text="${{bookForm.price}}">1,234.50</p>
<p id="isbn" th:text="*{{isbn}}">978-0134685991</p>
<p id="author" th:text="*{{author}}">1</p>
<p id="manual" th:text="${#conversions.convert(bookForm.isbn, 'String')}">978-0134685991</p>
<p id="title" th:text="*{title}">Title</p>
</body>
For a form posted with isbn=978-0-06-125965-2, publishedOn=01/05/1974, price=1,234.50, author=1 and a title with
surrounding blanks, the response is:
<body>
<p id="plain">1974-05-01</p>
<p id="converted">01/05/1974</p>
<p id="price">1,234.50</p>
<p id="isbn">978-0061259652</p>
<p id="author">1</p>
<p id="manual">978-0061259652</p>
<p id="title">The Dispossessed</p>
</body>
The single-brace plain paragraph shows the ISO form that LocalDate.toString() produces; the double-brace paragraphs show the
patterns registered with Spring (the sections below explain where each one comes from). Three details are worth remembering:
-
Double braces change only printing. They are not parsed on input, and they do not change what an expression selects.
-
Writing the double brace is an explicit request: an unconverted
${x}never goes through the conversion service inth:text,th:valueor any other attribute, even if a formatter is registered for the type. -
th:fieldis the exception. It always applies the conversion service, soth:field="*{publishedOn}"behaves liketh:field="*{{publishedOn}}". The tutorial points out that this is, per Spring’s requirement, the only place where single braces convert.
Conversion inside th:field
Because th:field prints through the conversion service, an input bound to an Isbn-typed property is rendered with the formatter’s
output, and when the form is redisplayed after a failed binding it shows the text the user typed instead. A GET with
?isbn=978-0061259652 binds the value to the form object, and this template line
<input type="text" th:field="*{isbn}">
renders the formatted value (the form tag also receives the CSRF hidden field, since the application uses Spring Security):
<form action="/conv/form" method="post"><input type="hidden" name="_csrf" value="..."/>
<input type="text" id="title" name="title" value="">
<input type="text" id="isbn" name="isbn" value="978-0061259652">
The value is printed by the IsbnFormatter registered below. Forms in general, including th:object and th:errors, are the topic of
the forms page; this page only covers the conversion that happens underneath.
The #conversions utility object
#conversions runs the conversion service wherever a double brace is awkward, for example inside a larger expression. It offers two
methods, which differ only in how the target type is given:
-
${#conversions.convert(object, targetClass)}, where the target is aClass -
${#conversions.convert(object, 'java.util.TimeZone')}, where the target is a class name in a string. The tutorial notes that thejava.lang.package can be omitted, which is why'String'works in the example above.
Both are implemented by org.thymeleaf.expression.Conversions, and both call the same service as the double brace. The other
utility objects are in Expression Utility Objects.
<p th:text="${'ISBN: ' + #conversions.convert(bookForm.isbn, 'String')}">ISBN: 978-0134685991</p>
Registering formatters and converters in Spring
Spring has two extension points for conversion:
| SPI | Shape | Use it for |
|---|---|---|
|
One direction, no locale: |
Mapping between types: a string id to an entity, an entity to its id string |
|
Both directions with a |
Text for people: dates, numbers, currency, identifiers shown with separators |
The Spring reference recommends the formatter SPI for web (client) environments, where localized field values are parsed and printed,
and the converter SPI for general type conversion. Formatter extends Printer and Parser, and the ConversionService offers
a unified API over both.
A formatter for the ISBN value object:
public record Isbn(String digits) {
public Isbn {
if (digits == null || !digits.matches("\\d{13}")) {
throw new IllegalArgumentException("An ISBN-13 has 13 digits: " + digits);
}
}
}
public 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);
}
@Override
public Isbn parse(String text, Locale locale) throws ParseException {
String digits = text.replaceAll("[-\\s]", "");
try {
return new Isbn(digits);
} catch (IllegalArgumentException e) {
throw new ParseException("Not an ISBN-13: " + text, 0);
}
}
}
Registration with addFormatters
The tutorial registers formatters by implementing WebMvcConfigurer and overriding addFormatters(FormatterRegistry). With Spring
Boot you do this in an ordinary @Configuration class that does not carry @EnableWebMvc (which would switch off Boot’s MVC
auto-configuration):
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addFormatter(new IsbnFormatter());
}
}
Spring Boot’s documentation describes the same hook (a WebMvcConfigurer bean with an addFormatters method, optionally delegating
to the static methods of ApplicationConversionService).
Source: Spring Boot — Conversion Service
Registration as beans
Boot’s Spring MVC auto-configuration lists "automatic registration of Converter, GenericConverter, and Formatter beans" among
its features, so a converter or formatter that is a Spring bean needs no addFormatters call at all, and it can receive injected
collaborators such as a repository. The converters used for the author select and the path variable below are plain @Component
classes registered this way:
@Component
public class AuthorFromIdConverter implements Converter<String, Author> {
private final AuthorRepository authors;
public AuthorFromIdConverter(AuthorRepository authors) { this.authors = authors; }
@Override
public Author convert(String source) {
if (source.isBlank()) {
return null; // "no author selected"
}
return authors.findById(Long.valueOf(source))
.orElseThrow(() -> new IllegalArgumentException("Unknown author " + source));
}
}
@Component
public class AuthorToIdConverter implements Converter<Author, String> {
@Override
public String convert(Author source) { return String.valueOf(source.id()); }
}
The second converter (entity to string) is what makes *{{author}} print 1 in the earlier output. When no converter or formatter
is registered for a type, the double brace falls back to toString().
A custom conversion service outside Spring
Standalone, there is no Spring ConversionService, so the default toString() behaviour applies until you supply an
IStandardConversionService and set it on the StandardDialect in use. The easiest base class is
AbstractStandardConversionService: it already handles the conversion to String and to other types, and you override the two
protected methods convertToString and convertOther. (StandardConversionService itself is final.) The service receives the
IExpressionContext, so the context locale is available:
static class DateConversionService extends AbstractStandardConversionService {
@Override
protected String convertToString(IExpressionContext context, Object object) {
if (object instanceof LocalDate date) {
return date.format(DateTimeFormatter.ofPattern("dd/MM/yyyy", context.getLocale()));
}
return super.convertToString(context, object);
}
}
StandardDialect dialect = new StandardDialect();
dialect.setConversionService(new DateConversionService());
TemplateEngine engine = new TemplateEngine();
engine.setDialect(dialect); // resolver configuration omitted: see the standalone engine page
Processing the template string
<p th:text="${day}"></p><p th:text="${{day}}"></p><p th:text="${#conversions.convert(day, 'String')}"></p>
with a LocalDate of 8 October 2026 gives:
<p>2026-10-08</p><p>08/10/2026</p><p>08/10/2026</p>
A plain TemplateEngine with the default dialect renders the double-brace expression as <p>2026-10-08</p>, that is, with
toString(). The engine setup itself is covered in Standalone Engine & Configuration.
Annotation-driven formatting
Most fields do not need a custom formatter: the annotations @DateTimeFormat and @NumberFormat attach the format to the field of
the form object, and Spring’s formatting infrastructure applies it in both directions (binding the request, and printing through
th:field or the double brace).
public class BookForm {
private String title;
private Isbn isbn;
@DateTimeFormat(pattern = "dd/MM/yyyy")
private LocalDate publishedOn;
@NumberFormat(pattern = "#,##0.00")
private BigDecimal price;
private Author author;
// getters and setters
}
The attributes verified against the Spring Framework 7.0.9 classes are:
| Annotation | Attributes |
|---|---|
|
|
|
|
The annotation can also be placed on a controller method parameter such as @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate day.
Formatting follows the request locale. Posting the price in the Spanish format with the request locale set to es
(price=1.234,50) and printing ${{bookForm.price}} gives the Spanish grouping and decimal separators back:
<p id="price">1.234,50</p>
The locale comes from the LocaleResolver in use, which by default reads the Accept-Language header; choosing and switching it is
the subject of the internationalization page.
Source: Javadoc: NumberFormat
Application-wide defaults with spring.mvc.format
Fields without an annotation get a default format. Spring Boot lets you change the defaults with three properties (the description strings are those of the 4.1.1 configuration metadata):
| Property | Applies to |
|---|---|
|
|
|
|
|
|
Boot’s documentation states that, when unset, the date formats use the localized SHORT style of DateTimeFormatter. A LocalDate
path variable printed with ${day} and ${{day}} shows the difference between the ISO toString() and the locale default:
<p th:text="${day}">2026-10-08</p><p th:text="${{day}}">08/10/2026</p>
<p>2026-10-08</p><p>10/8/26</p>
With a test that sets spring.mvc.format.date=dd-MM-yyyy (the request is GET /conv/days/08-10-2026, so the property is also used to
parse the path variable):
spring.mvc.format.date=dd-MM-yyyy
<p>2026-10-08</p><p>08-10-2026</p>
Note that spring.mvc.date-format is deprecated at error level since Boot 3.0.0 in favour of spring.mvc.format.date. For
WebFlux the equivalent properties are spring.webflux.format.date, spring.webflux.format.time and
spring.webflux.format.date-time. The other properties of the section are in
Spring Boot Auto-configuration & Properties.
Source: Spring Boot — Conversion Service
PropertyEditor versus Formatter
Before the conversion service existed, Spring’s data binder used java.beans.PropertyEditor objects, and they are still supported:
WebDataBinder#registerCustomEditor registers one for a type or a single property, and an editor registered on the binder is
used in preference to the conversion service. A PropertyEditor is a stateful object (it holds the value it is editing, in setAsText
and getAsText), so a new instance must be registered for each binder; a Formatter is stateless and registered once for the
whole application. Editors can only convert from and to text, and they get no Locale argument.
| PropertyEditor | Formatter | |
|---|---|---|
Registration |
per controller in |
once, in the conversion service |
State and threads |
stateful, one instance per binder |
stateless, shared |
Locale |
none (state must be captured by the instance) |
passed to |
Seen by |
no, the conversion service does not know it |
yes |
Seen by |
yes, when registered on the binder |
yes |
The ${{...}} row is the important one for Thymeleaf: an editor affects binding and th:field, but the double brace and
#conversions consult only the conversion service. A legacy java.util.Date bound through an editor is printed in th:field with the
editor’s text:
@Controller
public class EditorDemoController {
@InitBinder
void initBinder(WebDataBinder binder) {
// a new editor (and a new SimpleDateFormat) for every binder: editors are not thread-safe
binder.registerCustomEditor(java.util.Date.class,
new CustomDateEditor(new SimpleDateFormat("yyyy-MM-dd"), true));
}
@GetMapping("/conv/editor")
String editor(Loan loan) { return "conv/loan"; }
}
<body th:object="${loan}"><input type="text" th:field="*{dueOn}"></body>
Source: Spring Framework — @InitBinder
A request to /conv/editor?dueOn=2026-12-24 renders:
<body><input type="text" id="dueOn" name="dueOn" value="2026-12-24"></body>
New code should prefer a Formatter, or @DateTimeFormat on the field; keep editors for classes you cannot annotate or for
a binder-specific tweak such as the one in the next section.
Trimming input with StringTrimmerEditor
A frequent binder customization is trimming text fields, and making blank fields null instead of the empty string.
org.springframework.beans.propertyeditors.StringTrimmerEditor does exactly that; its boolean constructor argument says whether an
empty string is converted to null:
@Controller
public class ConversionController {
@InitBinder
void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(String.class, new StringTrimmerEditor(true));
}
@PostMapping("/conv/form")
String submit(BookForm bookForm, BindingResult result) {
return result.hasErrors() ? "conv/form" : "conv/saved";
}
// ...
}
Source: Javadoc: StringTrimmerEditor
In the rendered output earlier, title was posted as " The Dispossessed " and printed as The Dispossessed; in the Spanish-locale
test, where title was not posted at all, the paragraph is empty rather than the string null, because th:text writes nothing for
null. An @InitBinder method with no value applies to every binding in that controller; to share it between controllers, put the
method in a @ControllerAdvice class.
Id and value-object converters
Entities and value objects travel through URLs and forms as text, and the converters turn them back into objects before the controller runs.
Two converters registered once (the AuthorFromIdConverter, AuthorToIdConverter and IsbnFormatter above) serve every use.
Path variables
A @PathVariable of type Isbn or Author is converted with the same registry, and the template can use the object directly:
@GetMapping("/conv/books/{isbn}")
String book(@PathVariable Isbn isbn, Model model) {
model.addAttribute("isbn", isbn);
return "conv/isbn";
}
@GetMapping("/conv/authors/{author}")
String author(@PathVariable Author author, Model model) {
model.addAttribute("author", author);
return "conv/author";
}
<p th:text="${{isbn}}">978-0134685991</p>
GET /conv/books/978-0061259652 renders <p>978-0061259652</p> and GET /conv/authors/2 renders <p>Stanislaw Lem</p>. When the
converter throws, the request fails before the controller is reached: GET /conv/authors/99 and GET /conv/books/12 both returned
400 Bad Request (a MethodArgumentTypeMismatchException in the log). That is the right behaviour for unknown ids, and it is a reason to throw
an exception in the converter instead of returning null for an id that should exist. Convert a blank string to null only when "none"
is a legal value, as in the select below.
Because a converter that loads an entity touches a repository, keep it cheap and read-only; it runs on every request that mentions the
parameter, and it must not do anything a GET should not do.
Selects
A <select> bound to an Author property uses ids as option values. The option values are plain ids (th:value="${a.id}"), and
th:field marks the option whose value matches the bound author as selected:
<select th:field="*{author}">
<option value="">-- none --</option>
<option th:each="a : ${authors}" th:value="${a.id}" th:text="${a.name}">Author</option>
</select>
When the form is posted with author=2 the converter binds the Author record and the redisplayed select is:
<select id="author" name="author">
<option value="">-- none --</option>
<option value="1">Ursula K. Le Guin</option>
<option value="2" selected="selected">Stanislaw Lem</option>
</select>
The empty option posts an empty string, which AuthorFromIdConverter turns into null, so "no author" is representable.
When conversion fails
If the text cannot be converted (a malformed ISBN, the non-existent date 31/02/1974), Spring does not throw: it records a field error
with the code typeMismatch, keeps the rejected value, and the form is redisplayed with the user’s text in the input. In the
test, the ISBN 12-34 and the date 31/02/1974 were posted with author=2; the inputs show the typed values and the select keeps
the chosen author. By default the error text is a technical message such as Failed to convert property value of type
'java.lang.String' to required type 'java.time.LocalDate' …, which should never reach a user. Spring resolves the message from the
MessageSource using the codes typeMismatch.bookForm.publishedOn, typeMismatch.publishedOn, typeMismatch.java.time.LocalDate and
typeMismatch (most specific first), so a messages.properties can supply friendly text:
typeMismatch.bookForm.isbn=An ISBN-13 has 13 digits, for example 978-0061259652.
typeMismatch.bookForm.publishedOn=Use the format dd/MM/yyyy, for example 01/05/1974.
With those two keys and the markup
<input type="text" th:field="*{isbn}">
<span th:if="${#fields.hasErrors('isbn')}" th:errors="*{isbn}">ISBN error</span>
the redisplayed form contains:
<input type="text" id="isbn" name="isbn" value="12-34">
<span>An ISBN-13 has 13 digits, for example 978-0061259652.</span>
<input type="text" id="publishedOn" name="publishedOn" value="31/02/1974">
<span>Use the format dd/MM/yyyy, for example 01/05/1974.</span>
Message-code resolution and Bean Validation messages are covered on the validation page. Throw a ParseException (or an
IllegalArgumentException) from Formatter#parse, as IsbnFormatter does, rather than returning a partially valid object.
Pitfalls
| Symptom | Cause and fix |
|---|---|
The date shows as |
A single-brace |
|
No |
The editor registered in |
Editors affect binding and |
Formatter works in one controller and not in another |
The registration was an |
Boot’s MVC defaults vanish after adding a configuration class |
The class carries |
Blank input saves an empty string |
Register |
The converted number uses the wrong separators |
The request locale is not the one expected; check the |
Raw |
Add |
Notes on the books
The consulted edition of Taming Thymeleaf (Wim Deblauwe, PDF v1.1.1, 2021) uses the same two ideas, converters for id types in edit
forms and @InitBinder with a trimming editor as a tip. Both still apply; with Boot, converter beans are picked up automatically, which
makes the explicit registration step unnecessary for them.