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.IStandardConversionService is a Standard Dialect feature. It has a single method, convert(IExpressionContext, Object, Class<T>), and it is what the double-brace expressions and #conversions call. The default implementation, StandardConversionService, converts an object to a String with toString() and does nothing else.

  • Spring’s org.springframework.core.convert.ConversionService is the application-wide registry of Converter and Formatter objects, used by data binding, @PathVariable, @RequestParam and @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:

Rendered output
<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 in th:text, th:value or any other attribute, even if a formatter is registered for the type.

  • th:field is the exception. It always applies the conversion service, so th:field="*{publishedOn}" behaves like th: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):

Rendered output
<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 a Class

  • ${#conversions.convert(object, 'java.util.TimeZone')}, where the target is a class name in a string. The tutorial notes that the java.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

Converter<S, T>

One direction, no locale: T convert(S source)

Mapping between types: a string id to an entity, an entity to its id string

Formatter<T>

Both directions with a Locale: String print(T, Locale) and T parse(String, Locale) throws ParseException

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).

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:

Rendered output
<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

@DateTimeFormat

style (a two-character style such as MM), iso (DATE, TIME, DATE_TIME or NONE), pattern and fallbackPatterns

@NumberFormat

style (DEFAULT, NUMBER, PERCENT or CURRENCY) and pattern

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:

Rendered output
<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.

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

spring.mvc.format.date

java.util.Date and java.time.LocalDate

spring.mvc.format.time

LocalTime and OffsetTime

spring.mvc.format.date-time

LocalDateTime, OffsetDateTime and ZonedDateTime

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>
Rendered output (no property set, English locale)
<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
Rendered output
<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.

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 @InitBinder (or globally with @ControllerAdvice)

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 print and parse

Seen by ${{...}}

no, the conversion service does not know it

yes

Seen by th:field

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>

A request to /conv/editor?dueOn=2026-12-24 renders:

Rendered output
<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";
    }
    // ...
}

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:

Rendered output
<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:

Rendered output
<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 2026-10-08 although a formatter is registered

A single-brace ${...} does not convert. Use ${{...}}, *{{...}}, or th:field.

${{...}} prints toString() of the entity

No Converter<Entity, String> or Formatter is registered for that type, so the fallback toString() is used.

The editor registered in @InitBinder does not change ${{...}}

Editors affect binding and th:field only. Register a Formatter for the application-wide rule.

Formatter works in one controller and not in another

The registration was an @InitBinder editor; formatters belong in the conversion service.

Boot’s MVC defaults vanish after adding a configuration class

The class carries @EnableWebMvc. Remove it and implement WebMvcConfigurer.

Blank input saves an empty string

Register StringTrimmerEditor(true) in an @InitBinder or @ControllerAdvice.

The converted number uses the wrong separators

The request locale is not the one expected; check the LocaleResolver rather than the pattern.

Raw Failed to convert property value ... text in the page

Add typeMismatch.* messages.

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.