“I wrote @Valid @RequestBody List<UserDto> but the @NotBlank on the elements doesn’t work.” “I put @Min(1) on a @RequestParam but page=0 still gets through.” “I added @Validated and now I get a 500.” Once you’ve learned the basics of @Valid, these are the stumbling blocks almost everyone hits next.

This article is a reverse-lookup guide focused solely on that “next” step. We’ll sort out List element validation, query parameter and path variable validation, and the problem of three different exceptions being thrown depending on how you write things, all with fix code included. For how to use @Valid itself, see the article on using @Valid, and for a list of constraint annotations, see the validation annotations cheat sheet.

Assumed Versions and Verification Environment

This article assumes Spring Boot 3.2 or later, Jakarta Bean Validation 3.0, and Hibernate Validator 8.x on Spring MVC (the Servlet stack). WebFlux is not covered.

For the mapping between Boot and Spring Framework: Boot 3.2 / 3.3 use Spring Framework 6.1, and Boot 3.4 / 3.5 use 6.2. From here on, version references are aligned to Boot. Boot 3.2 introduced built-in method validation for controllers, and this is where behavior diverges.

The behavioral explanations in this article are based on the “Spring MVC Validation” section of the Spring Framework reference and the Javadoc for HandlerMethod#shouldValidateArguments. They are not the result of spinning up each Boot version locally and measuring, so if in doubt, use the code as written in this article and consult the reference as the primary source.

First, let’s check the dependency. Since Boot 2.3, spring-boot-starter-web does not include validation. Without this, none of the fixes in this article will do anything.

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'

Symptom 1: @Valid @RequestBody List<UserDto> Doesn’t Validate the Elements

This is a common shape for bulk registration APIs.

public record UserDto(
        @NotBlank String name,
        @Email String email) {}

@RestController
@RequestMapping("/users")
public class UserController {

    // 単体ならこれで 400 になる
    @PostMapping
    public void create(@Valid @RequestBody UserDto user) { /* ... */ }

    // List にすると要素の @NotBlank が検証されない
    @PostMapping("/bulk")
    public void bulk(@Valid @RequestBody List<UserDto> users) { /* ... */ }
}

Even if you send an element with an empty name mixed in, it passes with a 200. The same UserDto that would produce a 400 in the single-object create method is not validated the moment it becomes a List. This is the same in Boot 3.1 and earlier as well as 3.2 and later.

curl -i -X POST localhost:8080/users/bulk \
  -H 'Content-Type: application/json' \
  -d '[{"name":"alice","email":"[email protected]"},{"name":"","email":"[email protected]"}]'
# HTTP/1.1 200

Why It Doesn’t Work

Spring MVC has two paths for validation. The first is individual validation by the argument resolver, which validates a @Valid-annotated @RequestBody via DataBinder and throws MethodArgumentNotValidException on failure. However, the reference explicitly states that the target is “a command object, and not a container such as Map or Collection.” If the argument is a List, it does not go down this path.

The second is the built-in method validation introduced in Boot 3.2, which throws HandlerMethodValidationException on failure. The trigger condition for this path is that a constraint annotation such as @Min or @NotEmpty is placed directly on the argument. As the Javadoc for HandlerMethod#shouldValidateArguments states, @Valid is not a constraint but a cascade instruction to nested constraints, so @Valid alone does not trigger method validation.

In other words, @Valid @RequestBody List<UserDto> falls into a double gap: it is excluded from individual validation because it is a container, and method validation is not triggered because there is no constraint. The reason writing @Valid List<Item> items on a DTO field does descend into the elements is that the DTO itself goes through individual validation as a command object, and the validation cascades from its field.

Fix A: Put a Constraint on the Argument and Use List<@Valid UserDto>

Use this approach if you don’t want to change the JSON shape. It’s easier to reason about if you separate the roles.

  • Constraints placed directly on the argument, such as @NotEmpty or @NotNull, are the trigger condition for built-in method validation. @Valid is not a trigger condition
  • The type argument <@Valid UserDto> is an explicit instruction to cascade into the elements (container element constraints from Bean Validation 2.0)
// 空リストも禁止する場合
@PostMapping("/bulk")
public void bulk(@NotEmpty @RequestBody List<@Valid UserDto> users) { /* ... */ }

