Building REST APIs with Spring Boot

HTTP Status Codes & REST Semantics

18 min Lesson 6 of 13

HTTP Status Codes & REST Semantics

Returning the correct HTTP status code is not cosmetic — it is the primary signalling mechanism your API has with every client, proxy, cache, and monitoring tool in the chain. A well-chosen code tells the caller whether to retry, parse a body, redirect, log an alert, or do nothing. Getting codes wrong erodes trust in your API and forces clients to write defensive workarounds. This lesson maps the standard status families to real REST operations and shows how Spring Boot lets you control them precisely.

The Five Status Families

HTTP status codes fall into five families, each with a shared meaning:

  • 1xx — Informational: the request is being processed. Rare in REST APIs; you will seldom send these yourself.
  • 2xx — Success: the operation completed as requested. This is where you spend most of your design energy.
  • 3xx — Redirection: the client must take another action, usually follow a new URL.
  • 4xx — Client error: the request is bad. The client must fix it before retrying.
  • 5xx — Server error: the server failed. The client may retry later.

The 2xx Family — Matching Code to Operation

Most developers default to 200 OK for everything. This works, but loses precision. The right code:

  • 200 OK — a read or a full replacement succeeded and the response body carries the result. Use for GET, PUT (when returning the updated resource), and POST actions that are not resource creation.
  • 201 Created — a new resource was created. Use for POST (and occasionally PUT). The response should include a Location header pointing at the new resource URL.
  • 204 No Content — the operation succeeded but there is nothing to return. Use for DELETE, and for PUT/PATCH when you do not need to echo the updated representation back.
  • 202 Accepted — the request has been accepted for asynchronous processing but is not yet complete. The body usually contains a task or job ID the client can poll.
Why 201 + Location matters: Clients that follow the response can immediately navigate to the newly created resource without a second query. Frameworks and API gateways also use this header to auto-link related resources.

Returning 201 and Location in Spring Boot

Use ResponseEntity together with UriComponentsBuilder or ServletUriComponentsBuilder to build the Location URI cleanly:

import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.net.URI; @RestController @RequestMapping("/api/v1/products") public class ProductController { private final ProductService productService; public ProductController(ProductService productService) { this.productService = productService; } @PostMapping public ResponseEntity<Product> create(@RequestBody @Valid ProductRequest req) { Product saved = productService.create(req); URI location = ServletUriComponentsBuilder .fromCurrentRequest() // base = /api/v1/products .path("/{id}") .buildAndExpand(saved.getId()) .toUri(); // /api/v1/products/42 return ResponseEntity.created(location).body(saved); // 201 + Location header + body } }

ResponseEntity.created(uri) is a factory method that sets the status to 201 and the Location header in one call. Appending .body(saved) is optional — some teams omit it to keep the response lean.

Returning 204 for DELETE

@DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable Long id) { productService.delete(id); return ResponseEntity.noContent().build(); // 204, no body }

Using Void as the generic type makes it explicit that no body is expected. ResponseEntity.noContent().build() is the idiomatic factory.

The 4xx Family — Client Errors

These codes tell the client it must fix the request. Do not collapse them all to 400:

  • 400 Bad Request — the request body or parameters are syntactically or semantically invalid. Use when validation fails.
  • 401 Unauthorized — the client is not authenticated. The name is misleading; it really means "unauthenticated." Spring Security sets this automatically for protected endpoints.
  • 403 Forbidden — the client is authenticated but does not have permission for this operation.
  • 404 Not Found — the requested resource does not exist. Never use 200 with a null body when a record is absent.
  • 405 Method Not Allowed — the HTTP method is not supported for this path (Spring sets this automatically).
  • 409 Conflict — the operation cannot complete due to a conflict with the current state of the resource, for example a duplicate email during registration.
  • 422 Unprocessable Entity — the syntax is valid but the business rules reject the data. Many teams prefer this over 400 for validation errors to distinguish parse failures from logic failures.
  • 429 Too Many Requests — the client has exceeded a rate limit.
Use 404 consistently for missing resources. Leaking different codes depending on whether the row is missing vs. the user lacks access can reveal information about which IDs exist. For security-sensitive resources, returning 404 whether the record is missing or inaccessible is a deliberate design choice that prevents enumeration attacks.

Throwing Exceptions That Map to Status Codes

Cluttering every controller method with status-code decisions is noisy. The cleaner pattern is to throw a domain-specific exception and let a centralized @ControllerAdvice map it:

// Domain exception public class ResourceNotFoundException extends RuntimeException { public ResourceNotFoundException(String message) { super(message); } } // In the service or controller public Product findById(Long id) { return repository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Product not found: " + id)); }
import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(ResourceNotFoundException.class) public ProblemDetail handleNotFound(ResourceNotFoundException ex) { ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); pd.setTitle("Resource Not Found"); return pd; // Spring serialises this with status 404 } @ExceptionHandler(ConflictException.class) public ProblemDetail handleConflict(ConflictException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage()); } }

ProblemDetail (introduced in Spring 6 / Spring Boot 3) implements RFC 9457 (formerly RFC 7807), the standard problem-detail format. Clients receive a JSON body with type, title, status, and detail fields they can handle programmatically.

Using @ResponseStatus on Exception Classes

For simple cases you can annotate the exception itself instead of writing an @ExceptionHandler:

import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; @ResponseStatus(HttpStatus.NOT_FOUND) public class ResourceNotFoundException extends RuntimeException { public ResourceNotFoundException(String message) { super(message); } }

Spring will set the status code automatically when this exception propagates out of a controller. The trade-off: you cannot customise the response body this way, so prefer @ExceptionHandler + ProblemDetail for any production API where clients need structured error details.

The 5xx Family — When to Use 500 vs 503

Avoid writing code that deliberately throws 500. Let unhandled exceptions fall through to Spring's default handler, which returns 500. Use 503 Service Unavailable when a dependency (database, external service) is temporarily down and you want to signal that the client should retry — optionally include a Retry-After header. 502 Bad Gateway and 504 Gateway Timeout come from infrastructure layers (reverse proxies, load balancers), not your application code.

Never return 200 with an error in the body. Patterns like {"success": false, "error": "not found"} wrapped in a 200 response break every HTTP-aware tool in existence: caches store the error, monitoring graphs it as success, and retry logic skips it. Use the correct status code so the protocol layer works for you.

REST Semantics: Idempotency and Safety

Status codes alone do not tell the full story. Two HTTP properties shape which codes are appropriate:

  • Safe methods (GET, HEAD) must not change server state. They should never return 201 or 204.
  • Idempotent methods (GET, PUT, DELETE, HEAD, OPTIONS) produce the same result no matter how many times they are called. A DELETE on a resource that no longer exists should return 404 the first time and 404 again on a repeat — not a different code, not an error about "already deleted."
  • POST is neither safe nor idempotent. Calling POST /products twice should create two products, and the second call returns 201 again (or 409 if duplicates are forbidden).

Quick Reference Table

  • GET /resources200 OK (list), 404 impossible (list is empty = 200 + [])
  • GET /resources/{id}200 OK or 404 Not Found
  • POST /resources201 Created + Location, or 400/409 on error
  • PUT /resources/{id}200 OK (with body) or 204 No Content (no body); 404 if missing
  • PATCH /resources/{id}200 OK or 204 No Content; 404 or 422 on error
  • DELETE /resources/{id}204 No Content; 404 if missing

Summary

Choosing the correct status code is a deliberate design act. Return 201 with a Location header when you create, 204 when you delete or update without echoing the body, 404 when a resource is absent, and the appropriate 4xx when the client sends bad data. Centralise status-code decisions in a @RestControllerAdvice using ProblemDetail, and never abuse 200 to hide failures. With these habits your API communicates precisely over HTTP the same language every proxy, client, and monitoring tool already speaks.