HomeSpring BootSpring Boot HTTP Status Codes: Which to Return and How
Spring Boot

Spring Boot HTTP Status Codes: Which to Return and How

Spring Boot HTTP status codes explained: which code to return for GET, POST, PUT, DELETE, with ResponseEntity, @ResponseStatus, ProblemDetail and examples.

HTTP Status Codes in Spring Boot in 30 Seconds

An HTTP status code is a three-digit number in the response that tells the client what happened. In Spring Boot you set it with ResponseEntity (full control over status, headers and body), with @ResponseStatus (a fixed status on a method or exception class), or with ResponseStatusException (throw it from any layer). Unhandled exceptions are mapped to codes by Spring MVC and by your @RestControllerAdvice. The codes you will use most: - 200 OK: successful GET, or PUT/PATCH that returns the resource - 201 Created: POST created a resource (add a Location header) - 202 Accepted: request accepted, processing continues asynchronously - 204 No Content: success with no body (typical for DELETE) - 400 Bad Request: malformed or invalid input - 401 Unauthorized: not authenticated - 403 Forbidden: authenticated, but not allowed - 404 Not Found: resource does not exist - 409 Conflict: duplicate or state conflict - 422 Unprocessable Content: well-formed but semantically invalid - 429 Too Many Requests: rate limited (add Retry-After) - 500 Internal Server Error: unexpected failure - 502 / 503 / 504: upstream failed / unavailable / timed out Rule of thumb: 2xx means it worked, 4xx means the client must change the request, 5xx means the server failed. Never return 200 with an error message in the body, and never return 5xx for something the client did wrong.

Status Code Classes

HTTP status codes are three-digit integers grouped into five classes by their first digit. Every class has a defined meaning, and clients and infrastructure use the class to decide how to handle a response even when they do not recognise the specific code. 1xx codes are rarely written by hand in a Spring application: 100 Continue, 101 Switching Protocols (used when a connection is upgraded to WebSocket) and 103 Early Hints are handled by the server and the framework. Retry behaviour is a guideline, not a law. In general, retry only idempotent requests (GET, PUT, DELETE), or POST requests that carry an idempotency key. A 4xx means the same request will fail again, with two common exceptions: 408 Request Timeout and 429 Too Many Requests, which are worth retrying later. A 5xx may be transient, so retrying with exponential backoff is reasonable, except for 501 Not Implemented. Caching behaviour: a response is cacheable by default (without explicit Cache-Control) for 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414 and 501, and then only for GET/HEAD requests. 201, 202, 302 and 307 are not cacheable by default. 301 and 308 are cached very aggressively by browsers, so be sure before you use them. Responses to POST are normally not cached. Setting explicit Cache-Control headers is always safer than relying on defaults.
Java
// 1xx — Informational: request received, processing continues
// 2xx — Success: request was received, understood, and accepted
// 3xx — Redirection: further action needed to complete the request
// 4xx — Client Error: request contains bad syntax or cannot be fulfilled
// 5xx — Server Error: server failed to fulfil a valid request

// ── Retry guidance ────────────────────────────────────────────────────
// 2xx — success, no retry needed
// 3xx — follow the redirect (Location header)
// 4xx — do NOT retry unchanged (client must fix the request first)
//       exceptions: 408 Request Timeout, 429 Too Many Requests (after Retry-After)
// 5xx — may retry with backoff, if the request is idempotent
// 503 — retry after the Retry-After interval, if present

// ── Cacheable by default (heuristic caching, GET/HEAD) ────────────────
// 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501
// Not cacheable by default: 201, 202, 302, 307, most other 4xx/5xx

2xx — Success Codes