// 空リストは許容し、null だけ拒否する場合
@PostMapping("/bulk")
public void bulk(@NotNull @RequestBody List<@Valid UserDto> users) { /* ... */ }

The reference also says that “@NotNull is a constraint, so adding it to a @Valid argument results in method validation.” @NotEmpty is a constraint on the List itself, meaning “empty lists are forbidden,” while the @Valid on the type argument validates the contents of the elements. They are different things, so if you need both, write both.

As a side note, if you put @Valid at the argument level, Bean Validation’s traditional cascading will validate down to the elements, so strictly speaking the @Valid on the type argument can be omitted. It is included here to make the intent of “I want to validate the elements” explicit.

On Boot 3.2 and later, this alone is enough for @NotBlank violations on elements to become a 400 via HandlerMethodValidationException. Boot 3.1 and earlier has no built-in validation, so add @Validated to the Controller class to enable AOP method validation. In this case, a ConstraintViolationException is thrown.

@Validated  // Boot 3.1 以前ではこれが必須
@RestController
@RequestMapping("/users")
public class UserController { /* ... */ }

Fix B: Receive a Wrapper DTO

If you can change the JSON shape, this approach is simpler and more predictable. It is written against the same /bulk endpoint as Fix A, but in practice you should adopt only one of the two.

public record UsersRequest(@Valid @NotEmpty List<UserDto> users) {}

@PostMapping("/bulk")
public void bulk(@Valid @RequestBody UsersRequest request) { /* ... */ }

This is ordinary @RequestBody validation, so on failure it produces a MethodArgumentNotValidException (400) on any Boot 3.x version. If you already handle this exception in a @RestControllerAdvice, you can reuse it as-is. The trade-off is that the JSON changes to the shape {"users": [...]}, but this also leaves room to add metadata later.

Comparing Fix A and Fix B

Fix A @NotEmpty List<@Valid Dto>Fix B Wrapper DTO
JSON shapeUnchanged (stays an array)Changes to {"users": [...]}
Exception thrownHandlerMethodValidationException (3.2+, without @Validated) / ConstraintViolationException (with class-level @Validated)MethodArgumentNotValidException
Error path (3.2+)Built from the argument name and index in ParameterValidationResultBindingResult gives users[1].name directly
Error path (with @Validated)Strip the leading node from PropertyPath bulk.users[1].nameSame as above
Reusing existing handlersAdditional implementation requiredWorks as-is
Best suited forMaintaining compatibility of existing APIsNew APIs

For new APIs, I recommend the wrapper DTO. Use Fix A only when you need to preserve compatibility of an existing API, and you’ll rarely run into trouble with that rule of thumb.

Symptom 2: @Min or @Pattern on @RequestParam / @PathVariable Doesn’t Work

@GetMapping
public List<UserDto> list(@RequestParam @Min(1) int page) { /* ... */ }

@GetMapping("/{id}")
public UserDto get(@PathVariable @Pattern(regexp = "[0-9]+") String id) { /* ... */ }

@RequestParam and @PathVariable are not subject to the argument resolver’s individual validation the way @RequestBody is. As a result, on Boot 3.1 and earlier, page=0 passes with a 200. The fix is to add @Validated to the class. The key point is to put it on the class, not the method.

On Boot 3.2 and later, having a constraint annotation on the argument satisfies the trigger condition, so built-in method validation works without @Validated and returns a 400 via HandlerMethodValidationException. However, this does not go through your existing MethodArgumentNotValidException handler, so Boot’s default error JSON is returned.

One thing to watch out for is leaving @Validated in place on Boot 3.2 and later. The reference states that “if the class has @Validated, method validation happens via the AOP proxy, and you need to remove it to use built-in validation.” In other words, the exception remains ConstraintViolationException. For how to receive parameters in general, see the article on request parameter binding.

Mapping Table of the Three Exceptions

The exception thrown changes depending on how you write things, so if you don’t get this straight, your handlers won’t fire.

