The first time you deploy a Spring Boot API to production and a client sends a malformed request, you find out quickly whether you have thought about error handling or not. Without explicit configuration, Spring Boot returns the Whitelabel Error Page — an HTML page that means nothing to a JavaScript client, or worse, it returns a 500 with a full stack trace that leaks your internal class names and library versions to anyone paying attention.

This guide covers how to handle errors properly in Spring Boot 3.x: method-level exception handling with @ExceptionHandler, global handlers with @ControllerAdvice, the RFC 7807 ProblemDetail standard introduced in Spring 6, and how to deal with validation errors in a way that clients can actually use.

The Default Behavior and Why It Fails

Spring Boot ships with BasicErrorController, which handles requests that reach the /error endpoint after an exception is thrown. It produces different output based on the Accept header — HTML for browsers, JSON for API clients. The JSON response looks like this:

{
  "timestamp": "2026-03-04T14:22:11.843+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/api/orders/99"
}

That response is nearly useless. The status code is the only signal the client has. There is no error code, no human-readable message, no way for the client to distinguish “order not found” from “database is down”. Every non-2xx response looks the same.

The other failure mode is the exception leaking through entirely: no @ExceptionHandler configured, the exception propagates out of the controller, and Spring returns a 500 with the exception message in the response body. In development that is useful. In production it is a security problem.

@ExceptionHandler: Handling Exceptions Per Controller

The simplest form of Spring Boot error handling is @ExceptionHandler inside a controller class. When an exception of the specified type is thrown anywhere in that controller, Spring routes execution to the handler method instead of propagating the exception further.

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    public Order getOrder(@PathVariable Long id) {
        return orderService.findById(id)
            .orElseThrow(() -> new OrderNotFoundException(id));
    }

    @ExceptionHandler(OrderNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public Map<String, String> handleOrderNotFound(OrderNotFoundException ex) {
        return Map.of("message", ex.getMessage());
    }
}

This works, but it only handles exceptions thrown within OrderController. If ProductController also needs to handle OrderNotFoundException for some reason, you would need to duplicate the handler. More practically, you will likely have 15 controllers and 10 exception types — duplicating handler methods across every controller is not maintainable.

Method-level @ExceptionHandler is useful when a specific controller has genuinely different behavior for an exception type than the rest of the application. In most cases, you want global handling.

@ControllerAdvice: Global Exception Handling

@ControllerAdvice is a specialization of @Component that lets you define exception handlers, model attribute methods, and data binder initializers that apply across all controllers. For error handling purposes, you combine it with @ExceptionHandler:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleResourceNotFound(ResourceNotFoundException ex) {
        return new ErrorResponse(
            "NOT_FOUND",
            ex.getMessage()
        );
    }

    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleIllegalArgument(IllegalArgumentException ex) {
        return new ErrorResponse(
            "INVALID_REQUEST",
            ex.getMessage()
        );
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResponse handleGenericException(Exception ex) {
        // Do not expose ex.getMessage() here — it may contain internal details
        return new ErrorResponse(
            "INTERNAL_ERROR",
            "An unexpected error occurred"
        );
    }
}

Note @RestControllerAdvice rather than @ControllerAdvice. The difference is the same as between @RestController and @Controller: @RestControllerAdvice is @ControllerAdvice + @ResponseBody, so the return value from every handler method is written directly to the response body as JSON rather than being interpreted as a view name.

The catch-all Exception.class handler at the bottom is important. Without it, any exception type you have not explicitly handled will fall through to Spring’s default behavior. Log the exception there and return a safe, generic message. Do not expose ex.getMessage() from generic exceptions — you do not know what information might be in that string.

The ErrorResponse record is just a simple value object:

public record ErrorResponse(String code, String message) {}

Keeping your error response structure consistent across the entire API is one of the most client-friendly things you can do. Every non-2xx response returns the same fields.

ProblemDetail: The RFC 7807 Standard

Spring 6 (and therefore Spring Boot 3.x) introduced native support for RFC 7807, the HTTP Problem Details standard. This specification defines a JSON format for error responses that has enough structure for machines to parse and enough flexibility for humans to understand:

{
  "type": "https://katyella.com/errors/order-not-found",
  "title": "Order Not Found",
  "status": 404,
  "detail": "Order with ID 99 was not found",
  "instance": "/api/orders/99"
}

The fields:

  • type: A URI that identifies the problem type. Should point to documentation when possible.
  • title: A short, human-readable summary of the problem type. Fixed per problem type — not instance-specific.
  • status: The HTTP status code as an integer.
  • detail: A human-readable explanation specific to this occurrence of the problem.
  • instance: A URI identifying the specific occurrence — typically the request path.