The 2xx class indicates the request was successfully received, understood, and processed. Choosing the correct 2xx code communicates the precise outcome: whether a resource was created, whether there is a body, or whether processing is still ongoing. A common mistake is returning null from a controller to signal "not found". Spring then answers 200 with an empty body. Map an Optional to ResponseEntity explicitly, as shown below. For 206 Partial Content, you rarely need to build the response by hand: when a controller returns a Resource and the request has a Range header, Spring MVC serves the requested range and sets 206 and Content-Range for you. Make sure to advertise Accept-Ranges: bytes.
Java
// ── 200 OK ────────────────────────────────────────────────────────────
// The standard success response. Use for GET, and for PUT/PATCH when
// the response includes a body.
@GetMapping("/{id}")
public ResponseEntity<UserResponse> findById(@PathVariable Long id) {
    return userService.findOptionalById(id)
        .map(ResponseEntity::ok)
        .orElseGet(() -> ResponseEntity.notFound().build());   // never return null for "not found"
}

// ── 201 Created ───────────────────────────────────────────────────────
// Resource was created. Include a Location header pointing to the new
// resource. A body is optional but recommended.
@PostMapping
public ResponseEntity<UserResponse> create(
        @RequestBody @Valid CreateUserRequest request,
        UriComponentsBuilder uriBuilder) {
    UserResponse created = userService.create(request);
    URI location = uriBuilder.path("/users/{id}")
        .buildAndExpand(created.id()).toUri();
    return ResponseEntity.created(location).body(created);
}

// ── 202 Accepted ──────────────────────────────────────────────────────
// Request accepted for processing but not yet complete (async).
// Give the client a way to check progress: a status URL in Location or the body.
@PostMapping("/{id}/export")
public ResponseEntity<ExportStatusResponse> startExport(@PathVariable Long id) {
    String jobId = exportService.startAsync(id);
    URI statusUri = URI.create("/jobs/" + jobId);
    return ResponseEntity.accepted()
        .location(statusUri)
        .body(new ExportStatusResponse(jobId, "PENDING"));
}

// ── 204 No Content ────────────────────────────────────────────────────
// Success with no response body. Standard for DELETE and for PUT/PATCH
// when you choose not to return the updated resource.
// A 204 response must not contain a body.
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
    userService.delete(id);
    return ResponseEntity.noContent().build();
}

// ── 206 Partial Content ───────────────────────────────────────────────
// Returned for Range requests (video streaming, resumable downloads).
// Spring MVC handles the Range header when the body is a Resource and
// turns the response into 206 with Content-Range automatically.
@GetMapping("/videos/{id}")
public ResponseEntity<Resource> streamVideo(@PathVariable Long id) {
    Resource video = videoService.load(id);
    return ResponseEntity.ok()
        .header(HttpHeaders.ACCEPT_RANGES, "bytes")
        .contentType(MediaType.parseMediaType("video/mp4"))
        .body(video);
    // Request:  Range: bytes=0-1023
    // Response: 206 Partial Content, Content-Range: bytes 0-1023/50000
}

3xx — Redirection Codes

Redirection responses tell the client to look elsewhere for the resource. They are less common in REST APIs than in web applications, but are used for permanent URI changes, OAuth flows and conditional requests. Choose the redirect code by what should happen to the HTTP method. 301 and 302 are old codes that many clients handle by changing a POST into a GET. If the method must be preserved, use 307 (temporary) or 308 (permanent). 303 See Other explicitly means "fetch the result with GET", which is the right answer after a POST that finished and has a result page. 304 Not Modified is not a redirect in the usual sense: it tells the client its cached copy is still valid. The example builds the ETag by hand to show the mechanism; in real code let Spring do it with ShallowEtagHeaderFilter (computes ETags from the response body) or WebRequest.checkNotModified(etag), which compares If-None-Match for you, including weak validators and multiple values.
Java
// ── 301 Moved Permanently / 308 Permanent Redirect ───────────────────
// Resource has a new permanent URI. Clients and search engines should
// update their links. Browsers cache this very aggressively.
// Use 308 instead of 301 when the HTTP method must be preserved.
@GetMapping("/users/{id}")
public ResponseEntity<Void> redirectOldPath(@PathVariable Long id) {
    URI newUri = URI.create("/api/v2/users/" + id);
    return ResponseEntity.status(HttpStatus.MOVED_PERMANENTLY)
        .location(newUri)
        .build();
}