ExceptionTrigger conditionVersionDefault HTTPHow to extract error info
MethodArgumentNotValidExceptionValidation failure of a @Valid @RequestBody DTO (non-container)All Boot 3.x400getBindingResult().getFieldErrors()
ConstraintViolationExceptionFailure of AOP method validation via class-level @ValidatedAll Boot 3.x500getPropertyPath() from getConstraintViolations()
HandlerMethodValidationExceptionFailure of built-in method validation with constraints on arguments, without @ValidatedBoot 3.2 and later400getParameterValidationResults() (Boot 3.4+) / getAllValidationResults() (Boot 3.2 / 3.3)

The biggest pitfall is ConstraintViolationException. Spring MVC does not know about this exception, so without a handler it becomes a 500 as-is.

Another point: when you upgrade to Boot 3.2 and remove @Validated, the exception changes from ConstraintViolationException to HandlerMethodValidationException. Your existing handler will stop firing, so be sure to check this when upgrading. Also, according to the reference, method validation takes precedence over individual validation. If a @RequestParam with @Min and a @Valid @RequestBody coexist in the same method, failures on the @RequestBody side also become HandlerMethodValidationException.

Unifying the Three Exceptions into a Single Error Response with @RestControllerAdvice

Let’s align all three to the same 400 and the same JSON. If you extend ResponseEntityExceptionHandler, two of them can be handled with overrides, and you only need to add an @ExceptionHandler for ConstraintViolationException. The record for error items is named FieldViolation. Naming it FieldError would collide with Spring’s org.springframework.validation.FieldError and cause ambiguous references with IDE auto-import.

public record ErrorResponse(String message, List<FieldViolation> errors) {}
public record FieldViolation(String field, String message) {}

@RestControllerAdvice
public class ValidationExceptionHandler extends ResponseEntityExceptionHandler {

    // @Valid @RequestBody の失敗(ラッパー DTO 方式)
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {
        List<FieldViolation> errors = ex.getBindingResult().getFieldErrors().stream()
                .map(e -> new FieldViolation(e.getField(), e.getDefaultMessage()))
                .toList();
        return ResponseEntity.badRequest().body(new ErrorResponse("Validation failed", errors));
    }

    // 組み込みメソッド検証の失敗(Boot 3.2+、@Validated なし)
    @Override
    protected ResponseEntity<Object> handleHandlerMethodValidationException(
            HandlerMethodValidationException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {
        List<FieldViolation> errors = new ArrayList<>();
        // getParameterValidationResults() は Boot 3.4(Spring Framework 6.2)以降。
        // Boot 3.2 / 3.3 では getAllValidationResults() に読み替える。
        // getContainerIndex() にインデックスが入るのは Boot 3.2.2(Spring Framework 6.1.3)以降。
        for (ParameterValidationResult result : ex.getParameterValidationResults()) {
            String param = result.getMethodParameter().getParameterName();
            Integer index = result.getContainerIndex();
            String prefix = index != null ? param + "[" + index + "]" : param;
            if (result instanceof ParameterErrors paramErrors) {
                // List<@Valid Dto> の要素エラー。users[1].name の形にする
                paramErrors.getFieldErrors().forEach(e ->
                        errors.add(new FieldViolation(prefix + "." + e.getField(), e.getDefaultMessage())));
            } else {
                // @Min 付き @RequestParam などの単純な引数エラー
                result.getResolvableErrors().forEach(e ->
                        errors.add(new FieldViolation(prefix, e.getDefaultMessage())));
            }
        }
        return ResponseEntity.badRequest().body(new ErrorResponse("Validation failed", errors));
    }

    // AOP メソッド検証の失敗(@Validated 時)。これがないと 500 になる
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException ex) {
        List<FieldViolation> errors = ex.getConstraintViolations().stream()
                .map(v -> new FieldViolation(stripMethodName(v.getPropertyPath()), v.getMessage()))
                .toList();
        return ResponseEntity.badRequest().body(new ErrorResponse("Validation failed", errors));
    }

    // bulk.users[1].name -> users[1].name
    private static String stripMethodName(Path path) {
        String s = path.toString();
        return s.substring(s.indexOf('.') + 1);
    }
}

getParameterValidationResults() is the name used from Boot 3.4 (Spring Framework 6.2) onward; on Boot 3.2 / 3.3 it is getAllValidationResults(). The old name remains as deprecated in 3.4 and later, but it is slated for removal in a future version, so migrate to the new name.

