Default Error Handling
What Spring Boot sends when a controller throws and nobody handles it: the /error endpoint, its JSON shape and the spring.web.error properties.
spring.web.error.include-message=never|always|on-param
spring.web.error.include-stacktrace=never
spring.web.error.whitelabel.enabled=false
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "reason");Before you write a single handler, Spring Boot already answers every failed request. When an exception leaves a controller and nothing in Spring MVC turns it into a response, the servlet container records the error and forwards the request to /error. Boot maps that path to BasicErrorController, which builds the body from an ErrorAttributes bean.
What the client sees
Browsers that send Accept: text/html get the Whitelabel page. Every other client gets JSON with four fields:
{"timestamp":"...","status":404,"error":"Not Found","path":"/api/orders/99"}
The status comes from the exception. A ResponseStatusException or an exception class annotated with @ResponseStatus carries its own status. Spring MVC's own exceptions map to sensible codes: a request for an unmapped path is a 404, a POST to a GET-only route is a 405, unreadable JSON is a 400. Anything else, such as a NullPointerException in your service, is a 500, and the container logs it with the full stack trace.
What it hides, on purpose
The exception message, the exception class, binding errors and the stack trace are all left out by default. That is the safe choice: messages often contain SQL, file paths, or another customer's data. The switches live under spring.web.error.*. Spring Boot 4 moved them there from server.error.*, and the old names are no longer supported, so a Boot 3 server.error.include-message=always silently stops working after an upgrade.
Each include-* property takes never, always or on-param. Avoid on-param in production: it lets any caller ask for the stack trace by adding a query parameter.
Where the default falls short
- The body is Boot's own format, not RFC 9457 Problem Details, and it has no room for fields a client could act on, such as which order was missing.
- Every failure in your domain looks the same: a missing order and a payment declined by the bank are both a bare status code.
- Tests with MockMvc never see this body. The forward to
/erroris done by the servlet container, which MockMvc does not run, so you get the status with an empty body.
Treat the default as the safety net for exceptions you did not expect. For errors you can predict, the following lessons show how to answer with @ExceptionHandler and Problem Details instead.
Example
import java.util.Map;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
record Order(long id, String status) {}
("/api/orders")
public class OrderController {
private final Map<Long, Order> orders = Map.of(1L, new Order(1L, "PAID"));
("/{id}")
Order find( long id) {
// No handler anywhere: the exception sets the status, Boot's /error writes the body.
return Optional.ofNullable(orders.get(id))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Order " + id + " not found"));
}
("/{id}/invoice")
String invoice( long id) {
// Anything unexpected becomes a 500 with the same JSON shape,
// and the stack trace goes to the log, not to the client.
throw new IllegalStateException("invoice service returned no data for order " + id);
}
}
When to use it
- Return a consistent JSON 404 for unknown URLs in a new API before any custom handlers exist.
- Make sure an unexpected NullPointerException in a service never shows its stack trace to callers.
- Upgrade a Boot 3 service and move its server.error.* settings to spring.web.error.*.
More examples
Locking down the error body
These are the defaults written out, apart from the Whitelabel switch. Writing them down protects you from a profile file or environment variable that turns <code>include-message</code> on for debugging and reaches production. Turning off the Whitelabel page makes a browser hitting the API see the same JSON as every other client, which suits a service with no HTML pages.
# Boot 4 names. The old server.error.* keys are no longer bound.
spring.web.error.include-message=never
spring.web.error.include-binding-errors=never
spring.web.error.include-stacktrace=never
spring.web.error.include-exception=false
spring.web.error.include-path=always
# A JSON-only API has no use for the HTML page.
spring.web.error.whitelabel.enabled=false
Adding a field to every default error
Boot's error controller backs off its own <code>DefaultErrorAttributes</code> when you declare an <code>ErrorAttributes</code> bean. Extending the default and calling <code>super</code> keeps the standard fields and the <code>spring.web.error.*</code> switches working. Use this for small additions such as a service name; for anything richer, handle the exception before it reaches <code>/error</code>.
import java.util.Map;
import org.springframework.boot.web.error.ErrorAttributeOptions;
import org.springframework.boot.webmvc.error.DefaultErrorAttributes;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.WebRequest;
@Component
public class ApiErrorAttributes extends DefaultErrorAttributes {
@Override
public Map<String, Object> getErrorAttributes(WebRequest request, ErrorAttributeOptions options) {
// Start from Boot's map so status, error and path stay consistent.
Map<String, Object> attributes = super.getErrorAttributes(request, options);
attributes.put("service", "orders-api");
return attributes;
}
}
The two shapes clients receive
The 404 and the 500 share one shape, so a client can only branch on <code>status</code>. Note what is missing from the 500: the exception message about the invoice service stays in the server log. That is exactly the behaviour you want for unexpected failures, and exactly why predictable failures deserve their own handler.
GET /api/orders/99 HTTP/1.1
Accept: application/json
HTTP/1.1 404
Content-Type: application/json
{"timestamp":"2026-10-13T09:14:02.118+00:00","status":404,"error":"Not Found","path":"/api/orders/99"}
GET /api/orders/1/invoice HTTP/1.1
Accept: application/json
HTTP/1.1 500
Content-Type: application/json
{"timestamp":"2026-10-13T09:14:05.441+00:00","status":500,"error":"Internal Server Error","path":"/api/orders/1/invoice"}
Discussion