// ── 302 Found / 307 Temporary Redirect / 303 See Other ───────────────
// Temporary redirect: keep using the original URI for future requests.
// Used in login and OAuth flows. 307 preserves the method, 303 forces GET.
@GetMapping("/login/start")
public ResponseEntity<Void> startLogin() {
    return ResponseEntity.status(HttpStatus.FOUND)
        .location(URI.create("https://idp.example.com/authorize"))
        .build();
}

// ── 304 Not Modified ──────────────────────────────────────────────────
// Client's cached version is still valid — no body returned.
// Used with ETag / If-None-Match and Last-Modified / If-Modified-Since.
@GetMapping("/{id}")
public ResponseEntity<UserResponse> findById(
        @PathVariable Long id,
        @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) {

    UserResponse user = userService.findById(id);
    String etag = "\"" + user.version() + "\"";    // ETags are quoted strings

    if (etag.equals(ifNoneMatch)) {
        return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
            .eTag(etag).build();         // 304 — client cache is current
    }
    return ResponseEntity.ok().eTag(etag).body(user);  // 200 + fresh body
}

// ── Simpler: let Spring handle the comparison ─────────────────────────
@GetMapping("/v2/{id}")
public ResponseEntity<UserResponse> findByIdV2(@PathVariable Long id, WebRequest request) {
    UserResponse user = userService.findById(id);
    String etag = "\"" + user.version() + "\"";
    if (request.checkNotModified(etag)) {
        return null;      // Spring has already set 304 and the headers
    }
    return ResponseEntity.ok().eTag(etag).body(user);
}

4xx — Client Error Codes

Client errors indicate the request was invalid: the client must fix the request before retrying. Never return a 5xx for a client error; doing so breaks retry logic and monitoring and makes your error rate look worse than it is. Choosing between similar codes: - 400 vs 422: use 400 when the request cannot be parsed (invalid JSON, wrong types, missing required field). Use 422 when it parses fine but breaks a business rule. Many APIs use only 400 for both; pick one convention and apply it consistently. (RFC 9110 renamed 422 "Unprocessable Content"; the numeric code is the same.) - 401 vs 403: 401 means "we do not know who you are", so authenticate and try again. 403 means "we know who you are and the answer is no". - 403 vs 404: if revealing that a resource exists is itself sensitive, return 404 to users who may not see it. - 409 vs 412: 409 is for state conflicts such as duplicates. 412 Precondition Failed is the right answer when an If-Match header (ETag) does not match during an optimistic-locking update; 428 Precondition Required tells the client that it must send If-Match.
Java
// ── 400 Bad Request ──────────────────────────────────────────────────
// Malformed JSON, type mismatch, or failed Bean Validation.
// Most common 4xx — return field-level detail when possible.
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
        MethodArgumentNotValidException ex) {
    Map<String, String> errors = ex.getBindingResult().getFieldErrors()
        .stream().collect(Collectors.toMap(
            FieldError::getField,
            fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid",
            (a, b) -> a));
    return ResponseEntity.badRequest()
        .body(new ErrorResponse(400, "Validation Failed", errors));
}
// Related Spring exceptions that also map to 400:
//   HttpMessageNotReadableException   (invalid JSON body)
//   MethodArgumentTypeMismatchException (e.g. /users/abc for a Long id)
//   MissingServletRequestParameterException
//   HandlerMethodValidationException  (Spring 6.1+: validation of @RequestParam/@PathVariable)

// ── 401 Unauthorized ─────────────────────────────────────────────────
// Authentication is required and has not been provided or is invalid.
// (Misleadingly named — it means "unauthenticated", not "unauthorized".)
// Spring Security returns this automatically; the response must include a
// WWW-Authenticate header, which Spring Security's entry points add.

// ── 403 Forbidden ────────────────────────────────────────────────────
// Authenticated but not authorised to perform this action.
// Spring Security returns this automatically for access denied.
// Use 404 instead if revealing the resource's existence is sensitive.

// ── 404 Not Found ────────────────────────────────────────────────────
// Resource does not exist.
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(UserNotFoundException ex) {
    return ResponseEntity.status(HttpStatus.NOT_FOUND)
        .body(ErrorResponse.of(404, "Not Found", ex.getMessage()));
}