Spring Boot 3.x provides the ProblemDetail class that maps directly to this structure. You return it from exception handlers and Spring serializes it correctly, including setting the Content-Type response header to application/problem+json.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ProblemDetail> handleResourceNotFound(
            ResourceNotFoundException ex,
            HttpServletRequest request) {

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND,
            ex.getMessage()
        );
        problem.setTitle("Resource Not Found");
        problem.setType(URI.create("https://katyella.com/errors/not-found"));
        problem.setInstance(URI.create(request.getRequestURI()));

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
    }
}

You can also extend ProblemDetail to add custom fields. The spec allows this explicitly — any extra properties are treated as extensions:

public class ValidationProblemDetail extends ProblemDetail {

    private Map<String, List<String>> fieldErrors;

    public static ValidationProblemDetail of(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {

        ValidationProblemDetail problem = new ValidationProblemDetail();
        problem.setStatus(HttpStatus.UNPROCESSABLE_ENTITY.value());
        problem.setTitle("Validation Failed");
        problem.setDetail("One or more fields failed validation");
        problem.setInstance(URI.create(request.getRequestURI()));

        Map<String, List<String>> errors = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
            errors.computeIfAbsent(error.getField(), k -> new ArrayList<>())
                  .add(error.getDefaultMessage())
        );
        problem.setFieldErrors(errors);

        return problem;
    }

    public Map<String, List<String>> getFieldErrors() {
        return fieldErrors;
    }

    public void setFieldErrors(Map<String, List<String>> fieldErrors) {
        this.fieldErrors = fieldErrors;
    }
}

This produces a response like:

{
  "type": "about:blank",
  "title": "Validation Failed",
  "status": 422,
  "detail": "One or more fields failed validation",
  "instance": "/api/orders",
  "fieldErrors": {
    "customerEmail": ["must be a well-formed email address"],
    "quantity": ["must be greater than 0"]
  }
}

The fieldErrors extension gives clients exactly what they need to show per-field validation messages in a form. This is the kind of error response that frontend developers will actually thank you for.

Handling Validation Errors

Spring’s validation integration (@Valid, @Validated) throws MethodArgumentNotValidException when bean validation fails on a request body, and ConstraintViolationException when validation fails on path variables or request parameters. These need different handling.

First, the setup. Your DTO with validation annotations:

public record CreateOrderRequest(
    @NotBlank(message = "Customer email is required")
    @Email(message = "must be a well-formed email address")
    String customerEmail,

    @NotNull(message = "Product ID is required")
    Long productId,

    @Min(value = 1, message = "must be greater than 0")
    int quantity
) {}

The controller:

@PostMapping
public ResponseEntity<Order> createOrder(@Valid @RequestBody CreateOrderRequest request) {
    Order order = orderService.create(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(order);
}

The handler in your @RestControllerAdvice:

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ProblemDetail> handleValidationErrors(
        MethodArgumentNotValidException ex,
        HttpServletRequest request) {

    Map<String, List<String>> fieldErrors = new LinkedHashMap<>();
    ex.getBindingResult().getFieldErrors().forEach(error ->
        fieldErrors.computeIfAbsent(error.getField(), k -> new ArrayList<>())
                   .add(error.getDefaultMessage())
    );

    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
    problem.setTitle("Validation Failed");
    problem.setDetail("One or more fields failed validation");
    problem.setInstance(URI.create(request.getRequestURI()));
    problem.setProperty("fieldErrors", fieldErrors);

    return ResponseEntity.unprocessableEntity().body(problem);
}

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ProblemDetail> handleConstraintViolations(
        ConstraintViolationException ex,
        HttpServletRequest request) {

    Map<String, String> errors = new LinkedHashMap<>();
    ex.getConstraintViolations().forEach(violation -> {
        String field = violation.getPropertyPath().toString();
        errors.put(field, violation.getMessage());
    });

    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    problem.setTitle("Constraint Violation");
    problem.setDetail("Request parameters failed validation");
    problem.setInstance(URI.create(request.getRequestURI()));
    problem.setProperty("violations", errors);

    return ResponseEntity.badRequest().body(problem);
}

Note the status choice: 422 Unprocessable Entity for body validation, 400 Bad Request for parameter validation. Both are defensible choices, and reasonable people disagree on which is more appropriate for body validation. Pick one and apply it consistently. The RFC 7807 response makes it unambiguous to clients regardless of which status you choose.

Enabling ProblemDetail for Spring MVC Errors

Spring Boot 3.x does not enable RFC 7807 responses for built-in Spring MVC exceptions by default. You need to opt in via a property:

spring:
  mvc:
    problemdetails:
      enabled: true

With this enabled, exceptions like HttpMessageNotReadableException (malformed JSON body), MethodNotAllowedException, and NoHandlerFoundException will automatically return ProblemDetail responses rather than the default Spring error format. This gives you consistent error structure without needing to write handlers for every possible Spring infrastructure exception.

Custom Error Pages

If your application serves HTML — you are building a server-rendered app alongside an API, or you have a basic web frontend — the default Whitelabel Error Page is not acceptable in production. It is visually jarring, reveals that you are using Spring Boot, and provides no useful information to users.

Spring Boot looks for error page templates in specific locations. With Thymeleaf:

  • src/main/resources/templates/error/404.html — for 404 errors specifically
  • src/main/resources/templates/error/5xx.html — for all 5xx errors
  • src/main/resources/templates/error/error.html — catch-all

A minimal 404.html:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Page Not Found</title>
</head>
<body>
    <h1>Page Not Found</h1>
    <p>The page you requested does not exist.</p>
    <p><a href="/">Go back home</a></p>
</body>
</html>

Spring Boot’s BasicErrorController exposes several model attributes you can reference in these templates: status, error, message, timestamp, and path. Use them selectively — be careful not to expose message in production without sanitizing it.

If you want to disable the Whitelabel page entirely without replacing it:

server:
  error:
    whitelabel:
      enabled: false

This will cause Spring Boot to throw a NoResourceFoundException (404) when /error is hit without a matching template. Usually you want to replace it, not just disable it.

Logging in Exception Handlers

One detail that gets overlooked: logging inside exception handlers. The temptation is to log every exception at ERROR level, but that creates noise. Not every exception is an error — a ResourceNotFoundException that results in a 404 is expected behavior, not a system failure.

A reasonable approach:

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ProblemDetail handleResourceNotFound(ResourceNotFoundException ex) {
        // INFO level — this is expected, not alarming
        log.info("Resource not found: {}", ex.getMessage());
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ProblemDetail handleGenericException(Exception ex, HttpServletRequest request) {
        // ERROR level with stack trace — this needs investigation
        log.error("Unhandled exception for request {}", request.getRequestURI(), ex);
        return ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR,
            "An unexpected error occurred"
        );
    }
}

Keep ResourceNotFoundException and similar domain exceptions at INFO. Reserve ERROR for the catch-all handler and for exceptions that indicate genuine system problems. Your alerting should trigger on ERROR log events — if you log every 404 as ERROR, your alerts become meaningless.

Putting It Together: A Production-Ready Exception Handler

A complete @RestControllerAdvice for a REST API using Spring Boot 3.x:

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ProblemDetail> handleResourceNotFound(
            ResourceNotFoundException ex, HttpServletRequest request) {
        log.info("Not found: {}", ex.getMessage());
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Resource Not Found");
        problem.setInstance(URI.create(request.getRequestURI()));
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ProblemDetail> handleValidation(
            MethodArgumentNotValidException ex, HttpServletRequest request) {
        Map<String, List<String>> fieldErrors = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
            fieldErrors.computeIfAbsent(error.getField(), k -> new ArrayList<>())
                       .add(error.getDefaultMessage())
        );
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
        problem.setTitle("Validation Failed");
        problem.setDetail("One or more fields failed validation");
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("fieldErrors", fieldErrors);
        return ResponseEntity.unprocessableEntity().body(problem);
    }

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ProblemDetail> handleAccessDenied(
            AccessDeniedException ex, HttpServletRequest request) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.FORBIDDEN);
        problem.setTitle("Access Denied");
        problem.setDetail("You do not have permission to access this resource");
        problem.setInstance(URI.create(request.getRequestURI()));
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(problem);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ProblemDetail> handleGeneric(
            Exception ex, HttpServletRequest request) {
        log.error("Unhandled exception for {}", request.getRequestURI(), ex);
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
        problem.setTitle("Internal Server Error");
        problem.setDetail("An unexpected error occurred");
        problem.setInstance(URI.create(request.getRequestURI()));
        return ResponseEntity.internalServerError().body(problem);
    }
}

Combined with spring.mvc.problemdetails.enabled: true, this gives you consistent RFC 7807 responses for your own exceptions, Spring MVC infrastructure exceptions, and an appropriate catch-all for anything unexpected.

The two things that will improve your API the most are structure and consistency. Clients should never have to guess what format your error responses are in. Define the structure once, apply it everywhere, and document the error codes in your API docs. The type URI field in RFC 7807 is designed for this — it is a link to human-readable documentation about that specific error type. Using it properly means your error responses are self-documenting.