Spring Boot Validation
Every application receives data it cannot trust: request bodies, query parameters, form fields and configuration files. Spring Boot validates this data with the Jakarta Bean Validation API (formerly JSR 380), using Hibernate Validator as the implementation. You describe the rules once, as annotations on your classes, and Spring checks them automatically before your code runs.
1. Adding the Dependency
Since Spring Boot 2.3, validation is not included in spring-boot-starter-web. Add the validation starter explicitly:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
If you use Gradle:
implementation 'org.springframework.boot:spring-boot-starter-validation'
In Spring Boot 3 and later, all validation annotations live in the jakarta.validation package. Imports from javax.validation compile, but they are ignored at runtime.
2. Built-in Constraint Annotations
These are the annotations you will use most often. All of them accept a message attribute to customize the error text.
| Annotation | What it checks | Example |
|---|---|---|
@NotNull | The value is not null | @NotNull Integer age |
@NotEmpty | Not null and not empty (strings, collections, maps, arrays) | @NotEmpty List<String> roles |
@NotBlank | Not null and contains at least one non-whitespace character | @NotBlank String name |
@Size | Length of a string or size of a collection is within a range | @Size(min = 2, max = 30) |
@Min / @Max | A number is at least or at most a value | @Min(18) @Max(120) |
@Positive / @PositiveOrZero | A number is greater than zero, or zero and above | @Positive BigDecimal price |
@DecimalMin / @Digits | Lower bound and digit precision for decimal values | @Digits(integer = 6, fraction = 2) |
@Email | The string is a well-formed email address | @Email String email |
@Pattern | The string matches a regular expression | @Pattern(regexp = "[A-Z]{3}") |
@Past / @Future | A date or time is before or after now | @Past LocalDate dateOfBirth |
@AssertTrue | A boolean is true | @AssertTrue boolean termsAccepted |
Most constraints treat null as valid. For example, @Email alone accepts a missing email. Combine it with @NotBlank or @NotNull when the field is required.
3. Example Request Model
Put constraints on the class that receives the incoming data, usually a request DTO rather than your JPA entity. A Java record keeps it short:
package com.javabytechie.user;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record UserRequest(
@NotBlank(message = "Name is required")
@Size(min = 2, max = 30, message = "Name must be between 2 and 30 characters")
String name,
@NotBlank(message = "Email is required")
@Email(message = "Email should be valid")
String email,
@NotNull(message = "Age is required")
@Min(value = 18, message = "Age must be at least 18")
@Max(value = 120, message = "Age must be at most 120")
Integer age,
@Pattern(regexp = "^\\+?[0-9]{10,15}$", message = "Phone must contain 10 to 15 digits")
String phone
) {
}
A regular class with fields, getters and setters works the same way. Place the annotations on the fields.
4. Validating Request Bodies in a Controller
Add @Valid before @RequestBody. Spring validates the object after reading the JSON and before calling your method:
package com.javabytechie.user;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping
public ResponseEntity<UserResponse> create(@Valid @RequestBody UserRequest request) {
UserResponse created = userService.register(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
}
If validation fails, the method is never called. Spring throws MethodArgumentNotValidException and responds with HTTP 400 Bad Request.
If you need to handle errors inside a single method, add a BindingResult parameter right after the validated object. Spring then passes the errors to you instead of throwing:
@PostMapping("/draft")
public ResponseEntity<?> saveDraft(@Valid @RequestBody UserRequest request, BindingResult result) {
if (result.hasErrors()) {
Map<String, String> errors = new LinkedHashMap<>();
result.getFieldErrors()
.forEach(error -> errors.putIfAbsent(error.getField(), error.getDefaultMessage()));
return ResponseEntity.badRequest().body(errors);
}
return ResponseEntity.ok(userService.saveDraft(request));
}
Repeating this check in every method gets tedious. The next section shows how to handle errors once for the whole application.
5. Returning Clear Error Responses
Spring Boot 3 supports Problem Details (RFC 9457), a standard JSON format for API errors. Extend ResponseEntityExceptionHandler and add the field errors to the response:
package com.javabytechie.common;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
error -> Objects.requireNonNullElse(error.getDefaultMessage(), "Invalid value"),
(first, second) -> first,
LinkedHashMap::new));
ProblemDetail problem = ex.getBody();
problem.setTitle("Validation failed");
problem.setProperty("errors", errors);
return handleExceptionInternal(ex, problem, headers, status, request);
}
}
Now send an invalid request:
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"name": "A", "email": "not-an-email", "age": 15}'
The client receives HTTP 400 with every problem listed by field:
{
"type": "about:blank",
"title": "Validation failed",
"status": 400,
"detail": "Invalid request content.",
"instance": "/api/users",
"errors": {
"name": "Name must be between 2 and 30 characters",
"email": "Email should be valid",
"age": "Age must be at least 18"
}
}
6. Validating Service Methods
Controllers are not the only entry point. Messages from Kafka, scheduled jobs and other services call your service layer directly. Add @Validated to the class, and Spring validates method parameters and return values on every call:
package com.javabytechie.user;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
@Service
@Validated
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public UserResponse register(@Valid UserRequest request) {
User saved = userRepository.save(User.from(request));
return UserResponse.from(saved);
}
@NotNull
public UserResponse findById(@Positive(message = "Id must be positive") Long id) {
return userRepository.findById(id)
.map(UserResponse::from)
.orElseThrow(() -> new UserNotFoundException(id));
}
}
A failure here throws ConstraintViolationException. Two points to remember:
- Method validation works through a Spring proxy, so it does not run when a method calls another method in the same class.
- Add an
@ExceptionHandler(ConstraintViolationException.class)to your@RestControllerAdviceif these errors can reach an HTTP client. Otherwise they return HTTP 500.
7. Writing a Custom Validation Annotation
If the built-in annotations do not fully meet your business requirements, you can create a custom annotation tailored to your specific needs.
In this example, we will create a custom annotation to validate password strength, with a configurable minimum password length.
First, let’s define the custom annotation:
package com.javabytechie.validation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
@Documented
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = StrongPasswordValidator.class)
public @interface StrongPassword {
String message() default
"Password must be at least {min} characters and contain an uppercase letter, "
+ "a lowercase letter, a digit and a special character";
int min() default 8;
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Then the validator that holds the logic. initialize() reads the annotation’s attributes once:
package com.javabytechie.validation;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class StrongPasswordValidator implements ConstraintValidator<StrongPassword, String> {
private int min;
@Override
public void initialize(StrongPassword annotation) {
this.min = annotation.min();
}
@Override
public boolean isValid(String password, ConstraintValidatorContext context) {
if (password == null) {
return true; // leave null checks to @NotBlank
}
return password.length() >= min
&& password.chars().anyMatch(Character::isUpperCase)
&& password.chars().anyMatch(Character::isLowerCase)
&& password.chars().anyMatch(Character::isDigit)
&& password.chars().anyMatch(ch -> !Character.isLetterOrDigit(ch));
}
}
Use it like any built-in constraint:
public record RegistrationRequest(
@NotBlank
@Email
String email,
@NotBlank
@StrongPassword(min = 10)
String password
) {
}
Returning true for null is a convention: each constraint checks one thing, and @NotBlank decides whether the value is required.
8. Validating Configuration Properties
Validation also protects your application settings. Add @Validated to a @ConfigurationProperties class, and the application refuses to start with invalid values instead of failing later in production:
package com.javabytechie.config;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@Validated
@ConfigurationProperties(prefix = "app.mail")
public record MailProperties(
@NotBlank
String host,
@Min(1)
@Max(65535)
int port,
@NotBlank
@Email
String from
) {
}
Register it with @ConfigurationPropertiesScan on your main class. With app.mail.port=0 in application.properties, startup stops with a clear message:
***************************
APPLICATION FAILED TO START
***************************
Description:
Binding to target com.javabytechie.config.MailProperties failed:
Property: app.mail.port
Value: "0"
Origin: class path resource [application.properties] - 2:15
Reason: must be greater than or equal to 1
9. Testing Your Validation Rules
Validation rules are business rules, so test them. A plain unit test needs no Spring context and runs in milliseconds:
package com.javabytechie.user;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Set;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class UserRequestValidationTest {
private static Validator validator;
@BeforeAll
static void setUp() {
validator = Validation.buildDefaultValidatorFactory().getValidator();
}
@Test
void acceptsValidRequest() {
UserRequest request = new UserRequest("John", "john@example.com", 30, "+919876543210");
assertThat(validator.validate(request)).isEmpty();
}
@Test
void rejectsShortNameAndUnderageUser() {
UserRequest request = new UserRequest("J", "john@example.com", 16, null);
Set<ConstraintViolation<UserRequest>> violations = validator.validate(request);
assertThat(violations)
.extracting(violation -> violation.getPropertyPath().toString())
.containsExactlyInAnyOrder("name", "age");
}
}
To check the full HTTP behavior, including the error format, use a web slice test with MockMvc:
@WebMvcTest(UserController.class)
@Import(ApiExceptionHandler.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private UserService userService;
@Test
void returns400WithFieldErrorsForInvalidEmail() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "John", "email": "not-an-email", "age": 30}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.title").value("Validation failed"))
.andExpect(jsonPath("$.errors.email").value("Email should be valid"));
}
}
@MockitoBean is available from Spring Boot 3.4. On older versions, use @MockBean.
10. Summary and Best Practices
Spring Boot validation lets you declare rules once and enforce them everywhere data enters your application. Keep these practices in mind:
- Validate at the edges. Put constraints on request DTOs and configuration classes, not deep inside your business logic.
- Handle errors in one place. A single
@RestControllerAdvicegives every endpoint the same error format. - Write messages for humans. Say what is wrong and what is allowed, like “Age must be at least 18”.
- Keep custom validators focused. One rule per constraint, and return
truefornull. - Do not rely on validation alone for data integrity. Keep database constraints such as
NOT NULLandUNIQUEas the final safety net. - Test your rules with fast unit tests, plus one web test for the error format.
11. Frequently Asked Questions (FAQ)
Q1. What is the difference between @NotNull, @NotEmpty and @NotBlank?
They reject progressively more values. This table shows which inputs each one accepts for a String field:
| Input | @NotNull | @NotEmpty | @NotBlank |
|---|---|---|---|
null | ❌ | ❌ | ❌ |
"" | ✅ | ❌ | ❌ |
" " | ✅ | ✅ | ❌ |
"John" | ✅ | ✅ | ✅ |
Use @NotBlank for text a user types, such as names. @NotEmpty also works on collections, maps and arrays. @NotNull fits numbers, dates and objects.
Q2. Why is my validation being ignored?
Check these common causes, in order:
- The starter is missing. Since Spring Boot 2.3,
spring-boot-starter-webno longer includes validation. Addspring-boot-starter-validation. - Wrong imports. Spring Boot 3 uses
jakarta.validation.*. Annotations fromjavax.validation.*compile but are silently ignored. @Validis missing on the@RequestBodyparameter, or on a nested object field.- Constraints on getters and fields are mixed up. Put them on fields, consistently, unless you have a reason not to.
Q3. What is the difference between @Valid and @Validated?
@Valid | @Validated | |
|---|---|---|
| Comes from | Jakarta Bean Validation (standard) | Spring |
| Validation groups | No | Yes, for example @Validated(OnCreate.class) |
| Cascades into nested objects | Yes, when placed on a field | No |
| On a class | Not used | Turns on method validation for Spring beans |
A simple rule: use @Valid on request bodies and nested fields, and use @Validated when you need groups or want to validate method parameters of a service class.
Q4. How do I validate a list sent directly as the request body?
Putting @Valid in front of List<UserRequest> does not validate the elements. Put it inside the generic type instead:
@PostMapping("/bulk")
public List<UserResponse> createAll(@RequestBody @Size(max = 100) List<@Valid UserRequest> requests) {
return userService.registerAll(requests);
}
- Spring Boot 3.2+ (Spring Framework 6.1+): this works out of the box. A failure throws
HandlerMethodValidationException(HTTP 400), with paths such asrequests[3].email. - Older versions: also add
@Validatedto the controller class. Failures then throwConstraintViolationException.
If you extend ResponseEntityExceptionHandler as in Section 5, override handleHandlerMethodValidationException too, so list errors use the same JSON format.
Q5. How do I validate nested objects and lists?
Validation does not cascade automatically. Add @Valid to the nested field, and put constraints inside the generic type to check each list element:
public class OrderRequest {
@Valid
@NotNull
private Address shippingAddress;
@NotEmpty
private List<@Valid OrderItem> items;
@Size(max = 5)
private List<@NotBlank String> couponCodes;
}
Error paths then point to the exact element, for example items[2].quantity.
Q6. How do I apply different rules for create and update requests?
Use validation groups. Define marker interfaces, assign constraints to them, and choose the group with @Validated:
public interface OnCreate extends Default {}
public interface OnUpdate extends Default {}
public class UserRequest {
@Null(groups = OnCreate.class, message = "Id must not be sent when creating")
@NotNull(groups = OnUpdate.class, message = "Id is required when updating")
private Long id;
@NotBlank
private String name;
}
@PostMapping
public User create(@Validated(OnCreate.class) @RequestBody UserRequest request) { ... }
@PutMapping
public User update(@Validated(OnUpdate.class) @RequestBody UserRequest request) { ... }
Making the groups extend Default is important. Without it, constraints that have no group, like @NotBlank on name, are skipped when you validate a specific group.
Q7. How do I validate one field against another, like password and confirm password?
Field annotations only see one value, so create a class-level constraint:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordsMatchValidator.class)
public @interface PasswordsMatch {
String message() default "Passwords do not match";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PasswordsMatchValidator
implements ConstraintValidator<PasswordsMatch, RegistrationRequest> {
@Override
public boolean isValid(RegistrationRequest r, ConstraintValidatorContext ctx) {
if (r.getPassword() == null || r.getPassword().equals(r.getConfirmPassword())) {
return true;
}
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(ctx.getDefaultConstraintMessageTemplate())
.addPropertyNode("confirmPassword")
.addConstraintViolation();
return false;
}
}
Annotate the class with @PasswordsMatch. The addPropertyNode call attaches the error to confirmPassword, so a form can show it next to the right input.
Q8. How do I move error messages out of the code and translate them?
Reference a message key in curly braces instead of hard-coding the text:
@Size(min = 2, max = 30, message = "{user.name.size}")
private String name;
Then define the key in src/main/resources/messages.properties. Constraint attributes such as {min} and {max} are filled in automatically:
user.name.size=Name must be between {min} and {max} characters
Add messages_fr.properties, messages_de.properties and so on for other languages. Spring Boot picks the file based on the request’s Accept-Language header.
Q9. How do I validate path variables and request parameters?
Put constraints directly on the parameters:
@GetMapping("/users/{id}")
public User getUser(@PathVariable @Min(1) Long id,
@RequestParam @Size(max = 50) String fields) { ... }
- Spring Boot 3.2+ (Spring Framework 6.1+): this works out of the box. A failure throws
HandlerMethodValidationException, which returns HTTP 400. - Older versions: also add
@Validatedto the controller class. Failures then throwConstraintViolationException, which returns HTTP 500 unless you handle it in a@RestControllerAdvice.
Q10. Can a custom validator use a Spring bean, for example to check that an email is unique?
Yes. Spring creates ConstraintValidator instances itself, so constructor injection works:
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final UserRepository userRepository;
public UniqueEmailValidator(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public boolean isValid(String email, ConstraintValidatorContext ctx) {
return email == null || !userRepository.existsByEmail(email);
}
}
Two cautions:
- Use such constraints on request DTOs, not JPA entities. Hibernate may validate entities again before saving, using a validator that Spring does not manage, so the repository would be
null. - The check does not prevent a race between two simultaneous requests. Keep a unique constraint in the database as the final guarantee.