// ── 405 Method Not Allowed ────────────────────────────────────────────
// HTTP method not supported for this URI.
// Spring MVC returns this automatically, with an Allow header.

// ── 409 Conflict ─────────────────────────────────────────────────────
// State conflict — duplicate email, optimistic lock failure,
// or business rule violation.
@ExceptionHandler(EmailAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleConflict(EmailAlreadyExistsException ex) {
    return ResponseEntity.status(HttpStatus.CONFLICT)
        .body(ErrorResponse.of(409, "Conflict", ex.getMessage()));
}

// ── 410 Gone ────────────────────────────────────────────────────────
// Resource existed but was permanently deleted.
// Stronger signal than 404 — tells clients to remove their links.

// ── 412 Precondition Failed ──────────────────────────────────────────
// If-Match / If-Unmodified-Since did not match the current state.
// Typical for optimistic locking with ETags on PUT/PATCH/DELETE.

// ── 415 Unsupported Media Type ────────────────────────────────────────
// Content-Type of the request body is not supported.
// Spring MVC returns this automatically when 'consumes' does not match.

// ── 422 Unprocessable Content (Entity) ────────────────────────────────
// Request is syntactically valid but semantically invalid.
// Some APIs prefer this over 400 for domain-level validation failures.
@ExceptionHandler(DomainValidationException.class)
public ResponseEntity<ErrorResponse> handleDomainValidation(
        DomainValidationException ex) {
    return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
        .body(ErrorResponse.of(422, "Unprocessable Entity", ex.getMessage()));
}

// ── 429 Too Many Requests ─────────────────────────────────────────────
// Rate limit exceeded. Always include a Retry-After header.
@ExceptionHandler(RateLimitExceededException.class)
public ResponseEntity<ErrorResponse> handleRateLimit(RateLimitExceededException ex) {
    return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
        .header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.getRetryAfterSeconds()))
        .body(ErrorResponse.of(429, "Too Many Requests", "Rate limit exceeded"));
}

5xx — Server Error Codes

Server errors indicate the server failed to process a valid request. The client request was correct and the problem is on the server side. These are usually retryable with backoff; 4xx errors are not. Warning about catch-all handlers: a @ExceptionHandler(Exception.class) in a @RestControllerAdvice catches every exception, including the ones Spring MVC would normally translate into 4xx codes (405 Method Not Allowed, 415 Unsupported Media Type, 400 for unreadable JSON). Your clients would then see 500 for their own mistakes. Either extend ResponseEntityExceptionHandler, which already maps those exceptions correctly, or add explicit handlers for them before the catch-all.
Java
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // ── 500 Internal Server Error ─────────────────────────────────────
    // Catch-all for unhandled server-side failures.
    // Log the full stack trace server-side; return a generic message to the client
    // — never expose internal details (stack traces, SQL errors, file paths).
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleAll(Exception ex) {
        log.error("Unhandled exception", ex);   // full detail in logs
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse.of(500, "Internal Server Error",
                "An unexpected error occurred"));   // generic to client
    }

    // ── 502 Bad Gateway ───────────────────────────────────────────────
    // This server received an invalid response from an upstream service.
    // Return when a downstream dependency (another microservice, third-party API)
    // returns an unexpected response.
    @ExceptionHandler(UpstreamServiceException.class)
    public ResponseEntity<ErrorResponse> handleUpstream(UpstreamServiceException ex) {
        return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
            .body(ErrorResponse.of(502, "Bad Gateway",
                "Upstream service returned an invalid response"));
    }

    // ── 503 Service Unavailable ───────────────────────────────────────
    // Server is temporarily unable to handle the request — overloaded,
    // in maintenance, or a critical dependency is down.
    // Include a Retry-After header when the recovery time is known.
    @ExceptionHandler(ServiceUnavailableException.class)
    public ResponseEntity<ErrorResponse> handleUnavailable(ServiceUnavailableException ex) {
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
            .header(HttpHeaders.RETRY_AFTER, "30")
            .body(ErrorResponse.of(503, "Service Unavailable",
                "The service is temporarily unavailable"));
    }

    // ── 504 Gateway Timeout ───────────────────────────────────────────
    // This server did not receive a timely response from an upstream service.
    @ExceptionHandler(UpstreamTimeoutException.class)
    public ResponseEntity<ErrorResponse> handleTimeout(UpstreamTimeoutException ex) {
        return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT)
            .body(ErrorResponse.of(504, "Gateway Timeout",
                "Upstream service timed out"));
    }
}

