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>
Source: Spring Boot — Validation
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
BindingResultmust 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 anErrors/BindingResult, the controller is called even when the input is invalid. Otherwise Spring raisesMethodArgumentNotValidException, which the default resolver turns into a400response and the controller body never runs. AModelbetween the two is enough to break it, and aBindingResultplaced before its attribute fails with anIllegalStateExceptionwhose 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
BindingResultin 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:objectname, herebook. TheBindingResultis stored in the model underorg.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:
<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:ifon it keeps an error element out of the page when there is nothing to say. -
#fields.errors('title')returns aList<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 foryearabove) 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 thesummaryparagraph in the output shows (it leaves only a blank line), so theth:ifin front of it is redundant in Thymeleaf 3.1.5; the Thymeleaf + Spring tutorial still shows theth: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 thenameorth:fieldattribute of the same tag and, when that field has errors, appends the class to whateverclassthe tag already has (form-control is-invalid). Without errors theclassis left untouched. Use it instead ofth: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>
<b>bold</b> & more<p id="escaped"><b>bold</b> & 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.
Source: Javadoc — Fields
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>
<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>
Source: Thymeleaf + Spring — All errors
| Method | Returns | Meaning |
|---|---|---|
|
|
Does the field (or the group denoted by |
|
|
Same as |
|
|
Same as |
|
|
Same as |
|
|
The resolved messages for the field, or for the whole group |
|
|
Same as |
|
|
Same as |
|
|
Same as |
|
|
Rich objects (field name, code, arguments, message, global flag) for one field or group |
|
|
Rich objects for every error, field and global |
|
|
Same list as |
|
|
Rich objects for the global errors only |
|
|
The |
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.
Source: Javadoc — Fields
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:
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>
<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>
<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>
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:
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:
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:
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: aDefaultMessageSourceResolvablewhose own codes areobject.fieldandfield, so it is looked up in the same message file (title=Titleabove). That is how{0} must have...printedTitle must have.... Without atitleentry the label is the field name itself. -
The following arguments are the constraint attributes sorted alphabetically by name: for
@Sizethey aremax({1}, 60) andmin({2}, 3), so the template reads "between{2}and{1}`". For `@Patterntheflagsarray comes beforeregexp. 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");
}
Source: Spring Boot — Validation
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:
<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>
<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>
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:
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>
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>
@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>
<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 sameidthatth:fieldgave the input, so the two never drift apart; global errors have no field, so they are plain text. -
aria-invalid="true"marks the field andaria-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. Theidof the message element is written next toth:errors, which keeps the reference valid only when the message exists (the element is removed when there is no error, and thearia-describedbyis not emitted either). -
novalidateon the form turns off the browser’s own bubbles, so that the server-rendered messages are the single, translatable source (keeprequiredandaria-requiredso 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
| Mistake | What happens | Do this |
|---|---|---|
A parameter (such as |
|
Declare the |
The |
|
Put it right after the attribute |
No |
Invalid input produces a |
Add the |
Redirecting after a failed validation |
The |
Return the view name, redirect only on success |
|
The errors exist but the template looks for another |
Use the same name ( |
Expecting |
Constraints with |
Use |
|
Proxy-based method validation: a |
Leave the class-level annotation off so that Spring MVC raises |
Expecting the static content of a |
The element is removed from the output when the field has no error |
Treat the static content as a prototype placeholder only; the |
|
The message is not escaped: cross-site scripting |
Use |
|
Only the object-level (global) errors are shown |
Use |
|
A selection expression is evaluated against the closest |
Use the variable forms |
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 |
The placeholder is printed literally ( |
Pass the arguments to |
Writing |
"between 60 and 3 characters" |
The attributes are sorted by name: |
No |
The user reads |
Add a |
Treating |
The value is the text |
Test |
Relying only on a red border or on |
No text and no association with the input for assistive technology |
Message text, |
Version notes
-
javax.validationbecamejakarta.validationwith Spring Boot 3 and Spring Framework 6. The books consulted for this section showjavax.validation(the Taming Thymeleaf PDF v1.1.1 is from 2021 and uses Spring Boot 2.3); the Bean Validation concepts,BindingResult,#fieldsand the message-code rules are unchanged, only the imports and the Boot coordinates are not. -
Built-in method validation for
@RequestMappingmethods, withHandlerMethodValidationException, 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-spring6is the Spring integration used with Boot 4.1; there is nothymeleaf-spring7artifact, andFieldsandth:errorsare unchanged since Thymeleaf 3.0.3 (the version in which the currentFieldsclass 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:errorsbehaviour of removing the element when there is nothing to show is as observed on 3.1.5.RELEASE; verify it again when upgrading.