Validation & Error Messages

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, Jakarta Validation 3.1.1 and Hibernate Validator 9.1.3.Final as managed by the Boot BOM) and Java 21, checked on 2026-10-08, and every example was compiled and run on that stack (Maven wrapper, MockMvc tests, JDK 26 with --release 21; the rendered HTML shown is the real output, with the CSRF token shortened). The #fields object, th:errors and th:errorclass belong to the Spring integration module thymeleaf-spring6, so everything about displaying errors applies to Thymeleaf with Spring MVC, and, because the same processors run, to Spring WebFlux as well; the standalone engine has no BindingResult and therefore no #fields (see the Standalone Engine & Configuration page). The controller examples use Spring MVC (servlet) BindingResult handling.

A form that cannot say what is wrong with the input is a form people abandon. Validation itself is not a Thymeleaf feature: Spring runs Bean Validation over the form object while it binds the request, collects the failures into a BindingResult, and puts that object in the model. What Thymeleaf adds is a small vocabulary for the view: the #fields expression object, the th:errors attribute and the th:errorclass attribute. This page follows an error from the constraint annotation to the HTML, and then looks at how to write your own constraints, validate in stages and mark the result up so that assistive technology can use it. The page builds on the Bookshelf scenario of Getting Started; the form binding itself (th:object, th:field) is the topic of the forms page of this section, and the conversion that produces a type mismatch is covered by Conversion & Formatting.

Validating the form object

The form object of the running example carries the constraints. They are plain Jakarta Bean Validation annotations (the packages are jakarta.validation.* since Spring Boot 3; the 2021 edition of Taming Thymeleaf still shows javax.validation.*):

public class BookForm {

    @NotBlank
    @Size(min = 3, max = 60)
    private String title;

    @NotBlank
    @Pattern(regexp = "\\d{13}")
    private String isbn;

    @NotNull
    @Min(1450)
    @Max(2100)
    private Integer year;

    @Size(max = 200)
    private String summary;

    // getters and setters
}

The dependency is the Boot validation starter, which brings Hibernate Validator (the Bean Validation implementation) and the Jakarta Validation API. It is not part of spring-boot-starter-thymeleaf:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Validation is switched on in the controller by annotating the form parameter with @Valid (Jakarta) or @Validated (Spring), and the result is received by a BindingResult parameter:

@Controller
@RequestMapping("/validation")
public class ValidationController {

    @GetMapping("/books/new")
    public String newBook(Model model) {
        model.addAttribute("book", new BookForm());
        return "validation/book-form";
    }

    @PostMapping("/books")
    public String create(@Valid @ModelAttribute("book") BookForm book, BindingResult result) {
        if (result.hasErrors()) {
            return "validation/book-form";           // redisplay, do not redirect
        }
        return "redirect:/validation/books/new";     // success: Post/Redirect/Get
    }
}

Three facts about this method decide whether the errors reach the template at all:

  • The BindingResult must come immediately after the model attribute it describes. Spring decides this by looking at the method signature: if the parameter right after the validated one is an Errors/BindingResult, the controller is called even when the input is invalid. Otherwise Spring raises MethodArgumentNotValidException, which the default resolver turns into a 400 response and the controller body never runs. A Model between the two is enough to break it, and a BindingResult placed before its attribute fails with an IllegalStateException whose message says the argument "is expected to be declared immediately after the model attribute".

  • A failed validation does not redirect. The invalid form is rendered again from the same request, so that the BindingResult in the model, the rejected values and the messages are still there. A redirect would lose them (they are request attributes, not session data). Redirecting is the right answer to a valid post, the Post/Redirect/Get pattern.

  • The model attribute name must match the th:object name, here book. The BindingResult is stored in the model under org.springframework.validation.BindingResult.book, and Thymeleaf finds it from the object name.

The following tests, run against the controller, pin down all three facts:

@Test
void bindingResultNotRightAfterTheModelAttribute() throws Exception {
    // a Model parameter between the attribute and the BindingResult: 400, controller not called
    mvc.perform(bookPost("/validation/books/separated", "", "", "").with(csrf()))
            .andExpect(status().isBadRequest());
    // BindingResult before the attribute: IllegalStateException
    assertThatThrownBy(() -> mvc.perform(bookPost("/validation/books/first", "", "", "").with(csrf())))
            .rootCause().isInstanceOf(IllegalStateException.class).hasMessageContaining("BindingResult");
}

@Test
void withoutBindingResultInvalidInputIs400() throws Exception {
    mvc.perform(bookPost("/validation/books/no-result", "", "", "").with(csrf()))
            .andExpect(status().isBadRequest());
}

@Test
void validFormRedirects() throws Exception {
    mvc.perform(bookPost("/validation/books", "Effective Java", "9780134685992", "2018").with(csrf()))
            .andExpect(status().is3xxRedirection());
}

A related behaviour that is easy to confuse with this one: when the constraint annotation sits directly on a method parameter (@RequestParam @Min(1) int page) and not on a form object, Spring applies built-in method validation (since Spring Framework 6.1) and raises HandlerMethodValidationException, which the default resolver also turns into a 400 (the test methodParameterValidationIs400 confirms it for page=0). There is no BindingResult in that case unless one follows the parameter. For this to work the controller class must not carry a class-level @Validated, because that switches to AOP-proxy based validation instead.