// Safer alternative for the catch-all: extend ResponseEntityExceptionHandler,
// which already returns the correct 4xx codes for Spring MVC's own exceptions:
//
// @RestControllerAdvice
// public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
//     @ExceptionHandler(Exception.class) ...   // only reached for truly unexpected errors
// }

How Spring MVC Maps Exceptions to Status Codes

Even without any @ExceptionHandler, Spring MVC translates many framework exceptions into the right status code through DefaultHandlerExceptionResolver. Knowing this saves you from writing handlers you do not need, and explains many "why did I get this code?" questions. - HttpRequestMethodNotSupportedException → 405 Method Not Allowed - HttpMediaTypeNotSupportedException → 415 Unsupported Media Type - HttpMediaTypeNotAcceptableException → 406 Not Acceptable - HttpMessageNotReadableException (invalid JSON) → 400 Bad Request - MethodArgumentNotValidException (@Valid on a request body) → 400 Bad Request - MethodArgumentTypeMismatchException / TypeMismatchException → 400 Bad Request - MissingServletRequestParameterException → 400 Bad Request - NoResourceFoundException / NoHandlerFoundException (no matching route) → 404 Not Found - AsyncRequestTimeoutException → 503 Service Unavailable - HttpMessageNotWritableException → 500 Internal Server Error - ResponseStatusException → the status you set - Exceptions annotated with @ResponseStatus → the annotated status - AccessDeniedException / AuthenticationException (Spring Security) → 403 / 401 Anything else that escapes a controller becomes 500, and Spring Boot renders it through its /error endpoint (BasicErrorController), which returns a default JSON body. Control what that body reveals with server.error.include-message, server.error.include-stacktrace=never and server.error.include-binding-errors.

A Standard Error Body: ProblemDetail (RFC 9457)

Instead of inventing your own ErrorResponse class, Spring Framework 6 and Spring Boot 3 support the standard Problem Details format for HTTP APIs (RFC 9457, which replaces RFC 7807). A ProblemDetail carries the fields type, title, status, detail and instance, plus any custom properties, and is served as application/problem+json. Clients and API gateways that understand the standard can parse your errors without custom code. Enable it for Spring's built-in exceptions with spring.mvc.problemdetails.enabled=true, or extend ResponseEntityExceptionHandler. Return ProblemDetail directly from your own handlers, or throw an ErrorResponseException / ResponseStatusException.
Java
// application.properties
// spring.mvc.problemdetails.enabled=true

@RestControllerAdvice
public class ProblemExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ProblemDetail handleNotFound(UserNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("User not found");
        problem.setType(URI.create("https://api.example.com/problems/user-not-found"));
        problem.setProperty("userId", ex.getUserId());     // custom field
        return problem;
    }

    @ExceptionHandler(EmailAlreadyExistsException.class)
    public ProblemDetail handleConflict(EmailAlreadyExistsException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.CONFLICT, ex.getMessage());
        problem.setTitle("Email already registered");
        return problem;
    }
}

// Response:
// HTTP/1.1 404 Not Found
// Content-Type: application/problem+json
// {
//   "type": "https://api.example.com/problems/user-not-found",
//   "title": "User not found",
//   "status": 404,
//   "detail": "User not found: 42",
//   "instance": "/users/42",
//   "userId": 42
// }

HttpStatus Enum and Status Codes in Spring