Retrieving argument names requires compiling with -parameters. This affects not only the HandlerMethodValidationException side but also the PropertyPath of ConstraintViolationException. Without -parameters, bulk.users[1].name becomes bulk.arg0[1].name, and even after stripping the leading node you’re left with arg0[1].name. Boot’s Gradle / Maven plugins enable this by default, so you only need to check if you have a custom build configuration.

With this handler in place, the client is told “which element failed” regardless of the approach. With the wrapper DTO approach, the BindingResult field is already users[1].name from the start. With Fix A on Boot 3.2 and later, it is built from the index in getContainerIndex(). With Fix A plus @Validated, the leading node of PropertyPath is stripped to produce the same shape.

{
  "message": "Validation failed",
  "errors": [
    { "field": "users[1].name", "message": "must not be blank" }
  ]
}

Indices are zero-based, so users[1] is the second element. For general usage of @ControllerAdvice, see the article on exception handling, and if you want responses in RFC 9457 format, see the article on ProblemDetail.

Side Effects of Adding @Validated to a Controller

Adding @Validated turns the class into a CGLIB proxy. As a result, final classes and final methods cannot be proxied, and they will either go unvalidated or cause an error at startup. Self-invocations like this.method() within the same Controller also bypass the proxy and are not validated.

As mentioned earlier, on Boot 3.2 and later, having @Validated prevents built-in validation from running, and the exception remains ConstraintViolationException. On Boot 3.2 and later, it is cleaner to remove @Validated from Controllers and consolidate exceptions into HandlerMethodValidationException. Method validation in the Service layer is still where @Validated belongs, and that is the subject of the article on @Validated.

Testing Element Validation with MockMvc

Let’s lock in the fixes with tests. @WebMvcTest also loads @RestControllerAdvice. The first test targets the Controller from Fix B (wrapper DTO). If you adopted Fix A, replace the body with the bare array ([{...}, {...}]). The expected field is still users[1].name.

@WebMvcTest(UserController.class)
class UserControllerValidationTest {

    @Autowired MockMvc mockMvc;

    @Test
    void 要素のnameが空なら400でインデックス付きのfieldが返る() throws Exception {
        mockMvc.perform(post("/users/bulk")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"users": [
                                  {"name": "alice", "email": "[email protected]"},
                                  {"name": "", "email": "[email protected]"}
                                ]}
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[0].field").value("users[1].name"));
    }

    @Test
    void pageが0なら400になる() throws Exception {
        // ConstraintViolationException が 500 になっていないかの検知にもなる
        mockMvc.perform(get("/users").param("page", "0"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[0].field").value("page"));
    }
}

General testing techniques are summarized in the JUnit and Mockito testing guide.

Checklist When It Still Doesn’t Work

If it still doesn’t work after all of this, work through the following from the top.

  • The spring-boot-starter-validation dependency is missing (it is not included in the web starter)
  • jakarta.validation and javax.validation are mixed (only jakarta works on Boot 3.x)
  • @Valid and @Validated are swapped (@Valid goes on @RequestBody, @Validated goes on the class. List<@Validated Dto> will not compile)
  • The List argument has only @Valid and no constraint such as @NotEmpty or @NotNull, so method validation is not triggered
  • @Valid was forgotten on a nested DTO field, so validation does not cascade
  • There is no handler for ConstraintViolationException, resulting in a 500
  • The @RestControllerAdvice is not picking it up due to basePackages or ordering issues

If custom constraint annotations are involved, also check the article on custom validation.

Summary

For List element validation, either trigger it by placing a constraint on the argument, as in @NotEmpty @RequestBody List<@Valid Dto>, or receive a wrapper DTO. For new APIs, the wrapper DTO is easier to work with. Constraints on @RequestParam and @PathVariable work just by adding them on Boot 3.2 and later, while on 3.1 and earlier you add @Validated to the class.

There are three kinds of exceptions, and only ConstraintViolationException defaults to a 500, so always provide a handler for it. If you align all three to the same shape, the behavior visible to clients stays the same even when the coding style or version changes.