Showing the errors: #fields, th:errors and th:errorclass

Inside a form bound with th:object, three tools cover most needs. The template below uses all of them on the BookForm:

<form th:action="@{/validation/books}" th:object="${book}" method="post">
  <ul th:if="${#fields.hasErrors('*')}" class="summary">
    <li th:each="err : ${#fields.errors('*')}" th:text="${err}">Input is incorrect</li>
  </ul>

  <label for="title">Title</label>
  <input type="text" th:field="*{title}" class="form-control" th:errorclass="is-invalid">
  <p th:if="${#fields.hasErrors('title')}" th:errors="*{title}" class="error">Incorrect title</p>

  <label for="isbn">ISBN</label>
  <input type="text" th:field="*{isbn}" th:errorclass="is-invalid">
  <span th:each="err : ${#fields.errors('isbn')}" th:text="${err}" class="error">ISBN error</span>

  <label for="year">Year</label>
  <input type="text" th:field="*{year}" th:errorclass="is-invalid">
  <p th:if="${#fields.hasErrors('year')}"
     th:text="${#strings.listJoin(#fields.errors('year'), ' / ')}" class="error">Incorrect year</p>

  <label for="summary">Summary</label>
  <textarea th:field="*{summary}" th:errorclass="is-invalid"></textarea>
  <p th:errors="*{summary}" class="error">Summary is fine</p>

  <button type="submit">Save</button>
</form>

Posting an empty form (the year field is empty, so Spring binds null) gives:

Rendered after posting an empty form
<form action="/validation/books" method="post"><input type="hidden" name="_csrf" value="..."/>
  <ul class="summary">
    <li>The ISBN must be exactly 13 digits</li>
    <li>Please enter a title for the book</li>
    <li>This field is required</li>
    <li>Title must have between 3 and 60 characters</li>
    <li>must not be null</li>
  </ul>

  <label for="title">Title</label>
  <input type="text" class="form-control is-invalid" id="title" name="title" value="">
  <p class="error">Please enter a title for the book<br />Title must have between 3 and 60 characters</p>

  <label for="isbn">ISBN</label>
  <input type="text" id="isbn" name="isbn" value="" class="is-invalid">
  <span class="error">The ISBN must be exactly 13 digits</span><span class="error">This field is required</span>

  <label for="year">Year</label>
  <input type="text" id="year" name="year" value="" class="is-invalid">
  <p class="error">must not be null</p>

  <label for="summary">Summary</label>
  <textarea id="summary" name="summary"></textarea>


  <button type="submit">Save</button>
</form>

What each tool does, as the output shows:

  • #fields.hasErrors('title') returns a boolean: does this field have at least one error? The argument is a field name, not an expression, so it is quoted. th:if on it keeps an error element out of the page when there is nothing to say.

  • #fields.errors('title') returns a List<String> with the resolved messages of that field (the text, already looked up in the message source, see below), so you can iterate it, join it (#strings.listJoin(...), as for year above) or count it.

  • th:errors="*{title}" is the same list as a ready-made element body: all the messages of the field, separated by <br />. The text is HTML-escaped. When the field has no errors, the whole element is removed from the output, as the summary paragraph in the output shows (it leaves only a blank line), so the th:if in front of it is redundant in Thymeleaf 3.1.5; the Thymeleaf + Spring tutorial still shows the th:if, and keeping it costs nothing. The static content of the element (Incorrect title) is only a prototype placeholder, which keeps the template natural: opened straight in a browser, the designer sees an error message in the right place.

  • th:errorclass="is-invalid" is evaluated on a form-field tag. It reads the field name from the name or th:field attribute of the same tag and, when that field has errors, appends the class to whatever class the tag already has (form-control is-invalid). Without errors the class is left untouched. Use it instead of th:class="${#fields.hasErrors('year')}? fieldError", which would replace the existing class.

Two details of the output deserve attention. The messages of one field and the entries of the summary come in no guaranteed order: Bean Validation returns the violations as a set, so the order of the errors may differ between two runs (it was different in two executions of this very test). If the order matters to users, sort in the controller or pick messages explicitly instead of listing them all. And the year message is must not be null, the English default of the constraint, because the example defines no message for NotNull: the next sections explain how a message is chosen.

Because th:errors escapes the text and th:uerrors does not, the second one must never be used with messages that contain user input:

<p id="escaped" th:errors="*{title}">x</p>
<p id="unescaped" th:uerrors="*{title}">x</p>
Rendered when the message is <b>bold</b> & more
<p id="escaped">&lt;b&gt;bold&lt;/b&gt; &amp; more</p>
<p id="unescaped"><b>bold</b> & more</p>

The constraint messages you write are trusted, but a message can include the rejected value ({0} arguments or a message that quotes the rejected value), and then it is not. Use th:errors or th:text.

Every #fields method

#fields is an instance of org.thymeleaf.spring6.expression.Fields, created for each template execution from the expression context. It has ten method names (thirteen signatures), all of them used in one template so that the real return values can be read side by side. The form below is posted with an empty title, a valid ISBN and abc as the year:

<div th:object="${book}">
<pre>
hasErrors()           = [[${#fields.hasErrors()}]]
hasErrors('title')    = [[${#fields.hasErrors('title')}]]
hasErrors('*')        = [[${#fields.hasErrors('*')}]]
hasAnyErrors()        = [[${#fields.hasAnyErrors()}]]
hasGlobalErrors()     = [[${#fields.hasGlobalErrors()}]]
errors()              = [[${#fields.errors()}]]
errors('title')       = [[${#fields.errors('title')}]]
errors('all')         = [[${#fields.errors('all')}]]
allErrors()           = [[${#fields.allErrors()}]]
globalErrors()        = [[${#fields.globalErrors()}]]
detailedErrors()      = [[${#fields.detailedErrors()}]]
detailedErrors('year') = [[${#fields.detailedErrors('year')}]]
allDetailedErrors()   = [[${#fields.allDetailedErrors()}]]
globalDetailedErrors() = [[${#fields.globalDetailedErrors()}]]
idFromName('title')   = [[${#fields.idFromName('title')}]]
</pre>
<p th:errors="*{all}">all</p>
<p th:errors="*{global}">global</p>
</div>
Rendered
<pre>
hasErrors()           = true
hasErrors('title')    = true
hasErrors('*')        = true
hasAnyErrors()        = true
hasGlobalErrors()     = false
errors()              = [The publication year must be a whole number, Please enter a title for the book, Title must have between 3 and 60 characters]
errors('title')       = [Please enter a title for the book, Title must have between 3 and 60 characters]
errors('all')         = [The publication year must be a whole number, Please enter a title for the book, Title must have between 3 and 60 characters]
allErrors()           = [The publication year must be a whole number, Please enter a title for the book, Title must have between 3 and 60 characters]
globalErrors()        = []
detailedErrors()      = [year:The publication year must be a whole number, title:Please enter a title for the book, title:Title must have between 3 and 60 characters]
detailedErrors('year') = [year:The publication year must be a whole number]
allDetailedErrors()   = [year:The publication year must be a whole number, title:Please enter a title for the book, title:Title must have between 3 and 60 characters]
globalDetailedErrors() = []
idFromName('title')   = title
</pre>
<p>The publication year must be a whole number<br />Please enter a title for the book<br />Title must have between 3 and 60 characters</p>
Table 1. The #fields methods
Method Returns Meaning

hasErrors(field)

boolean

Does the field (or the group denoted by '*', 'all' or 'global', see below) have errors?

hasErrors()

boolean

Same as hasErrors('*'): any field error, and, as the output of the loan example below shows, also a global one

hasAnyErrors()

boolean

Same as hasErrors('*')

hasGlobalErrors()

boolean

Same as hasErrors('global'): errors that belong to no field

errors(field)

List<String>

The resolved messages for the field, or for the whole group

errors()

List<String>

Same as errors('*')

allErrors()

List<String>

Same as errors('*')

globalErrors()

List<String>

Same as errors('global')

detailedErrors(field)

List<DetailedError>

Rich objects (field name, code, arguments, message, global flag) for one field or group

detailedErrors()

List<DetailedError>

Rich objects for every error, field and global

allDetailedErrors()

List<DetailedError>

Same list as detailedErrors()

globalDetailedErrors()

List<DetailedError>

Rich objects for the global errors only

idFromName(name)

String

The id string for a field name (title for title), used here to link a summary entry to its input

The equivalences in the table were confirmed with the outputs of the examples on this page (hasErrors(), errors() and detailedErrors() equal their '*' versions). The javadoc of Fields lists the signatures but does not describe them, and the Thymeleaf + Spring tutorial documents hasErrors, errors, hasAnyErrors, allErrors, hasGlobalErrors, globalErrors and detailedErrors; the other three (idFromName, allDetailedErrors and globalDetailedErrors) were checked against the 3.1.5 class and the output above.

The same list of methods appears, in short form, in the Expression Utility Objects page. #fields is only available with the Spring integration (it comes from SpringStandardDialect), which is why the standalone engine does not have it.

The '*', 'all' and 'global' constants

The argument of hasErrors and errors (and of th:errors, through the selection expression) can be a field name, or one of three constants:

  • '*' and 'all' are equivalent and mean every error of the form object, field errors and global errors alike.

  • 'global' means only the errors that are not tied to a field.

In the selection form, th:errors="*{all}" and th:errors="*{global}" mean the same, but a selection expression is always evaluated against the object chosen by the closest th:object, so they work only inside such an element (see the next section for the alternative). A global error is easiest to see on a class-level constraint, shown further down; the loan form posted with a loan period of two months renders these #fields values:

Rendered by the loan form template for a loan that lasts too long
hasErrors()        = true
hasErrors('*')     = true
hasErrors('global') = true
hasAnyErrors()     = true
hasGlobalErrors()  = true
errors()           = [A loan cannot last more than 28 days]
errors('global')   = [A loan cannot last more than 28 days]
globalErrors()     = [A loan cannot last more than 28 days]
detailedErrors()   = [[global]:A loan cannot last more than 28 days]
globalDetailedErrors() = [[global]:A loan cannot last more than 28 days]

Note that for a global error the fieldName of the DetailedError is the literal text [global], which is why the next-but-one section tests the global flag instead of using the name as a link target.

Errors outside forms

Sometimes the errors are shown away from the th:object: in a banner at the top of the layout, or in a fragment rendered on its own. A selection expression has nothing to select there, so the variable form is used instead, prefixing the name of the model attribute:

<div id="banner" th:if="${#fields.hasErrors('${book}')}">
  <p th:errors="${book}">All the errors of the book form</p>
</div>
<p id="title-only" th:errors="${book.title}">Incorrect title</p>
<p id="everything" th:errors="${book.*}">Incorrect values</p>
<p id="has-all" th:if="${#fields.hasErrors('${book.*}')}" th:text="'some field has errors'">none</p>
Rendered for an empty title, a valid ISBN and a valid year
<p id="title-only">Please enter a title for the book<br />Title must have between 3 and 60 characters</p>
<p id="everything">Please enter a title for the book<br />Title must have between 3 and 60 characters</p>

<p id="has-all">some field has errors</p>

The banner is missing from the output, and the explanation is the main trap of this syntax: ${book} alone means the object-level errors of the bean, which are the global ones, while ${book.*} means every error of every field plus them. With a class-level constraint that fails the same expressions give the opposite picture:

<p id="global-object" th:if="${#fields.hasErrors('${loan}')}" th:errors="${loan}">Loan errors</p>
<p id="dueOn-only" th:if="${#fields.hasErrors('${loan.dueOn}')}" th:errors="${loan.dueOn}">Due date errors</p>
<p id="everything" th:errors="${loan.*}">Loan and field errors</p>
Rendered for a loan that lasts two months
<p id="global-object">A loan cannot last more than 28 days</p>

<p id="everything">A loan cannot last more than 28 days</p>

The dueOn-only paragraph is absent because the field itself has no error. Use ${book} for the global errors and ${book.*} or ${book.field} for the rest. The model attribute must be present, and the BindingResult that follows it in the controller is what makes the errors reachable.

Rich error objects

#fields.detailedErrors() returns org.thymeleaf.spring6.util.DetailedError beans instead of strings. The class has getFieldName(), getCode(), getArguments(), getMessage() and isGlobal(), so in expressions they are fieldName, code, arguments, message and global. This is the tool for an error summary that needs more than the text:

<ul>
  <li th:each="e : ${#fields.detailedErrors()}" th:class="${e.global} ? globalerr : fielderr">
    <span th:text="${e.global} ? '*' : ${e.fieldName}">The field name</span> |
    <span th:text="${e.code}">code</span> |
    <span th:text="${e.message}">The error message</span>
  </li>
</ul>
Rendered for the empty-title, abc-year post
<ul>
  <li class="fielderr">
    <span>year</span> |
    <span>typeMismatch</span> |
    <span>The publication year must be a whole number</span>
  </li>
  <li class="fielderr">
    <span>title</span> |
    <span>NotBlank</span> |
    <span>Please enter a title for the book</span>
  </li>
  <li class="fielderr">
    <span>title</span> |
    <span>Size</span> |
    <span>Title must have between 3 and 60 characters</span>
  </li>
</ul>

code is the first part of the code list, here the constraint name or typeMismatch, which is handy to pick an icon or to translate on the client. For a global error global is true and fieldName is [global].

From constraint to message: message-code resolution

The messages in the output above did not come from the annotations. They came from a messages.properties file, looked up by codes. When the constraint fails (or a binding error happens), Spring registers the error with a list of codes, produced by the MessageCodesResolver (by default DefaultMessageCodesResolver) from the error code, the name of the object, the name of the field and the type of the field, from the most specific to the least specific. The resolver is applied to the errors that Bean Validation reports as well, with the name of the constraint annotation as the error code. The real code lists of the test with an empty title and ISBN and abc as the year are:

Codes recorded for each error (from a test that prints the FieldError)
year  -> [typeMismatch.book.year, typeMismatch.year, typeMismatch.java.lang.Integer, typeMismatch]
isbn  -> [NotBlank.book.isbn, NotBlank.isbn, NotBlank.java.lang.String, NotBlank]
title -> [NotBlank.book.title, NotBlank.title, NotBlank.java.lang.String, NotBlank]
title -> [Size.book.title, Size.title, Size.java.lang.String, Size]
isbn  -> [Pattern.book.isbn, Pattern.isbn, Pattern.java.lang.String, Pattern]

When a page renders the error, the message source is asked for the codes in that order, and the first code that exists in the MessageSource wins:

flowchart TD err["FieldError for the field title of the model attribute book, produced by @NotBlank"] c1["NotBlank.book.title: constraint, object and field"] c2["NotBlank.title: constraint and field"] c3["NotBlank.java.lang.String: constraint and field type"] c4["NotBlank: constraint only"] dm["Default message: the constraint message interpolated by Bean Validation, for example must not be blank"] found["Message text found: arguments are applied to its placeholders"] err --> c1 c1 -->|not in MessageSource| c2 c2 -->|not in MessageSource| c3 c3 -->|not in MessageSource| c4 c4 -->|not in MessageSource| dm c1 -->|found| found c2 -->|found| found c3 -->|found| found c4 -->|found| found found --> th["th:errors, #fields.errors and #fields.detailedErrors return the text"] dm --> th

The messages file of the example uses all four levels, which shows the precedence:

# labels used for the first argument of every message
title=Title
isbn=ISBN
year=Publication year

# most specific: constraint, object, field
NotBlank.book.title=Please enter a title for the book
# least specific: any NotBlank anywhere
NotBlank=This field is required

Size.book.title={0} must have between {2} and {1} characters
Pattern.isbn=The {0} must be exactly 13 digits
typeMismatch.book.year=The publication year must be a whole number
typeMismatch=The value is not valid

Reading the earlier output with this file: the empty title gets Please enter a title for the book (the NotBlank.book.title entry) while the empty ISBN gets This field is required (only the plain NotBlank entry exists for it), and year has no NotNull entry at all, so it falls through to the interpolated default must not be null. The type mismatch (abc in the year) uses the typeMismatch.book.year entry; without any typeMismatch code the user would see the exception text, Failed to convert property value of type 'java.lang.String' to required type 'java.lang.Integer' for property 'year'; For input string: "abc", which is the default message of that error. typeMismatch is the code for every binding conversion failure (it is the error that the conversion page’s formatters produce), and it exists in four variants like any other code, as the first line of the listing shows.

The arguments of a message

The text found for a code may contain placeholders. In a .properties file they are written {0}, {1} and so on (the syntax of java.text.MessageFormat, which is also why a literal apostrophe must be doubled). The arguments are the same for every code of one error:

Arguments of each error (printed by the same test)
year  : [label[book.year, year]]
title (Size)    : [label[book.title, title], 60, 3]
isbn  (Pattern) : [label[book.isbn, isbn], [Ljakarta.validation.constraints.Pattern$Flag;@6bfe4dd1, \d{13}]
  • {0} is always the field label: a DefaultMessageSourceResolvable whose own codes are object.field and field, so it is looked up in the same message file (title=Title above). That is how {0} must have... printed Title must have.... Without a title entry the label is the field name itself.

  • The following arguments are the constraint attributes sorted alphabetically by name: for @Size they are max ({1}, 60) and min ({2}, 3), so the template reads "between {2} and {1}`". For `@Pattern the flags array comes before regexp. That order is easy to get wrong when writing a message by hand, and the only reliable source is the printed argument list.

  • For a rejection made by hand with rejectValue, the arguments are the ones the code passes (see the next example).

The same lookup is what makes a validation message translatable: the codes are resolved with the locale of the request, so adding messages_es.properties with the same keys is enough. The internationalization page of this section covers the MessageSource properties and the locale resolvers. Note that {0} inside the properties file is not an AsciiDoc or a Thymeleaf expression, so there is nothing to escape there.

LocalValidatorFactoryBean and the MessageSource

The object that runs Bean Validation in Spring is org.springframework.validation.beanvalidation.LocalValidatorFactoryBean. It bootstraps the validation provider, implements both the Jakarta Validator and the Spring org.springframework.validation.Validator, and, in the second role, converts every ConstraintViolation into a FieldError (or ObjectError) with the code list described above.

Spring Boot defines one for you when no other Validator bean exists, and it already uses the application’s MessageSource when it resolves {parameters} in constraint messages (the Boot reference states this, and the example confirms it). That is the reason a constraint such as the custom @UniqueIsbn below can declare message = "{UniqueIsbn.message}" and have the text come from messages.properties. If you need to configure the factory yourself, define the bean and pass the message source explicitly; Boot’s own bean then backs off:

@Configuration
public class ValidationConfig {

    @Bean
    static LocalValidatorFactoryBean validator(MessageSource messageSource) {
        LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean();
        validator.setValidationMessageSource(messageSource);
        return validator;
    }
}

The bean method is static so that the factory can be created early, as Boot’s own definition does. To customize the jakarta.validation.Configuration used to build the factory without replacing it, the Boot reference suggests a ValidationConfigurationCustomizer bean instead (the interface is in org.springframework.boot.validation.autoconfigure). A test that injects the Jakarta Validator shows the message source at work, because the text of the violation is not in the annotation:

@Test
void constraintMessagesComeFromTheMessageSource() {
    EditionForm form = new EditionForm();
    form.setIsbn("9780134685991");
    Set<ConstraintViolation<EditionForm>> violations = validator.validate(form, Advanced.class);
    assertThat(violations).extracting(ConstraintViolation::getMessage)
            .containsExactly("This ISBN is already in the catalogue");
}

Two different lookups are involved and they are easy to mix up. The constraint message ({UniqueIsbn.message}) is interpolated at validation time and becomes the default message of the error. The error codes (UniqueIsbn.edition.isbn, …​) are resolved at render time, and win over the default message when they exist. Both read the same messages.properties.

Errors added by code: rejectValue and reject

Not every rule can be a constraint. A duplicate check needs a repository, a business rule may depend on several services. The controller adds such errors to the same BindingResult, and the template cannot tell the difference:

@PostMapping("/books")
public String create(@Valid @ModelAttribute("book") BookForm book, BindingResult result) {
    if (!result.hasFieldErrors("isbn") && catalog.contains(book.getIsbn())) {
        result.rejectValue("isbn", "isbn.duplicate", new Object[] {book.getIsbn()},
                "This ISBN is already catalogued");
    }
    if (result.hasErrors()) {
        return "validation/book-form";
    }
    return "redirect:/validation/books/new";
}

With isbn.duplicate=The ISBN {0} is already catalogued in the message file, posting the ISBN 9780134685991 renders the error on the ISBN field, with the value inserted from the argument:

Rendered
<input type="text" id="isbn" name="isbn" value="9780134685991" class="is-invalid">
<span class="error">The ISBN 9780134685991 is already catalogued</span>

rejectValue(field, code, args, defaultMessage) creates a field error, with codes isbn.duplicate.book.isbn, isbn.duplicate.isbn, isbn.duplicate.java.lang.String and isbn.duplicate; reject(code, args, defaultMessage) creates a global error. The check !result.hasFieldErrors("isbn") avoids a second, misleading message on a field that already failed its constraints.

Custom constraint validators

A custom constraint is an annotation plus a ConstraintValidator. The two kinds that matter for forms are the field-level constraint, which sees one value, and the class-level constraint, which sees the whole object and can therefore compare fields.

A field-level constraint with an injected dependency

The annotation declares its default message as a {...} key, so that the text lives in the message file:

@Documented
@Constraint(validatedBy = UniqueIsbnValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueIsbn {

    String message() default "{UniqueIsbn.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

The validator is instantiated by Spring (LocalValidatorFactoryBean plugs in a constraint validator factory that uses the application context), so it can take other beans through its constructor:

public class UniqueIsbnValidator implements ConstraintValidator<UniqueIsbn, String> {

    private final IsbnCatalog catalog;

    public UniqueIsbnValidator(IsbnCatalog catalog) {
        this.catalog = catalog;
    }

    @Override
    public boolean isValid(String isbn, ConstraintValidatorContext context) {
        return isbn == null || !catalog.contains(isbn);     // null is for @NotNull to reject
    }
}

A validator should treat null as valid and leave the rejection to @NotNull/@NotBlank, so that each rule reports one problem. The error codes of this constraint follow the same pattern as the built-in ones (UniqueIsbn.edition.isbn, UniqueIsbn.isbn, UniqueIsbn), so a message can be targeted as precisely as before.

A class-level constraint and addPropertyNode

Comparing two fields (a due date against a borrowing date) needs a constraint on the class. The validator below applies two rules, and shows the two ways in which a class-level violation is reported:

@ValidLoan(maxDays = 28)
public class LoanForm {

    @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) private LocalDate borrowedOn;
    @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) private LocalDate dueOn;

    // getters and setters
}

public class ValidLoanValidator implements ConstraintValidator<ValidLoan, LoanForm> {

    private int maxDays;

    @Override
    public void initialize(ValidLoan annotation) {
        this.maxDays = annotation.maxDays();
    }

    @Override
    public boolean isValid(LoanForm loan, ConstraintValidatorContext context) {
        if (loan.getBorrowedOn() == null || loan.getDueOn() == null) {
            return true;
        }
        if (loan.getDueOn().isBefore(loan.getBorrowedOn())) {
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("{ValidLoan.dueOn}")
                    .addPropertyNode("dueOn")
                    .addConstraintViolation();
            return false;
        }
        return ChronoUnit.DAYS.between(loan.getBorrowedOn(), loan.getDueOn()) <= maxDays;
    }
}

The annotation @ValidLoan (not repeated here) is the same as @UniqueIsbn with @Target(ElementType.TYPE), the default message {ValidLoan.message} and an int maxDays() default 28 attribute. The template shows both kinds of error:

<form th:action="@{/validation/loans}" th:object="${loan}" method="post">
  <div th:if="${#fields.hasGlobalErrors()}" class="global">
    <p th:each="err : ${#fields.globalErrors()}" th:text="${err}">Global error</p>
  </div>
  <input type="date" th:field="*{borrowedOn}" th:errorclass="is-invalid">
  <input type="date" th:field="*{dueOn}" th:errorclass="is-invalid">
  <p th:if="${#fields.hasErrors('dueOn')}" th:errors="*{dueOn}" class="error">Incorrect due date</p>
  <p th:if="${#fields.hasErrors('global')}" th:errors="*{global}" class="global-alt">Incorrect loan</p>
  <button type="submit">Lend</button>
</form>
Rendered for a loan that lasts two months (the violation without a property node is a global error)
<div class="global">
  <p>A loan cannot last more than 28 days</p>
</div>
<input type="date" id="borrowedOn" name="borrowedOn" value="2026-01-01">
<input type="date" id="dueOn" name="dueOn" value="2026-03-01">

<p class="global-alt">A loan cannot last more than 28 days</p>
Rendered for a due date before the loan date (the violation built with addPropertyNode("dueOn") is a field error)
<input type="date" id="borrowedOn" name="borrowedOn" value="2026-01-10">
<input type="date" id="dueOn" name="dueOn" value="2026-01-01" class="is-invalid">
<p class="error">The due date must not be before the loan date</p>

The difference is exactly the addPropertyNode call. A plain class-level violation has no property, so it becomes an object error with the codes ValidLoan.loan and ValidLoan, and #fields.hasGlobalErrors() is true. With addPropertyNode("dueOn") the same constraint produces a field error on dueOn, with codes ValidLoan.loan.dueOn, ValidLoan.dueOn, ValidLoan.java.time.LocalDate and ValidLoan (these two lists were printed by the test), so th:errorclass highlights the input and the message appears next to it. Class-level constraints and disableDefaultConstraintViolation() are the standard way to attach a cross-field rule to the field the user must fix.

If the dates themselves cannot be converted (yesterday), Spring registers typeMismatch on the field and the class-level validator returns true because it is given null (the test loanMissingDatesAndTypeMismatch posts it), so the field carries only the conversion error.

Validation groups and @GroupSequence

By default every constraint belongs to the Default group and @Valid validates that group, which is why the earlier examples needed no configuration. Groups are marker interfaces that let a constraint declare when it applies, and a group sequence lets one group run only when another has passed. The classical use in forms is to run a cheap syntax check first and an expensive check, such as a database lookup, only if the syntax is right:

public interface Basic { }

public interface Advanced { }

@GroupSequence({Basic.class, Advanced.class})
public interface Checks { }

public class EditionForm {

    @NotBlank(groups = Basic.class)
    @Pattern(regexp = "\\d{13}", groups = Basic.class)
    @UniqueIsbn(groups = Advanced.class)
    private String isbn;

    // getter and setter
}

The group that is validated is chosen in the controller, which means using Spring’s @Validated (which takes the group classes) and not @Valid (which cannot):

@PostMapping("/editions")
public String createEdition(@Validated(Checks.class) @ModelAttribute("edition") EditionForm edition,
        BindingResult result) {
    return "validation/edition-form";
}

With the template <input th:field="*{isbn}" th:errorclass="is-invalid"> and <p th:errors="*{isbn}"> the three cases give:

Rendered for 12: the Basic group fails, so the Advanced group is not evaluated
<input type="text" id="isbn" name="isbn" value="12" class="is-invalid">
<p class="error">The ISBN must be exactly 13 digits</p>
Rendered for 9780134685991, which is well formed but already catalogued
<input type="text" id="isbn" name="isbn" value="9780134685991" class="is-invalid">
<p class="error">This ISBN is already in the catalogue</p>
Rendered for the same ISBN when the endpoint uses a plain @Valid (only the Default group is validated)
<input type="text" id="isbn" name="isbn" value="9780134685991">

The last case is a trap: constraints that name other groups are silently ignored by @Valid, and the invalid form is accepted. The interface Checks holds the sequence rather than the form class; the alternative of placing @GroupSequence on the form class itself redefines the Default group for that class, which is described in the Hibernate Validator reference ("Redefining the default group sequence of a class"). Groups are also the usual way to reuse one form for creating and editing, for example with OnCreate and OnUpdate markers, one requiring an id and the other forbidding it.

Accessible error markup

Colour and a red border are not enough: a screen-reader user must be told that a field is invalid, which message belongs to it, and that the form has problems at all. The standards behind this are WCAG 2.2 success criterion 3.3.1 (Error Identification: the item in error is identified and the error is described to the user in text) and the WAI-ARIA states aria-invalid and aria-describedby. The accessibility guide of this site, Web Accessibility, explains the principles; the template below applies them with the tools of this page:

<form th:action="@{/validation/books/accessible}" th:object="${book}" method="post" novalidate>
  <section th:if="${#fields.hasAnyErrors()}" id="error-summary" role="alert" aria-labelledby="error-summary-title">
    <h2 id="error-summary-title" th:text="#{errors.summary(${#lists.size(#fields.allErrors())})}">Problems</h2>
    <ul>
      <li th:each="e : ${#fields.detailedErrors()}">
        <a th:if="${!e.global}" th:href="'#' + ${#fields.idFromName(e.fieldName)}" th:text="${e.message}">Error</a>
        <span th:if="${e.global}" th:text="${e.message}">Error</span>
      </li>
    </ul>
  </section>

  <label for="title">Title</label>
  <input type="text" th:field="*{title}" required aria-required="true"
         th:aria-invalid="${#fields.hasErrors('title')} ? 'true'"
         th:aria-describedby="${#fields.hasErrors('title')} ? 'title-error'">
  <p id="title-error" class="error" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">Incorrect title</p>
</form>
Rendered after posting an empty title
<section id="error-summary" role="alert" aria-labelledby="error-summary-title">
  <h2 id="error-summary-title">2 problems prevent saving the form</h2>
  <ul>
    <li>
      <a href="#title">Title must have between 3 and 60 characters</a>
    </li>
    <li>
      <a href="#title">Please enter a title for the book</a>
    </li>
  </ul>
</section>

<label for="title">Title</label>
<input type="text" required aria-required="true"
       aria-invalid="true"
       aria-describedby="title-error" id="title" name="title" value="">
<p id="title-error" class="error">Title must have between 3 and 60 characters<br />Please enter a title for the book</p>

Posting a valid form renders the same input without aria-invalid and aria-describedby: the attribute processors drop an attribute whose conditional expression has no else branch and evaluates to null, so a valid field does not carry a stale error state.

What each piece achieves:

  • role="alert" on the summary makes assistive technology announce it when the page loads with errors, and the links inside it take a keyboard user straight to the offending field. The link target is #fields.idFromName(...), the same id that th:field gave the input, so the two never drift apart; global errors have no field, so they are plain text.

  • aria-invalid="true" marks the field and aria-describedby="title-error" points to the element with the message, so the message is read together with the field label when the field gets focus. The id of the message element is written next to th:errors, which keeps the reference valid only when the message exists (the element is removed when there is no error, and the aria-describedby is not emitted either).

  • novalidate on the form turns off the browser’s own bubbles, so that the server-rendered messages are the single, translatable source (keep required and aria-required so that the requirement is still announced). Client-side checks are a convenience, not the validation.

  • The heading message is a count passed to #{errors.summary(...)}: errors.summary={0} problems prevent saving the form.

Whether the errors are enough for a given audience depends on contrast, focus handling and wording as much as on markup, and automated tools cannot judge them, as the accessibility guide explains. Moving focus to the summary on load, which this template does not do, is a further improvement that needs a small script.

The test that runs all of this

All the outputs on this page were produced by MockMvc tests of the following shape, which post the form with the CSRF token that Spring Security expects (th:action adds the hidden _csrf field to the rendered form automatically, because it goes through the RequestDataValueProcessor) and assert the rendered text:

@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
class ValidationTest {

    @Autowired MockMvc mvc;

    private String render(String name, MockHttpServletRequestBuilder request) throws Exception {
        MvcResult result = mvc.perform(request.with(csrf())).andExpect(status().isOk()).andReturn();
        String html = result.getResponse().getContentAsString();
        Path out = Path.of("target/rendered");
        Files.createDirectories(out);
        Files.writeString(out.resolve(name + ".html"), html);     // the output pasted on this page
        return html;
    }

    private static MockHttpServletRequestBuilder bookPost(String url, String title, String isbn, String year) {
        return post(url).param("title", title).param("isbn", isbn).param("year", year).param("summary", "");
    }

    @Test
    void emptyFormShowsEveryKindOfFieldError() throws Exception {
        String html = render("book-form-empty", bookPost("/validation/books", "", "", ""));
        assertThat(html).contains("is-invalid");
        assertThat(html).contains("Please enter a title for the book");
        assertThat(html).contains("Title must have between 3 and 60 characters");
        assertThat(html).contains("This field is required");
        assertThat(html).doesNotContain("Summary is fine");
    }
}

The test imports AutoConfigureMockMvc from org.springframework.boot.webmvc.test.autoconfigure, the package it has in Spring Boot 4 (it was org.springframework.boot.test.autoconfigure.web.servlet before). The scenarios on this page are covered by 19 tests of this kind (29 tests pass in the whole scratch application).

Pitfalls

Table 2. Validation and error-message pitfalls
Mistake What happens Do this

A parameter (such as Model) between the validated attribute and its BindingResult

MethodArgumentNotValidException, a 400; the controller never runs

Declare the BindingResult immediately after the model attribute

The BindingResult before the attribute

IllegalStateException: the argument is expected right after the model attribute

Put it right after the attribute

No BindingResult at all

Invalid input produces a 400 error page instead of your form

Add the BindingResult and redisplay the form when hasErrors()

Redirecting after a failed validation

The BindingResult and the rejected values are lost; the user sees an empty form with no messages

Return the view name, redirect only on success

th:object name different from the @ModelAttribute name

The errors exist but the template looks for another BindingResult

Use the same name (book) in both places

Expecting @Valid to honour groups

Constraints with groups = ... are ignored and the bad input is accepted

Use @Validated(Group.class)

@Validated on the controller class together with constraints on @RequestParam

Proxy-based method validation: a ConstraintViolationException propagates (the test shows it) instead of a 400

Leave the class-level annotation off so that Spring MVC raises HandlerMethodValidationException (a 400)

Expecting the static content of a th:errors element to appear when the field is valid

The element is removed from the output when the field has no error

Treat the static content as a prototype placeholder only; the th:if in front of it is optional in 3.1.5

th:uerrors with messages that contain user input

The message is not escaped: cross-site scripting

Use th:errors, or th:text over #fields.errors(...)

${book} to show all the errors outside the form

Only the object-level (global) errors are shown

Use ${book.*} for every error, ${book.field} for one field

*{all} or *{global} outside a th:object

A selection expression is evaluated against the closest th:object, so it only works inside one

Use the variable forms ${book.*} and ${book}, or the #fields functions

Relying on the order of the messages

The order of the violations changes between runs

Sort them, or choose the messages to display

A message with {0} but a rejection without arguments

The placeholder is printed literally (The ISBN {0} is...)

Pass the arguments to rejectValue, and remember that {0} is the field label for constraint messages

Writing {1} and {2} of @Size in the wrong order

"between 60 and 3 characters"

The attributes are sorted by name: max is {1}, min is {2}

No typeMismatch message

The user reads Failed to convert property value of type ...

Add a typeMismatch (or typeMismatch.object.field) entry to the message file

Treating fieldName of a global error as an id

The value is the text [global]

Test e.global first

Relying only on a red border or on required

No text and no association with the input for assistive technology

Message text, aria-invalid, aria-describedby, and a summary

Version notes

  • javax.validation became jakarta.validation with Spring Boot 3 and Spring Framework 6. The books consulted for this section show javax.validation (the Taming Thymeleaf PDF v1.1.1 is from 2021 and uses Spring Boot 2.3); the Bean Validation concepts, BindingResult, #fields and the message-code rules are unchanged, only the imports and the Boot coordinates are not.

  • Built-in method validation for @RequestMapping methods, with HandlerMethodValidationException, was added in Spring Framework 6.1. It is why a constraint directly on a request parameter no longer needs a class-level @Validated.

  • thymeleaf-spring6 is the Spring integration used with Boot 4.1; there is no thymeleaf-spring7 artifact, and Fields and th:errors are unchanged since Thymeleaf 3.0.3 (the version in which the current Fields class was introduced, per its javadoc).

  • In Boot 4 the auto-configuration of validation and the starter are as described in the Boot reference; the validation starter is spring-boot-starter-validation.

  • The th:errors behaviour of removing the element when there is nothing to show is as observed on 3.1.5.RELEASE; verify it again when upgrading.