Spring's HttpStatus enum provides named constants for the standard HTTP status codes. It implements the HttpStatusCode interface (Spring 6+) and integrates with ResponseEntity, @ResponseStatus, ResponseStatusException, RestTemplate and WebClient. Since Spring 6, ResponseEntity.getStatusCode() and the status of client responses return HttpStatusCode, not HttpStatus, because servers may send non-standard codes that are not in the enum. Use HttpStatusCode.valueOf(int) for any code, HttpStatus.resolve(int) when you want null for unknown codes, and note that HttpStatus.valueOf(int) throws IllegalArgumentException for unknown codes. There are three ways to set a status without building a ResponseEntity: @ResponseStatus on a method (fixed success status), @ResponseStatus on an exception class (status for that exception), and throwing ResponseStatusException (status chosen at the throw site, no custom exception class needed). If a method returns a ResponseEntity, the status in the ResponseEntity wins.
Java
// ── HttpStatus enum — the most common constants ───────────────────────
HttpStatus.OK                        // 200
HttpStatus.CREATED                   // 201
HttpStatus.ACCEPTED                  // 202
HttpStatus.NO_CONTENT                // 204
HttpStatus.PARTIAL_CONTENT           // 206
HttpStatus.MOVED_PERMANENTLY         // 301
HttpStatus.FOUND                     // 302
HttpStatus.NOT_MODIFIED              // 304
HttpStatus.TEMPORARY_REDIRECT        // 307
HttpStatus.PERMANENT_REDIRECT        // 308
HttpStatus.BAD_REQUEST               // 400
HttpStatus.UNAUTHORIZED              // 401
HttpStatus.FORBIDDEN                 // 403
HttpStatus.NOT_FOUND                 // 404
HttpStatus.METHOD_NOT_ALLOWED        // 405
HttpStatus.CONFLICT                  // 409
HttpStatus.GONE                      // 410
HttpStatus.PRECONDITION_FAILED       // 412
HttpStatus.UNSUPPORTED_MEDIA_TYPE    // 415
HttpStatus.UNPROCESSABLE_ENTITY      // 422 (newer Spring versions also offer a "content" name; check yours)
HttpStatus.TOO_MANY_REQUESTS         // 429
HttpStatus.INTERNAL_SERVER_ERROR     // 500
HttpStatus.BAD_GATEWAY               // 502
HttpStatus.SERVICE_UNAVAILABLE       // 503
HttpStatus.GATEWAY_TIMEOUT           // 504

// ── HttpStatus utility methods ────────────────────────────────────────
HttpStatus notFound = HttpStatus.valueOf(404);        // throws for unknown codes
HttpStatus unknown  = HttpStatus.resolve(999);        // null if unknown code
HttpStatusCode any  = HttpStatusCode.valueOf(299);    // works for non-standard codes too
notFound.value()                                      // int: 404
notFound.getReasonPhrase()                            // "Not Found"
notFound.is2xxSuccessful()                            // false
notFound.is4xxClientError()                           // true
notFound.is5xxServerError()                           // false
notFound.isError()                                    // true (4xx or 5xx)

// ── @ResponseStatus on exception classes ──────────────────────────────
// Annotate a custom exception to map it to a status code automatically:
@ResponseStatus(HttpStatus.NOT_FOUND)
public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(Long id) {
        super("User not found: " + id);
    }
}
// Any unhandled UserNotFoundException → 404 Not Found
// (Without a @RestControllerAdvice handler — less control over body shape)

// ── ResponseStatusException — no custom exception class needed ─────────
@GetMapping("/{id}")
public UserResponse findById(@PathVariable Long id) {
    return userService.findOptionalById(id)
        .orElseThrow(() -> new ResponseStatusException(
            HttpStatus.NOT_FOUND, "User not found: " + id));
}

// ── @ResponseStatus on controller methods ─────────────────────────────
// Forces a specific status on a method that returns void or a plain object:
@PostMapping
@ResponseStatus(HttpStatus.CREATED)    // 201 Created — no ResponseEntity needed
public UserResponse create(@RequestBody @Valid CreateUserRequest request) {
    return userService.create(request);
    // Returns 201 + serialized body — but no Location header control
}

