A Spring MVC handler method never parses a raw request. It declares parameters — a Long id, a boolean dryRun, a record — and before the method runs, Spring finds each value in the right part of the HTTP request, converts it from text to the declared type and passes it in. The annotation on each parameter names the part: the path, the query string, a header, a cookie or the body. The return value travels the other way, and ResponseEntity is how a method sets the status code and headers instead of only the body.
This article goes through each annotation and shows what actually happens when a value is missing or cannot be converted. Those cases decide whether a client receives a 400 it can fix or a 500 that points at your code, and one of them no longer behaves the way older tutorials describe. It finishes by upgrading the product catalogue so that it returns 201 with a Location header, 404 for a product that does not exist and 204 for a delete.
![]()
Everything below was produced on OpenJDK 21.0.6 with Spring Boot 4.1.1 (Spring Framework 7.0.9, embedded Tomcat 11.0.24) and Gradle 9.7.1, on a project generated by Spring Initializr with dependencies=web. The application was started with java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8117, so the commands use port 8117, and every status line, header and log line is copied from those runs.
The product catalogue this article starts from
Chapter 3 builds one API, a product catalogue under /api/products. Products live in a ConcurrentHashMap with an AtomicLong handing out ids, because the database only arrives in Chapter 4. The model is three small types in com.example.demo.product:
package com.example.demo.product;
public enum Category {
BOOKS, ELECTRONICS, GROCERY
}package com.example.demo.product;
import java.math.BigDecimal;
public record Product(Long id, String name, BigDecimal price, Category category) {
}package com.example.demo.product;
import java.math.BigDecimal;
public record ProductRequest(String name, BigDecimal price, Category category) {
}ProductRequest is what a client sends to create a product: every field except the id, which the server assigns. Designing request and response types properly is the subject of the next article.
The controller starts from the CRUD skeleton of the previous article, with four changes for this one: products get a category, which the list filter at the end needs; create reads a ProductRequest, which has no id field, instead of a Product; the constructor stores three products, so every example has something to find; and replace and update are left out, because nothing here changes them:
package com.example.demo.product;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final Map<Long, Product> products = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
public ProductController() {
add(new ProductRequest("Clean Code", new BigDecimal("32.50"), Category.BOOKS));
add(new ProductRequest("Mechanical Keyboard", new BigDecimal("89.90"), Category.ELECTRONICS));
add(new ProductRequest("Arabica Coffee Beans", new BigDecimal("14.20"), Category.GROCERY));
}
@GetMapping
public List<Product> findAll() {
return products.values().stream()
.sorted(Comparator.comparing(Product::id))
.toList();
}
@GetMapping("/{id}")
public Product findById(@PathVariable Long id) {
return products.get(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Product create(@RequestBody ProductRequest request) {
return add(request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
products.remove(id);
}
private Product add(ProductRequest request) {
long id = sequence.incrementAndGet();
Product product = new Product(id, request.name(), request.price(), request.category());
products.put(id, product);
return product;
}
}Like the previous version, it answers with a fixed status whatever happens. Asking for a product that does not exist:
curl -i http://localhost:8117/api/products/99HTTP/1.1 200
Content-Length: 0
Date: Sat, 12 Sep 2026 07:01:33 GMTfindById returned null, so there was no body to write and the status stayed 200. Deleting the same missing product:
curl -i -X DELETE http://localhost:8117/api/products/99HTTP/1.1 204
Date: Sat, 12 Sep 2026 07:01:33 GMT@ResponseStatus(HttpStatus.NO_CONTENT) promises 204 before the method has run, so it cannot report that nothing was deleted. Both get fixed at the end of this article. First, how data gets into these methods.
Where each part of an HTTP request is bound
An HTTP request carries data in five places, and Spring MVC has one annotation for each:
| Part of the request | Example | Annotation |
|---|---|---|
| Path | /api/products/42 | @PathVariable |
| Query string, and the fields of a form body | ?category=BOOKS | @RequestParam |
| Headers | X-Request-Id: 7f3a9c | @RequestHeader |
| Cookies | Cookie: theme=dark | @CookieValue |
| Body | {"name":"Desk Lamp"} | @RequestBody |
To watch four of them at work in one call, add a throwaway controller that returns exactly what it received:
package com.example.demo.echo;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.example.demo.product.ProductRequest;
@RestController
public class EchoController {
public record Bound(Long id, boolean dryRun, String requestId, ProductRequest body) {
}
@PutMapping("/echo/products/{id}")
public Bound echo(
@PathVariable Long id,
@RequestParam boolean dryRun,
@RequestHeader("X-Request-Id") String requestId,
@RequestBody ProductRequest body) {
return new Bound(id, dryRun, requestId, body);
}
}curl -i -X PUT "http://localhost:8117/echo/products/42?dryRun=true" \
-H "Content-Type: application/json" \
-H "X-Request-Id: 7f3a9c" \
-d '{"name":"Mechanical Keyboard","price":89.90,"category":"ELECTRONICS"}'HTTP/1.1 200
Content-Type: application/json
Content-Length: 121
Date: Sat, 12 Sep 2026 07:01:33 GMT
{"id":42,"dryRun":true,"requestId":"7f3a9c","body":{"name":"Mechanical Keyboard","price":89.90,"category":"ELECTRONICS"}}Running the same command with -v instead of -i shows the request exactly as it left curl:
> PUT /echo/products/42?dryRun=true HTTP/1.1
> Host: localhost:8117
> User-Agent: curl/8.7.1
> Accept: */*
> Content-Type: application/json
> X-Request-Id: 7f3a9c
> Content-Length: 69
>
Each value took a different route. 42 was cut out of the path by matching it against {id}, then converted from text to Long. true was looked up in the query string by the name dryRun and converted to boolean. The header was found by its name. The body went to Jackson, because Content-Type said it was JSON. curl added Host, User-Agent, Accept and Content-Length on its own; they reach the server too, but no parameter asks for them.
The experiments in the rest of the article go into one more throwaway class, so the catalogue itself stays clean. It carries every import the following sections need:
package com.example.demo.lab;
import java.math.BigDecimal;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.springframework.web.util.UriComponentsBuilder;
import com.example.demo.product.Category;
import com.example.demo.product.ProductRequest;
import jakarta.servlet.http.HttpServletRequest;
@RestController
@RequestMapping("/lab")
public class LabController {
// the methods from the following sections go here
}Parameters that need no annotation
Some types are recognised by their type alone, and Spring MVC injects them without any annotation:
@GetMapping("/request-info")
public String requestInfo(HttpServletRequest request, HttpMethod method, Locale locale,
UriComponentsBuilder uriBuilder) {
return "request=" + request.getRequestURI() + "?" + request.getQueryString()
+ ", method=" + method
+ ", locale=" + locale
+ ", uriBuilder=" + uriBuilder.toUriString() + "\n";
}curl "http://localhost:8117/lab/request-info?page=2" -H "Accept-Language: vi-VN"request=/lab/request-info?page=2, method=GET, locale=vi_VN, uriBuilder=http://localhost:8117| Parameter type | What Spring passes in |
|---|---|
HttpServletRequest | the servlet request itself, for anything no annotation covers |
HttpMethod | the request method, GET here |
Locale | the locale from Accept-Language; without that header, the JVM default, which was en_VN on this machine |
UriComponentsBuilder | a builder preset to the scheme, host and port of the current request |
Being a Spring type is not enough to be on that list. HttpHeaders looks as though it should work the same way, and it does not fail — it arrives empty:
@GetMapping("/unannotated-headers")
public String unannotatedHeaders(HttpHeaders headers) {
return "size=" + headers.size() + ", names=" + headers.headerNames() + "\n";
}curl http://localhost:8117/lab/unannotated-headers -H "X-Request-Id: 7f3a9c"size=0, names=[]curl "http://localhost:8117/lab/unannotated-headers?contentLength=5" -H "X-Request-Id: 7f3a9c"size=1, names=[Content-Length]Spring MVC treats an unannotated parameter of a type it has no special handling for as a model attribute: it creates a new instance and binds request parameters onto its setters, so ?contentLength=5 became a call to setContentLength(5). Model attributes belong to the article on server-rendered forms; to receive the request's headers, the parameter needs @RequestHeader, shown below.
@PathVariable: values from the URL path
@PathVariable binds a {name} segment of the mapping pattern to a parameter. The catalogue already uses it:
@GetMapping("/{id}")
public Product findById(@PathVariable Long id) {
return products.get(id);
}curl -i http://localhost:8117/api/products/1HTTP/1.1 200
Content-Type: application/json
Content-Length: 61
Date: Sat, 12 Sep 2026 07:01:33 GMT
{"id":1,"name":"Clean Code","price":32.50,"category":"BOOKS"}The annotation carries no name. Spring paired {id} with the parameter because the parameter is called id — which only works because that name survived compilation.
How Spring knows the parameter name: the -parameters flag
A .class file does not record method parameter names unless the compiler is given -parameters, which adds a MethodParameters attribute that reflection can read. You never passed that flag: the Spring Boot Gradle plugin adds it to every JavaCompile task. Gradle's debug log shows what javac received; here the line is trimmed to the arguments, with paths shortened and the classpath and source files elided:
./gradlew compileJava --rerun --debug | grep "Compiler arguments"Compiler arguments: -source 21 -target 21 -d <project>/build/classes/java/main -encoding UTF-8 -h <project>/build/generated/sources/headers/java/main -g -sourcepath "" -proc:none -s <project>/build/generated/sources/annotationProcessor/java/main -XDuseUnsharedTable=true -classpath <classpath> -parameters <source files>The flag comes from the plugin's JavaPluginAction class, where javap finds it as a constant:
javap -p -constants -cp spring-boot-gradle-plugin-4.1.1.jar org.springframework.boot.gradle.plugin.JavaPluginAction | grep -i parameters private static final java.lang.String PARAMETERS_COMPILER_ARG = "-parameters";
private void configureParametersCompilerArg(org.gradle.api.Project);
private static void lambda$configureParametersCompilerArg$0(org.gradle.api.tasks.compile.JavaCompile);The compiled controller carries the name. This is the part of javap -v -cp build/classes/java/main com.example.demo.product.ProductController that belongs to findById:
LocalVariableTable:
Start Length Slot Name Signature
0 14 0 this Lcom/example/demo/product/ProductController;
0 14 1 id Ljava/lang/Long;
MethodParameters:
Name Flags
idTwo attributes hold the name id. LocalVariableTable is debug information, present because Gradle also passes -g; MethodParameters exists only because of -parameters. Taking the flag away shows which one Spring reads. This block removes it after the Boot plugin has added it:
tasks.named('test') {
useJUnitPlatform()
}
tasks.withType(JavaCompile).configureEach {
options.compilerArgs.remove('-parameters')
} After a rebuild, the javac arguments end at -classpath <classpath> with no -parameters, and javap shows the same LocalVariableTable for findById, id included, but no MethodParameters. The application starts without a complaint. The first request to the method fails:
curl -i http://localhost:8117/api/products/1HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:05:07 GMT
Connection: close
{"timestamp":"2026-09-12T07:05:07.283Z","status":500,"error":"Internal Server Error","path":"/api/products/1"}2026-09-12T14:05:07.280+07:00 ERROR 37195 --- [demo] [nio-8117-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalArgumentException: Name for argument of type [java.lang.Long] not specified, and parameter name information not available via reflection. Ensure that the compiler uses the '-parameters' flag.] with root cause
java.lang.IllegalArgumentException: Name for argument of type [java.lang.Long] not specified, and parameter name information not available via reflection. Ensure that the compiler uses the '-parameters' flag.
at org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver.updateNamedValueInfo(AbstractNamedValueMethodArgumentResolver.java:184) ~[spring-web-7.0.9.jar!/:7.0.9]The debug information was right there, and Spring did not use it. Up to Spring Framework 6.0, Spring fell back to reading parameter names from the LocalVariableTable, which is why older tutorials say the flag does not matter for a normal debug build. Spring Framework 6.1 removed that fallback, and 7.0.9 behaves exactly as above: only MethodParameters counts.
The exception comes from AbstractNamedValueMethodArgumentResolver, which javap shows to be the base class of the resolvers behind @PathVariable, @RequestParam, @RequestHeader and @CookieValue. All four therefore depend on the flag whenever the annotation gives no name. In the same build, a @RequestParam String q parameter failed with Name for argument of type [java.lang.String] not specified, while POST /api/products still returned 201, because @RequestBody looks nothing up by name. Naming the value in the annotation works with or without the flag; compiled without it, this method answered id=1, q=coffee for /lab/explicit/1?q=coffee:
@GetMapping("/explicit/{id}")
public String explicit(@PathVariable("id") Long id, @RequestParam("q") String q) {
return "id=" + id + ", q=" + q;
}Several path variables and explicit names
@GetMapping("/categories/{category}/products/{id}")
public String productInCategory(@PathVariable Category category,
@PathVariable("id") Long productId) {
return "category=" + category + ", productId=" + productId;
}curl http://localhost:8117/lab/categories/BOOKS/products/1category=BOOKS, productId=1A pattern can hold any number of variables, and @PathVariable("id") is how a parameter gets a different name from its variable. When the names disagree and nothing reconciles them, the result is not a 400:
@GetMapping("/reviews/{reviewId}")
public String review(@PathVariable Long id) {
return "id=" + id;
}curl -i http://localhost:8117/lab/reviews/7HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:01:33 GMT
Connection: close
{"timestamp":"2026-09-12T07:01:33.326Z","status":500,"error":"Internal Server Error","path":"/lab/reviews/7"}2026-09-12T14:01:33.326+07:00 WARN 34784 --- [demo] [nio-8117-exec-9] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MissingPathVariableException: Required URI template variable 'id' for method parameter type Long is not present]The client sent a perfectly valid URL; the mapping and the method disagree about a name. That is a bug in the controller, so Spring answers 500 instead of blaming the request, even though it logs the problem only as a WARN.
Optional path variables: required = false and Optional
@PathVariable is required by default, and required = false looks like a way to make a segment optional. On a method mapped to a single pattern it changes nothing: the method only runs when the pattern matched, so the variable is always there. It matters when one method maps two patterns, one with the variable and one without:
@GetMapping({"/stock", "/stock/{warehouse}"})
public String stock(@PathVariable(required = false) String warehouse) {
return "warehouse=" + warehouse;
}
@GetMapping({"/shipping", "/shipping/{zone}"})
public String shipping(@PathVariable Optional<String> zone) {
return "zone=" + zone;
}
@GetMapping({"/returns", "/returns/{reason}"})
public String returns(@PathVariable String reason) {
return "reason=" + reason;
}| Request | Result |
|---|---|
GET /lab/stock | warehouse=null |
GET /lab/stock/hanoi | warehouse=hanoi |
GET /lab/shipping | zone=Optional.empty |
GET /lab/shipping/north | zone=Optional[north] |
GET /lab/returns | 500, MissingPathVariableException: Required URI template variable 'reason' for method parameter type String is not present |
GET /lab/returns/damaged | reason=damaged |
An optional path variable makes one method serve two URLs that usually mean different things. Two methods with one pattern each are easier to read, and they avoid the last trap in the table: a required variable under a pattern that does not declare it is a 500, not a 404.
Converting path variables to Long, UUID and enums
Every path variable arrives as text. Spring converts it to the parameter type, and when conversion fails the client gets a 400:
curl -i http://localhost:8117/api/products/abcHTTP/1.1 400
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:01:33 GMT
Connection: close
{"timestamp":"2026-09-12T07:01:33.226Z","status":400,"error":"Bad Request","path":"/api/products/abc"}2026-09-12T14:01:33.223+07:00 WARN 34784 --- [demo] [io-8117-exec-10] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.method.annotation.MethodArgumentTypeMismatchException: Method parameter 'id': Failed to convert value of type 'java.lang.String' to required type 'java.lang.Long'; For input string: "abc"]The body is Spring Boot's default error response: status, reason phrase and path, and nothing about which parameter failed or why. The explanation is only in the server log. Shaping that error body is covered later in this chapter.
That 400 depends on the plain /{id} pattern used here. A pattern restricted to digits, like the one in the previous article, never lets abc reach the method, so no conversion is attempted at all.
UUID and enum parameters convert the same way:
@GetMapping("/orders/{orderId}")
public String order(@PathVariable UUID orderId) {
return "orderId=" + orderId + ", version=" + orderId.version();
}| Request | Status | Result, or the reason in the log |
|---|---|---|
GET /lab/orders/3f2b8c1e-9d4a-4e7b-a1c2-5d6e7f8a9b0c | 200 | orderId=3f2b8c1e-9d4a-4e7b-a1c2-5d6e7f8a9b0c, version=4 |
GET /lab/orders/12345 | 400 | Invalid UUID string: 12345 |
GET /lab/categories/BOOKS/products/1 | 200 | category=BOOKS, productId=1 |
GET /lab/categories/books/products/1 | 400 | Failed to convert from type [java.lang.String] to type [@org.springframework.web.bind.annotation.PathVariable com.example.demo.product.Category] for value [books] |
Enum conversion matches the constant name exactly, so books is not BOOKS.
@RequestParam: query string and form fields
@RequestParam reads a named parameter from the query string. One method shows the four ways to declare one:
@GetMapping("/search")
public String search(@RequestParam String q,
@RequestParam(defaultValue = "name") String sort,
@RequestParam(required = false) BigDecimal minPrice,
@RequestParam Optional<BigDecimal> maxPrice) {
return "q=" + q + ", sort=" + sort + ", minPrice=" + minPrice + ", maxPrice=" + maxPrice;
}curl "http://localhost:8117/lab/search?q=coffee"q=coffee, sort=name, minPrice=null, maxPrice=Optional.emptycurl "http://localhost:8117/lab/search?q=coffee&sort=price&minPrice=10&maxPrice=20"q=coffee, sort=price, minPrice=10, maxPrice=Optional[20]Required by default: the 400 for a missing parameter
q has no attributes at all, which means required:
curl -i http://localhost:8117/lab/searchHTTP/1.1 400
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:01:33 GMT
Connection: close
{"timestamp":"2026-09-12T07:01:33.335Z","status":400,"error":"Bad Request","path":"/lab/search"}2026-09-12T14:01:33.335+07:00 WARN 34784 --- [demo] [io-8117-exec-10] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MissingServletRequestParameterException: Required request parameter 'q' for method parameter type String is not present]The other three parameters are the ways out, and each hands the method something different when the parameter is absent:
| Declaration | When the parameter is absent |
|---|---|
@RequestParam String q | 400, MissingServletRequestParameterException |
@RequestParam(defaultValue = "name") String sort | "name" |
@RequestParam(required = false) BigDecimal minPrice | null |
@RequestParam Optional<BigDecimal> maxPrice | Optional.empty |
defaultValue makes the parameter optional on its own; there is no need to add required = false next to it. Absent is not the same as empty, though. ?q= satisfies the required q with an empty string and returned q=, sort=name, minPrice=null, maxPrice=Optional.empty, while ?q=coffee&minPrice= gave minPrice=null, because an empty value converts to null for a BigDecimal. A value that cannot be converted is a 400, exactly as for a path variable: ?q=coffee&minPrice=cheap logged Method parameter 'minPrice': Failed to convert value of type 'java.lang.String' to required type 'java.math.BigDecimal'; Character c is neither a decimal digit number, decimal point, nor "e" notation exponential mark.
A missing primitive parameter is a 500
required = false on a primitive type compiles, starts, and fails on the first request that leaves the parameter out:
@GetMapping("/page")
public String page(@RequestParam(required = false) int limit) {
return "limit=" + limit;
}curl -i http://localhost:8117/lab/pageHTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:01:33 GMT
Connection: close
{"timestamp":"2026-09-12T07:01:33.392Z","status":500,"error":"Internal Server Error","path":"/lab/page"}2026-09-12T14:01:33.390+07:00 ERROR 34784 --- [demo] [io-8117-exec-10] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalStateException: Optional int parameter 'limit' is present but cannot be translated into a null value due to being declared as a primitive type. Consider declaring it as object wrapper for the corresponding primitive type.] with root cause
java.lang.IllegalStateException: Optional int parameter 'limit' is present but cannot be translated into a null value due to being declared as a primitive type. Consider declaring it as object wrapper for the corresponding primitive type.
at org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver.handleNullValue(AbstractNamedValueMethodArgumentResolver.java:266) ~[spring-web-7.0.9.jar!/:7.0.9]Despite the wording, nothing called limit was sent. A missing optional parameter becomes null, and null cannot be stored in an int. ?limit=5 works and ?limit=abc is a normal 400, so the bug only shows up for the one request nobody tried. Declare Integer limit, or give the parameter a default, which also covers an empty value:
@GetMapping("/page-default")
public String pageDefault(@RequestParam(defaultValue = "20") int limit) {
return "limit=" + limit;
}Both /lab/page-default and /lab/page-default?limit= answered limit=20.
Several values: List, Map and MultiValueMap
A parameter that repeats binds to a List:
@GetMapping("/tags")
public String tags(@RequestParam List<String> tag) {
return "tag=" + tag + ", size=" + tag.size();
}| Query string | Result |
|---|---|
?tag=java&tag=spring | tag=[java, spring], size=2 |
?tag=java,spring | tag=[java, spring], size=2 |
?tag=java,spring&tag=boot | tag=[java,spring, boot], size=2 |
| none | 400, Required request parameter 'tag' for method parameter type List is not present |
The third row is the trap. A comma-separated value is split only when the parameter appears once; when the name repeats, every value is kept whole, commas included. Pick one style for an API and document it.
To take every parameter without naming them, bind a map:
@GetMapping("/params")
public String params(@RequestParam Map<String, String> single,
@RequestParam MultiValueMap<String, String> multi) {
return "Map=" + single + "\nMultiValueMap=" + multi + "\n";
}curl "http://localhost:8117/lab/params?q=coffee&tag=java&tag=spring"Map={q=coffee, tag=java}
MultiValueMap={q=[coffee], tag=[java, spring]}A Map<String, String> keeps only the first value of a repeated name; a MultiValueMap keeps all of them.
Enum parameters and case sensitivity
@GetMapping("/by-category")
public String byCategory(@RequestParam Category category) {
return "category=" + category;
}| Query string | Status | Result, or the reason in the log |
|---|---|---|
?category=BOOKS | 200 | category=BOOKS |
?category=books | 400 | Failed to convert from type [java.lang.String] to type [@org.springframework.web.bind.annotation.RequestParam com.example.demo.product.Category] for value [books] |
?category= | 400 | Required request parameter 'category' for method parameter type Category is present but converted to null |
The rule is the same as in the path: the constant name, in exactly its case. The last row shows how an empty value for a required enum is reported. It converts to null, and a required value that ends up null counts as missing.
Reading form fields with @RequestParam
An HTML form, or curl -d without a Content-Type, sends its fields as an application/x-www-form-urlencoded body. @RequestParam reads those too:
@PostMapping("/form")
public String form(@RequestParam String name, @RequestParam BigDecimal price) {
return "name=" + name + ", price=" + price;
}
@PutMapping("/form")
public String formPut(@RequestParam String name, @RequestParam BigDecimal price) {
return "PUT name=" + name + ", price=" + price;
}curl -X POST http://localhost:8117/lab/form -d "name=Desk+Lamp&price=24.00"name=Desk Lamp, price=24.00To @RequestParam, the query string and the form body are one set of parameters. POST /lab/form?name=Desk+Lamp with the body price=24.00 gave the same result, and when a name appears in both places the values are joined: ?name=FromQuery plus name=FromBody in the body arrived as name=FromQuery,FromBody.
For POST, the servlet container reads the form body itself. For other methods, Spring's FormContentFilter, which Spring Boot registers, does the reading: a PUT with the same body answered PUT name=Desk Lamp, price=24.00, and after a restart with --spring.mvc.formcontent.filter.enabled=false the same PUT became a 400 for a missing name, while POST kept working.
@RequestHeader and @CookieValue
@RequestHeader takes the header name and supports the same required and defaultValue attributes:
@GetMapping("/headers")
public String headers(@RequestHeader("X-Request-Id") String requestId,
@RequestHeader(name = "Accept-Language", defaultValue = "en") String language) {
return "requestId=" + requestId + ", language=" + language;
}curl http://localhost:8117/lab/headers -H "X-Request-Id: 7f3a9c"requestId=7f3a9c, language=encurl http://localhost:8117/lab/headers -H "x-request-id: 7f3a9c" -H "Accept-Language: vi-VN"requestId=7f3a9c, language=vi-VNThe second request spelled the header in lower case and still matched: HTTP header names are case-insensitive, and so is the lookup. Leaving the required header out is a 400 that logged MissingRequestHeaderException: Required request header 'X-Request-Id' for method parameter type String is not present. Conversion applies to headers too:
@GetMapping("/header-number")
public String headerNumber(@RequestHeader("X-Page") int page) {
return "page=" + page;
}X-Page: 3 gave page=3; X-Page: abc was a 400 that logged MethodArgumentTypeMismatchException: Method parameter 'X-Page': Failed to convert value of type 'java.lang.String' to required type 'int'; For input string: "abc".
Reading every header with HttpHeaders
With no name, @RequestHeader on an HttpHeaders parameter receives every header:
@GetMapping("/all-headers")
public String allHeaders(@RequestHeader HttpHeaders headers) {
return "size=" + headers.size()
+ ", names=" + headers.headerNames()
+ ", user-agent=" + headers.getFirst("user-agent")
+ ", accept=" + headers.getAccept() + "\n";
}curl http://localhost:8117/lab/all-headers -H "X-Request-Id: 7f3a9c"size=4, names=[Host, User-Agent, Accept, X-Request-Id], user-agent=curl/8.7.1, accept=[*/*]getFirst("user-agent") found User-Agent, and typed getters such as getAccept() parse a value into objects. In Spring Framework 7, HttpHeaders no longer implements MultiValueMap, so you iterate it with headerNames() and headerSet(). Compare the unannotated HttpHeaders parameter earlier, which received none of these headers.
Cookies with @CookieValue
@GetMapping("/theme")
public String theme(@CookieValue("theme") String theme) {
return "theme=" + theme;
}curl http://localhost:8117/lab/theme -b "theme=dark"theme=dark@CookieValue has the same required and defaultValue attributes. Without the cookie the request was a 400 that logged MissingRequestCookieException: Required cookie 'theme' for method parameter type String is not present, and @CookieValue("visits") int visits sent visits=abc was a 400 for the failed conversion.
@RequestBody: JSON into a Java record
@RequestBody hands the whole body to a message converter, which turns it into the parameter type. The catalogue's create method already uses it:
curl -i -X POST http://localhost:8117/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Desk Lamp","price":24.00,"category":"ELECTRONICS"}'HTTP/1.1 201
Content-Type: application/json
Content-Length: 66
Date: Sat, 12 Sep 2026 07:01:33 GMT
{"id":4,"name":"Desk Lamp","price":24.00,"category":"ELECTRONICS"}Jackson, which the web starter brings along, turned the JSON into a ProductRequest. How that mapping works and how to control it is the next article. The question here is what happens when the body is not what the method expects.
Missing Content-Type, empty body and malformed JSON
Leaving out -H "Content-Type: application/json" is the most common mistake, because curl -d then labels the body as a form:
curl -i -X POST http://localhost:8117/api/products \
-d '{"name":"Desk Lamp","price":24.00,"category":"ELECTRONICS"}'HTTP/1.1 415
Accept: application/json, application/*+json
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:01:33 GMT
{"timestamp":"2026-09-12T07:01:33.639Z","status":415,"error":"Unsupported Media Type","path":"/api/products"}2026-09-12T14:01:33.638+07:00 WARN 34784 --- [demo] [nio-8117-exec-6] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpMediaTypeNotSupportedException: Content-Type 'application/x-www-form-urlencoded;charset=UTF-8' is not supported]415 Unsupported Media Type, and the response's Accept header lists what the endpoint would have read. The JSON itself was fine; no converter reads a form into a ProductRequest. Removing the header entirely with -H "Content-Type:" gives the same 415, logged as Content-Type 'application/octet-stream' is not supported: a body without a type is treated as raw bytes.
The other failures are all 400, each with its own reason in the log:
Sent with Content-Type: application/json | Status | Reason in the log |
|---|---|---|
no body, or -d '' | 400 | HttpMessageNotReadableException: Required request body is missing: public com.example.demo.product.Product com.example.demo.product.ProductController.create(com.example.demo.product.ProductRequest) |
{"name":"Desk Lamp","price":24.00, | 400 | HttpMessageNotReadableException: JSON parse error: Unexpected end-of-input within/between Object entries |
{"name":"Desk Lamp","price":"cheap","category":"ELECTRONICS"} | 400 | HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.math.BigDecimal` from String "cheap": not a valid representation |
required = false on the body turns "no body" into null instead of a 400:
@PostMapping("/optional-body")
public String optionalBody(@RequestBody(required = false) ProductRequest request) {
return "request=" + request;
}With Content-Type: application/json and no body it answered request=null; with the Desk Lamp JSON from above, request=ProductRequest[name=Desk Lamp, price=24.00, category=ELECTRONICS]. Checking that the fields inside a body are valid — a blank name, a negative price — is a separate step with Bean Validation, two articles from now.
ResponseEntity: status, headers and body in one return value
A method that returns a Product lets Spring choose the status — 200, or whatever @ResponseStatus fixes — and writes the object as the body. ResponseEntity<T> wraps the body together with a status and headers, so the method decides all three while it runs. Its static builders cover the common cases:
| Builder | Status | Use it for |
|---|---|---|
ResponseEntity.ok(body) | 200 | the same as returning body; ok().header(…).body(body) when a header is needed too |
ResponseEntity.created(uri).body(body) | 201 | a new resource; uri becomes the Location header |
ResponseEntity.noContent().build() | 204 | success with nothing to send back |
ResponseEntity.notFound().build() | 404 | a resource that does not exist |
ResponseEntity.status(HttpStatus.ACCEPTED).body(body) | 202, or any status | a status without a shortcut of its own |
ResponseEntity.of(optional) | 200 or 404 | a lookup that may find nothing |
status() with a custom header, in one chain:
@PostMapping("/imports")
public ResponseEntity<Map<String, String>> startImport() {
return ResponseEntity.status(HttpStatus.ACCEPTED)
.header("X-Import-Id", "imp-42")
.body(Map.of("status", "QUEUED"));
}curl -i -X POST http://localhost:8117/lab/importsHTTP/1.1 202
X-Import-Id: imp-42
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:01:33 GMT
{"status":"QUEUED"}header(name, values…) can be called as many times as needed. Content-Type, Transfer-Encoding and Date were not set by the method; the JSON converter and the server added them.
ResponseEntity.of(Optional) saves the if in a lookup:
@GetMapping("/featured")
public ResponseEntity<String> featured(@RequestParam(defaultValue = "false") boolean empty) {
Optional<String> featured = empty ? Optional.empty() : Optional.of("Clean Code");
return ResponseEntity.of(featured);
}curl -i "http://localhost:8117/lab/featured?empty=true"HTTP/1.1 404
Content-Length: 0
Date: Sat, 12 Sep 2026 07:01:33 GMTWithout ?empty=true the same method answered 200 with the body Clean Code. The 404 has an empty body rather than Spring Boot's JSON error response, because nothing failed: the method simply returned a 404.
ResponseEntity extends HttpEntity, which holds only headers and a body. RequestEntity, its counterpart for requests, extends it as well and is mostly used with HTTP clients.
@ResponseStatus or ResponseEntity?
@ResponseStatus fixes the status when the code is written; ResponseEntity decides it when the request runs. The delete method at the start of the article shows why that matters: @ResponseStatus(HttpStatus.NO_CONTENT) answered 204 for a product that never existed, because an annotation cannot know what the method found.
Putting both on one method does not combine them:
@GetMapping("/which-status")
@ResponseStatus(HttpStatus.CREATED)
public ResponseEntity<String> whichStatus() {
return ResponseEntity.ok("ResponseEntity said 200");
}curl -i http://localhost:8117/lab/which-statusHTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 23
Date: Sat, 12 Sep 2026 07:01:33 GMT
ResponseEntity said 200The ResponseEntity won, without a warning. Choose one per method:
- Return the object, with
@ResponseStatusonly when the status is not 200, when every successful call ends the same way: a list, or a create that needs noLocation. - Return
ResponseEntitywhen the status or the headers depend on what happened: found or not found, deleted or never there, created with aLocationthat contains the new id.
Upgrading the product catalogue
The catalogue now gets what the first section showed it lacked: a real 404, a 204 only when something was deleted, a 201 with a Location header, and filters on the list. The changes against the controller from the start of the article:
package com.example.demo.product;
import java.math.BigDecimal;
import java.net.URI;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final Map<Long, Product> products = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
public ProductController() {
add(new ProductRequest("Clean Code", new BigDecimal("32.50"), Category.BOOKS));
add(new ProductRequest("Mechanical Keyboard", new BigDecimal("89.90"), Category.ELECTRONICS));
add(new ProductRequest("Arabica Coffee Beans", new BigDecimal("14.20"), Category.GROCERY));
}
@GetMapping
public List<Product> findAll() {
public List<Product> findAll(@RequestParam(required = false) Category category,
@RequestParam(required = false) BigDecimal minPrice) {
return products.values().stream()
.filter(p -> category == null || p.category() == category)
.filter(p -> minPrice == null || p.price().compareTo(minPrice) >= 0)
.sorted(Comparator.comparing(Product::id))
.toList();
}
@GetMapping("/{id}")
public Product findById(@PathVariable Long id) {
return products.get(id);
public ResponseEntity<Product> findById(@PathVariable Long id) {
return ResponseEntity.of(Optional.ofNullable(products.get(id)));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Product create(@RequestBody ProductRequest request) {
return add(request);
public ResponseEntity<Product> create(@RequestBody ProductRequest request,
@RequestHeader("X-Request-Id") Optional<String> requestId) {
Product product = add(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(product.id())
.toUri();
return ResponseEntity.created(location)
.header("X-Request-Id", requestId.orElseGet(() -> UUID.randomUUID().toString()))
.body(product);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
products.remove(id);
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (products.remove(id) == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.noContent().build();
}
private Product add(ProductRequest request) {
long id = sequence.incrementAndGet();
Product product = new Product(id, request.name(), request.price(), request.category());
products.put(id, product);
return product;
}
}What each change does:
findAlldeclares both filtersrequired = false, soGET /api/productswith no parameters still lists everything, and each filter applies only when its value is notnull.categoryis the enum, so?category=booksis a 400, as in the enum section.findByIdusesResponseEntity.of, which turns an emptyOptionalinto a 404.createaccepts an optionalX-Request-Idheader and returns it in the response, generating a UUID when the client sent none, so a client can match a response to the request it sent.deleteuses the return value ofMap.remove, which isnullwhen there was nothing to remove.
POST returns 201 Created with a Location header
curl -i -X POST http://localhost:8117/api/products \
-H "Content-Type: application/json" \
-H "X-Request-Id: 7f3a9c" \
-d '{"name":"Desk Lamp","price":24.00,"category":"ELECTRONICS"}'HTTP/1.1 201
Location: http://localhost:8117/api/products/4
X-Request-Id: 7f3a9c
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:05:08 GMT
{"id":4,"name":"Desk Lamp","price":24.00,"category":"ELECTRONICS"}
ServletUriComponentsBuilder.fromCurrentRequest() starts from the URL the client called, http://localhost:8117/api/products; path("/{id}") appends a template segment, buildAndExpand(product.id()) fills it with 4, and toUri() produces the URI that created() writes into Location. A second product, {"name":"Green Tea","price":6.80,"category":"GROCERY"} sent without an X-Request-Id, got a generated one. The first lines of that response:
HTTP/1.1 201
Location: http://localhost:8117/api/products/5
X-Request-Id: 2e37d6d3-49bb-4883-bdb7-97129cdf42cafromCurrentRequest() copies the query string as well. A third product, {"name":"Refactoring","price":41.00,"category":"BOOKS"}, posted to /api/products?source=import, came back with Location: http://localhost:8117/api/products/6?source=import — a query parameter that has nothing to do with product 6. When a create endpoint accepts query parameters, use fromCurrentRequestUri(), which keeps the scheme, host, port and path and drops the query. Both side by side, called for POST /lab/uris?source=import:
@PostMapping("/uris")
public String uris() {
return "fromCurrentRequest() -> "
+ ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(6).toUri()
+ "\nfromCurrentRequestUri() -> "
+ ServletUriComponentsBuilder.fromCurrentRequestUri().path("/{id}").buildAndExpand(6).toUri()
+ "\n";
}fromCurrentRequest() -> http://localhost:8117/lab/uris/6?source=import
fromCurrentRequestUri() -> http://localhost:8117/lab/uris/6GET returns 404 for a missing product
curl -i http://localhost:8117/api/products/99HTTP/1.1 404
Content-Length: 0
Date: Sat, 12 Sep 2026 07:05:08 GMTGET /api/products/4 still answers 200 with the Desk Lamp.
DELETE returns 204, or 404 when there is nothing to delete
curl -i -X DELETE http://localhost:8117/api/products/4HTTP/1.1 204
Date: Sat, 12 Sep 2026 07:05:08 GMTThe same command a second time:
HTTP/1.1 404
Content-Length: 0
Date: Sat, 12 Sep 2026 07:05:08 GMTGET filters the list by category and minimum price
After the three creates and the delete above, the catalogue holds products 1, 2, 3, 5 and 6.
curl -i "http://localhost:8117/api/products?category=BOOKS"HTTP/1.1 200
Content-Type: application/json
Content-Length: 126
Date: Sat, 12 Sep 2026 07:05:08 GMT
[{"id":1,"name":"Clean Code","price":32.50,"category":"BOOKS"},{"id":6,"name":"Refactoring","price":41.00,"category":"BOOKS"}]curl "http://localhost:8117/api/products?minPrice=20"[{"id":1,"name":"Clean Code","price":32.50,"category":"BOOKS"},{"id":2,"name":"Mechanical Keyboard","price":89.90,"category":"ELECTRONICS"},{"id":6,"name":"Refactoring","price":41.00,"category":"BOOKS"}]curl "http://localhost:8117/api/products?category=ELECTRONICS&minPrice=50"[{"id":2,"name":"Mechanical Keyboard","price":89.90,"category":"ELECTRONICS"}]?minPrice=cheap is a 400, with the same MethodArgumentTypeMismatchException as in the @RequestParam section.
Request binding annotations compared
Every cell below comes from the runs in this article:
| Annotation | Value comes from | Required by default | Value missing | Value cannot be converted |
|---|---|---|---|---|
@PathVariable | a {name} segment of the mapping pattern | yes | 500, MissingPathVariableException: the mapping does not declare the variable | 400, MethodArgumentTypeMismatchException |
@RequestParam | the query string, and the fields of a form body | yes | 400, MissingServletRequestParameterException | 400, MethodArgumentTypeMismatchException |
@RequestHeader | a request header, name matched ignoring case | yes | 400, MissingRequestHeaderException | 400, MethodArgumentTypeMismatchException |
@CookieValue | a cookie | yes | 400, MissingRequestCookieException | 400, MethodArgumentTypeMismatchException |
@RequestBody | the whole body, read by the message converter that accepts its Content-Type | yes | 400, HttpMessageNotReadableException | 400, HttpMessageNotReadableException; 415 when no converter accepts the Content-Type |
Two more 500s are declaration mistakes rather than request problems, so they have no column: a primitive declared with required = false and left out, and any name-based annotation without an explicit name in a class compiled without -parameters.
FAQ
Why does Spring say "parameter name information not available via reflection"?
The class was compiled without -parameters, so a @PathVariable, @RequestParam, @RequestHeader or @CookieValue without an explicit name has nothing to go by. Since Spring Framework 6.1 the debug information in LocalVariableTable is no longer used as a fallback. The Spring Boot Gradle plugin adds the flag to every JavaCompile task, so restore it wherever a build drops it, or name every value, as in @PathVariable("id").
What is the difference between @PathVariable and @RequestParam?
@PathVariable reads a path segment that the mapping declares as {name}, as in /api/products/42. @RequestParam reads a named value from the query string or a form body, as in /api/products?category=BOOKS. Both are required by default and both give a 400 for a value that cannot be converted, but a missing path variable is a 500, because it means the mapping itself is wrong.
Why does my @RequestBody endpoint return 415 Unsupported Media Type?
The request's Content-Type is not one that a message converter reads into your type. curl -d sends application/x-www-form-urlencoded unless told otherwise, and with no header at all Spring assumes application/octet-stream. Send Content-Type: application/json; the Accept header of the 415 response lists the types the endpoint accepts.
How do I make a @RequestParam optional?
Give it a defaultValue, declare required = false and handle null, or use Optional<T>. Do not combine required = false with a primitive such as int: a missing value becomes null, which an int cannot hold, and the request fails with a 500 IllegalStateException. Use Integer, or a defaultValue.
Should a Spring Boot controller return ResponseEntity or the object?
Return the object when a successful call always has the same status, adding @ResponseStatus if that status is not 200. Return ResponseEntity when the status or the headers depend on the outcome, such as 404 for a missing resource or 201 with a Location. Do not put both on one method: the status in the ResponseEntity silently wins.
How do I read all query parameters or all headers at once?
@RequestParam Map<String, String> gives every query parameter with its first value, and @RequestParam MultiValueMap<String, String> keeps repeated values. For headers, use @RequestHeader HttpHeaders. Without the annotation an HttpHeaders parameter arrives empty.
Conclusion
A handler method's parameters declare where each value lives. @PathVariable takes a segment the pattern names, @RequestParam a query or form parameter, @RequestHeader and @CookieValue a header or a cookie, and @RequestBody the whole body through a message converter. All of them are required by default and convert text to the declared type, so a missing or malformed value gives the client a 400 — except where the mistake is in the code. A path variable the mapping does not declare, a missing primitive declared required = false, and a name-based annotation compiled without -parameters are all 500s. On the way out, ResponseEntity lets the method choose status, headers and body at runtime, which is what turned the catalogue's fixed 200s and 204s into a 201 with a Location, a 404 and a 204 that mean what they say.
The next article is about the body itself: JSON with Jackson and DTOs — how serialisation and deserialisation work, what changes with Jackson 3 in Spring Boot 4, why request and response types should be kept separate from entities, and how MapStruct maps between them.