When you’re writing a REST API, you’ve probably thought, “I just want to return a 201 or a 404. Do I really have to wrap everything in a ResponseEntity every time?” That’s where @ResponseStatus comes in handy.
It’s not always obvious how to use it, though. Should it go on the method, on the exception class, or should you throw a ResponseStatusException instead? You may also have added a reason and then found that your JSON body was gone.
This article targets Spring Boot 3.x (Spring Framework 6.x, jakarta packages). We’ll go through the three places you can put the annotation, when to use ResponseStatusException or ResponseEntity instead, common pitfalls, and how to test status codes with MockMvc.
What Is @ResponseStatus?
@ResponseStatus is an annotation that declares which HTTP status code to return. It lives in the org.springframework.web.bind.annotation package.
It has only two attributes:
code(aliasvalue): the status to return, as anHttpStatusreason: a reason string. Adding it changes the behavior a lot, so we’ll cover it in detail later
You can put it in three places: on a controller method, on a custom exception class, or on an @ExceptionHandler method. It’s the same annotation in all three cases, but a different mechanism handles it in each one.
Usage 1: On a Controller Method to Return 201 / 204
This is the most basic usage. The article uses only these two DTOs:
public record CreateUserRequest(String name) {}
public record UserResponse(Long id, String name) {}
Let’s return 201 Created for POST and 204 No Content for DELETE. We’ll also add a GET endpoint for fetching a user.
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public UserResponse findById(@PathVariable Long id) {
return userService.findById(id); // 200 OK
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse create(@RequestBody CreateUserRequest request) {
return userService.create(request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
userService.delete(id);
}
}
Without a reason, Spring just calls response.setStatus() internally. The return value is written out as JSON as usual, so you don’t need to wrap it in a ResponseEntity. If the method returns void, you get a 204 with no body.
For the full CRUD implementation, see How to Implement a CRUD REST API with Spring Boot. Here we’ll stick to setting the status.
Usage 2: On a Custom Exception Class to Return 404
Next, let’s put the annotation on an exception class. Throwing the exception is all it takes to produce that status.
@ResponseStatus(HttpStatus.NOT_FOUND)
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(Long id) {
super("user id=" + id + " not found");
}
}
// UserService
public UserResponse findById(Long id) {
return userRepository.findById(id)
.map(u -> new UserResponse(u.getId(), u.getName()))
.orElseThrow(() -> new UserNotFoundException(id));
}
// UserController(使い方1の GET をそのまま使う)
@GetMapping("/{id}")
public UserResponse findById(@PathVariable Long id) {
return userService.findById(id);
}
When GET /users/99 throws the exception, ResponseStatusExceptionResolver reads the annotation and turns it into a 404. Spring also looks for the annotation on superclasses, so if you annotate a parent exception, its subclasses get the same status.
One thing to watch out for: this approach uses response.sendError() internally. The body becomes the JSON generated by Spring Boot’s /error endpoint (BasicErrorController), with fields such as timestamp, status, error, and path. If you want to decide the contents yourself, Usage 3 is a better fit.
Usage 3: On an @ExceptionHandler Method to Return a Custom Body
If you want to return your own JSON for an error and still declare the status, put the annotation on an @ExceptionHandler method.
public record ErrorBody(String code, String message) {}
// UserController の中に書く
@ExceptionHandler(UserNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorBody handleNotFound(UserNotFoundException ex) {
return new ErrorBody("USER_NOT_FOUND", ex.getMessage());
}
The returned ErrorBody becomes the response body as-is, so you have much more control over its shape than with the /error format.
Here the handler is local to the controller. For a design that keeps error formats consistent across the whole application, see How to Return Consistent Error Responses from a REST API.
How It Differs from ResponseStatusException
When you only need a 404 in one spot, creating a dedicated exception class is overkill. In that case, throw a ResponseStatusException.
@GetMapping("/search")
public UserResponse findByEmail(@RequestParam String email) {
return userService.findByEmail(email) // Optional<UserResponse> を返す想定
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "user email=" + email + " not found"));
}
You don’t need a dedicated exception class, and you can build the reason string at runtime. However, if you throw it all over the place, the logic for which status to return in which situation ends up scattered through your code. For errors you reuse, it’s better to consolidate them with Usage 2 or 3.
Also note that in Spring Framework 6, ResponseStatusException extends ErrorResponseException and implements ErrorResponse. If you set spring.mvc.problemdetails.enabled=true, errors can be returned in the RFC 9457 ProblemDetail format, with the reason placed in detail. For details, see How to Standardize Error Responses with Problem Details (RFC 9457).
Comparing with ResponseEntity and Choosing Between Them
When you want a 201 with a Location header, or you need to switch between 200 and 404 based on a condition, use ResponseEntity.
@PostMapping
public ResponseEntity<UserResponse> create(@RequestBody CreateUserRequest request) {
UserResponse created = userService.create(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri(); // http://localhost:8080/users/1 のような絶対URI
return ResponseEntity.created(location).body(created);
}
ServletUriComponentsBuilder builds the host name and path from the current request, so you don’t have to hard-code the URL.
Here’s a summary of all the approaches covered so far:
| Approach | Status | Body | Headers | Best for |
|---|---|---|---|---|
| @ResponseStatus on a method | Fixed | Return value as-is | Possible via HttpServletResponse | 201 / 204 on success |
| @ResponseStatus on an exception class | Fixed | Left to /error | Not possible | Reusable business errors |
| @ExceptionHandler + @ResponseStatus | Fixed | Fully customizable | Possible via HttpServletResponse | Errors with custom JSON |
| ResponseStatusException | Set at runtime | /error or ProblemDetail | Not possible | One-off errors |
| ResponseEntity | Set at runtime | Fully customizable | Fully customizable (recommended) | Location headers and conditional logic |
Also, avoid using @ResponseStatus and ResponseEntity together on the same method. Readers have to work out which one actually takes effect, so it’s safer to pick one.
Pitfall 1: Adding a reason Makes the JSON Body Disappear
This is the most common trap. Let’s try adding a reason to the Usage 3 handler, just to be helpful:
@ExceptionHandler(UserNotFoundException.class)
@ResponseStatus(code = HttpStatus.NOT_FOUND, reason = "user not found")
public ErrorBody handleNotFound(UserNotFoundException ex) {
return new ErrorBody("USER_NOT_FOUND", ex.getMessage()); // 書き出されない
}
When a reason is present, Spring calls response.sendError(404, "user not found") instead of setStatus() and doesn’t write the return value at all. The request is forwarded to /error, and the body is replaced with BasicErrorController’s JSON.
# reason なし
$ curl -i localhost:8080/users/99
HTTP/1.1 404
{"code":"USER_NOT_FOUND","message":"user id=99 not found"}
# reason あり
$ curl -i localhost:8080/users/99
HTTP/1.1 404
{"timestamp":"2026-11-13T01:00:00.000+00:00","status":404,"error":"Not Found","path":"/users/99"}
If you want to return a custom body, remove the reason. If you want to send a message, put it in the body (a DTO or ProblemDetail), not in the reason.
One more thing: if you’re using Spring Security, the forward to /error (the ERROR dispatch) also goes through authorization. If /error isn’t set to permitAll(), a response that should be a 404 may come back as a 401 or 403, or with an empty body.
Pitfall 2: The message Doesn’t Show Up (server.error.include-message)
Another common complaint is, “I added a reason, but no message appears in the /error JSON.” This is because since Spring Boot 2.3 (and in 3.x as well), server.error.include-message defaults to never. If the message suddenly disappeared after migrating from a 2.2-or-earlier project, also check out the Spring Boot 2.x to 3.x Migration Guide.
# application-dev.yml(開発プロファイルだけで有効にする)
server:
error:
include-message: always # on_param なら ?message=true のときだけ含める
However, setting it to always in production can leak internal details through exception messages. It’s safer to enable it only in development, or to switch to a custom body like in Usage 3.
Pitfall 3: @RestControllerAdvice Overrides the Exception’s @ResponseStatus
If you annotated an exception with 404 and it isn’t working, suspect this first. ExceptionHandlerExceptionResolver, which processes @ExceptionHandler methods, runs before ResponseStatusExceptionResolver. So if an advice catches the same exception, one of its parent classes, or Exception itself, the annotation on the exception class is never consulted.
A typical case is an advice with only an Exception handler annotated with @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR). UserNotFoundException gets swallowed by it too, so what should be a 404 becomes a 500. If that handler doesn’t declare a status at all, you’ll get a 200 instead. ResponseStatusException gets swallowed the same way, so be careful.
@RestControllerAdvice
public class GlobalExceptionHandler {
// 個別のハンドラを用意し、ステータスもハンドラ側で明示する
@ExceptionHandler(UserNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorBody handleNotFound(UserNotFoundException ex) {
return new ErrorBody("USER_NOT_FOUND", ex.getMessage());
}
// これだけだと UserNotFoundException も 500 になる
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorBody handleAll(Exception ex) {
return new ErrorBody("INTERNAL_ERROR", "unexpected error");
}
}
Handlers for more specific exceptions take precedence, so this correctly separates 404 from 500. If you want to keep relying on the annotation on the exception class, another option is to read it inside the handler with AnnotatedElementUtils.findMergedAnnotation(ex.getClass(), ResponseStatus.class) and return that status via a ResponseEntity.
Verifying Status Codes with MockMvc
Finally, let’s write tests to confirm that the expected statuses are returned. The target is the UserController from Usages 1–3.
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
MockMvc mockMvc;
@MockitoBean // Spring Boot 3.3以前は @MockBean
UserService userService;
@Test
void createReturns201() throws Exception {
when(userService.create(any())).thenReturn(new UserResponse(1L, "taro"));
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"taro\"}"))
.andExpect(status().isCreated());
}
@Test
void deleteReturns204() throws Exception {
mockMvc.perform(delete("/users/1"))
.andExpect(status().isNoContent());
}
@Test
void notFoundReturns404() throws Exception {
when(userService.findById(99L)).thenThrow(new UserNotFoundException(99L));
mockMvc.perform(get("/users/99"))
.andExpect(status().isNotFound());
}
}
MockMvc doesn’t actually perform the forward to /error. If you switch to the reason-annotated handler from Pitfall 1, verify the reason string instead of the body:
mockMvc.perform(get("/users/99"))
.andExpect(status().isNotFound())
.andExpect(status().reason("user not found"));
To test error responses that go through an advice more thoroughly, see How to Test @RestControllerAdvice Exception Handlers with MockMvc.
Summary
Remember these rules and you’ll rarely get stuck:
- Fixed status on success: @ResponseStatus on the method
- Reusable business errors: the exception class or an @ExceptionHandler
- One-off errors: ResponseStatusException
- Headers or conditional logic: ResponseEntity
Beyond that, keep two points in mind. If you want a custom body, don’t set a reason. And once you add an advice that catches an exception, the annotation on that exception class no longer takes effect. If your team agrees on one place to decide status codes, the code will also be easier for the next person to read.