// Prefer ResponseEntity for creation — it allows the Location header.
// @ResponseStatus is convenient for simple cases where headers don't matter.

Status Code Decision Guide

A practical reference for choosing the correct status code for common REST API scenarios. Whatever you choose, apply the same convention across the whole API.
Java
// ── GET requests ─────────────────────────────────────────────────────
// Resource found and returned          → 200 OK
// Resource not found                   → 404 Not Found
// Cached response still valid          → 304 Not Modified
// Access denied                        → 403 Forbidden (or 404 to hide existence)

// ── POST requests (create) ────────────────────────────────────────────
// Resource created                     → 201 Created + Location header
// Request accepted, processing async   → 202 Accepted + status URL
// Validation failed                    → 400 Bad Request + field errors
// Duplicate / conflict                 → 409 Conflict
// Business rule violation              → 422 Unprocessable Content

// ── PUT requests (full update) ────────────────────────────────────────
// Resource updated, body returned      → 200 OK
// Resource updated, no body            → 204 No Content
// Resource created by PUT              → 201 Created
// Resource not found                   → 404 Not Found
// Optimistic lock conflict             → 409 Conflict (or 412 with If-Match)

// ── PATCH requests (partial update) ──────────────────────────────────
// Fields updated, body returned        → 200 OK
// Fields updated, no body              → 204 No Content
// Resource not found                   → 404 Not Found
// Invalid field value                  → 400 Bad Request

// ── DELETE requests ───────────────────────────────────────────────────
// Resource deleted                     → 204 No Content
// Resource not found                   → 404 Not Found (or 204 — be consistent)
// Resource permanently gone            → 410 Gone

// ── Auth scenarios ────────────────────────────────────────────────────
// No credentials provided              → 401 Unauthorized
// Invalid credentials                  → 401 Unauthorized
// Valid credentials, wrong permissions → 403 Forbidden
// Rate limit exceeded                  → 429 Too Many Requests

// ── Server scenarios ──────────────────────────────────────────────────
// Unhandled exception                  → 500 Internal Server Error
// Upstream service error               → 502 Bad Gateway
// Server overloaded / maintenance      → 503 Service Unavailable
// Upstream timeout                     → 504 Gateway Timeout

Testing Status Codes with MockMvc

Status codes are part of your API contract, so test them. MockMvc lets you assert the status, headers and body without starting a server. Test the failure paths (404, 400, 409) as carefully as the happy path, and check that the Location header is present on 201.
Java
@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired MockMvc mvc;
    @MockitoBean UserService userService;   // @MockBean on older Spring Boot versions

    @Test
    void createReturns201WithLocation() throws Exception {
        when(userService.create(any())).thenReturn(new UserResponse(42L, "Ana"));

        mvc.perform(post("/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"Ana\",\"email\":\"ana@example.com\"}"))
            .andExpect(status().isCreated())
            .andExpect(header().string("Location", endsWith("/users/42")))
            .andExpect(jsonPath("$.id").value(42));
    }

    @Test
    void unknownUserReturns404() throws Exception {
        when(userService.findOptionalById(99L)).thenReturn(Optional.empty());

        mvc.perform(get("/users/99"))
            .andExpect(status().isNotFound());
    }

    @Test
    void invalidBodyReturns400() throws Exception {
        mvc.perform(post("/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{}"))
            .andExpect(status().isBadRequest());
    }

    @Test
    void deleteReturns204WithoutBody() throws Exception {
        mvc.perform(delete("/users/42"))
            .andExpect(status().isNoContent())
            .andExpect(content().string(""));
    }
}

Common Status Code Mistakes

1. Returning 200 with an error message in the body. Clients, proxies, caches and monitoring tools only look at the status. Use a 4xx or 5xx code. 2. Returning 500 for validation or lookup failures. These are client errors (400, 404, 409). A catch-all handler that turns everything into 500 also hides Spring's own 405, 415 and 400 responses (see the 5xx section). 3. Returning null from a controller for "not found". Spring answers 200 with an empty body. Return ResponseEntity.notFound() or throw an exception. 4. Mixing up 401 and 403. 401 = not authenticated, 403 = authenticated but not allowed. 5. Returning a body with 204. A 204 response must not contain a body; use 200 if you want to return data. 6. Forgetting the Location header on 201. Clients need it to find the new resource. 7. Using 201 for an update, or 200 for a creation. The code should describe the outcome. 8. Leaking internals in 5xx responses (stack traces, SQL messages, class names). Log them, return a generic message, and set server.error.include-stacktrace=never. 9. Missing Retry-After on 429 and 503. Without it clients guess, and usually guess wrong. 10. Inconsistent conventions, such as DELETE returning 404 for some resources and 204 for others, or error bodies in different shapes. Standardise on ProblemDetail or one ErrorResponse class. 11. Redirecting POST requests with 301/302 and expecting the method to be kept. Use 307 or 308. 12. Using @ResponseStatus on a method that returns ResponseEntity. The ResponseEntity status wins and the annotation is ignored.

Frequently Asked Questions

How do I return a specific HTTP status code in Spring Boot? Return ResponseEntity.status(HttpStatus.XXX).body(...) or one of its shortcuts (ok(), created(uri), accepted(), noContent(), badRequest(), notFound()). For a fixed status use @ResponseStatus, and from deep inside the code throw ResponseStatusException. What is the difference between ResponseEntity and @ResponseStatus? ResponseEntity decides status, headers and body at runtime for each call. @ResponseStatus sets a fixed status on a method or exception class. If both are present, the ResponseEntity status wins. Which status code should a POST return? 201 Created with a Location header when a resource is created, 202 Accepted for asynchronous processing, and 200 OK when the POST is an action that returns a result without creating a resource. Should DELETE return 200, 202 or 204? 204 No Content is the usual answer. Use 200 if you return a body, and 202 if the deletion happens asynchronously. What is the difference between 400 and 422? 400 means the request is malformed or cannot be parsed. 422 means it is well-formed but fails semantic or business validation. Many APIs use 400 for both; consistency matters more than the choice. What is the difference between 401 and 403? 401 means the client is not authenticated, 403 means the client is authenticated but lacks permission. Why does Spring return 404 for a URL that exists? Common causes: wrong HTTP method mapping, a missing context path or servlet path, a controller outside component scanning, or a static resource that is not where Spring expects it. Since Spring 6.1, unmatched paths raise NoResourceFoundException, which becomes 404. Why do I get 415 Unsupported Media Type? The request Content-Type does not match the controller's consumes value, or you forgot Content-Type: application/json for a JSON body. Why is my validation error a 500? Usually a catch-all @ExceptionHandler(Exception.class) is intercepting MethodArgumentNotValidException. Add a specific handler or extend ResponseEntityExceptionHandler. How do I change the status code of an unhandled exception? Annotate the exception class with @ResponseStatus, throw ResponseStatusException instead, or handle it in a @RestControllerAdvice. What is ProblemDetail? Spring 6's implementation of the standard RFC 9457 error format (application/problem+json). It gives your API a consistent, machine-readable error body. Does a client retry on 5xx automatically? Not by default. Retry logic belongs in the client (Resilience4j, Spring Retry, a gateway) and should apply backoff and only retry idempotent requests.

Key Takeaways

- 2xx = it worked, 4xx = the client must change the request, 5xx = the server failed. Use the class correctly and the specific code precisely. - POST that creates → 201 + Location; DELETE → 204; async work → 202; missing → 404; duplicate → 409; unauthenticated → 401; forbidden → 403. - Use ResponseEntity for dynamic responses, @ResponseStatus for fixed ones, and ResponseStatusException or a @RestControllerAdvice for errors. - Never return null for "not found", never return 200 with an error body, and never expose stack traces. - A catch-all Exception handler hides Spring's own 4xx codes; extend ResponseEntityExceptionHandler. - Prefer ProblemDetail (RFC 9457) for a standard error format, and test status codes with MockMvc. - Related topics: Exception Handling, Bean Validation, REST Controllers, Spring Security, API Rate Limiting.