@PutMapping and @PatchMapping
Map HTTP PUT (replace a whole resource) and PATCH (change some fields) requests to handler methods.
@PutMapping("/{id}") @PatchMapping(path = "/{id}", consumes = "application/merge-patch+json")- Added in Spring Boot Spring Framework 4.3
Parameters
| Parameter | Type | Description |
|---|---|---|
$valueoptional | String[]= {} | Alias for path. One or more URI patterns such as "/{id}". Combined with any class-level @RequestMapping path. |
$pathoptional | String[]= {} | The URI patterns this method handles. Same as value; use whichever reads better. |
$paramsoptional | String[]= {} | Narrows the mapping by query parameters: "type", "!type" or "type=pdf". |
$headersoptional | String[]= {} | Narrows the mapping by request headers, for example "X-Feature=beta". |
$consumesoptional | String[]= {} | Media types the request body must have (checked against Content-Type). A mismatch gives 415. |
$producesoptional | String[]= {} | Media types the method can write (checked against Accept). No overlap gives 406. |
$versionoptional | String= "" | API version this mapping serves, such as "1.1" or "1.1+". Needs API versioning configured. |
$nameoptional | String= "" | A name for the mapping, used by MvcUriComponentsBuilder to build links. Rarely needed. |
Return value
n/aShortcuts for @RequestMapping(method = RequestMethod.PUT) and @RequestMapping(method = RequestMethod.PATCH). Both share the same attributes.
Errors
Same as other mappings: 400 for unreadable bodies, 405 for the wrong method, 415 for an unsupported Content-Type.
Changelog
- PHP Spring Framework 7.0Added the version attribute for first-class API versioning.
These two annotations map the update verbs. They take exactly the same attributes as @GetMapping; the difference is in what the HTTP methods promise.
| PUT | PATCH | |
|---|---|---|
| Meaning | Replace the resource with this body | Apply these changes |
| Body | The full representation | Only the fields to change |
| Idempotent | Yes | Not guaranteed |
PUT replaces
A PUT body is the whole resource. A field the client leaves out is not "unchanged": it is set to its empty value. So the request record for PUT should require every field, with validation to match. Because PUT is idempotent, clients and proxies may retry it safely, which also makes it a good fit for create-or-replace on a key the client chooses.
PATCH changes some fields
The simplest PATCH uses a record with nullable fields and treats null as "leave alone". That is enough for most forms. Its limit is that a client cannot clear a field, because absent and null look the same. When clearing matters, use JSON Merge Patch (application/merge-patch+json) and bind the body to a Map so you can tell a missing key from an explicit null.
@PutMapping Product replace(@PathVariable long id, @Valid @RequestBody ReplaceProduct body)
@PatchMapping Product patch(@PathVariable long id, @RequestBody PatchProduct body)Lost updates
Two users load the same record, both edit, both save: the second silently overwrites the first. For edit screens, send the version as an ETag, require If-Match on PUT and PATCH, and return 412 Precondition Failed when it does not match. With JPA, @Version gives you the number to use.
Mistakes people make
- Treating PUT as a partial update. Clients written against the HTTP specification send the full object and are surprised when other fields survive, or the reverse.
- Copying a PATCH body onto an entity with a generic bean-copy utility. Nulls overwrite real data and fields such as
idbecome writable. - Validating a PATCH record with
@NotBlank. Every field is optional by design; validate the merged result instead.
Example
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.PositiveOrZero;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PatchMapping;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
record Product(long id, String name, String description, int stock) {}
// PUT: every field is required, because the whole resource is replaced.
record ReplaceProduct( String name, String description, int stock) {}
// PATCH: null means "leave unchanged".
record PatchProduct(String name, String description, Integer stock) {}
("/api/products/{id}")
public class ProductWriteController {
private final Map<Long, Product> store = new ConcurrentHashMap<>(Map.of(1L, new Product(1, "Kettle", "1.7 litre", 5)));
Product replace( long id, ReplaceProduct body) {
Product updated = new Product(find(id).id(), body.name(), body.description(), body.stock());
store.put(id, updated);
return updated;
}
Product patch( long id, PatchProduct body) {
Product current = find(id);
Product updated = new Product(id,
body.name() != null ? body.name() : current.name(),
body.description() != null ? body.description() : current.description(),
body.stock() != null ? body.stock() : current.stock());
// Validate the merged result, not the partial body.
if (updated.name().isBlank() || updated.stock() < 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Name must not be blank and stock must not be negative");
}
store.put(id, updated);
return updated;
}
private Product find(long id) {
Product p = store.get(id);
if (p == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No product " + id);
}
return p;
}
}
When to use it
- Saving a full product edit form with PUT and every field validated
- Toggling one user setting from a mobile app with PATCH
- Preventing two support agents from overwriting each other's ticket edits
Frequently asked questions
Should I support both PUT and PATCH?
Only if clients need both. Many APIs offer PATCH for edit forms and skip PUT. If you offer PUT, honour its full-replacement meaning.
Does Spring support JSON Patch (RFC 6902) operations?
Not out of the box. You can accept application/json-patch+json and apply the operations with a library of your choice, but merge patch is simpler and covers most needs.
More examples
JSON Merge Patch with a Map
Jackson's converter accepts application/*+json types, so merge-patch+json binds like ordinary JSON. Binding to a Map preserves the difference between a key set to null (remove) and a key that is absent (keep). Because a Map accepts any key, check the names against an allow-list first, or a client can write fields you never meant to expose.
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
public class SettingsController {
private static final Set<String> ALLOWED = Set.of("theme", "nickname");
private final Map<String, Object> settings = new HashMap<>(Map.of("theme", "dark", "nickname", "ada"));
// JSON Merge Patch (RFC 7396): a key set to null removes the value,
// a missing key leaves it alone. A Map keeps that difference; a record cannot.
@PatchMapping(path = "/api/users/{id}/settings", consumes = "application/merge-patch+json")
synchronized Map<String, Object> merge(@PathVariable long id, @RequestBody Map<String, Object> patch) {
// A Map accepts any key, so check the names before applying anything.
if (!ALLOWED.containsAll(patch.keySet())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown setting");
}
patch.forEach((key, value) -> {
if (value == null) {
settings.remove(key);
} else {
settings.put(key, value);
}
});
return Map.copyOf(settings);
}
}
Refusing a stale PUT with If-Match
The client sends back the ETag it read. If the stored version has moved on, the method returns 412 and the client reloads. A missing If-Match header gives 400 because the header is required.
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.RestController;
record Document(long id, String text, long version) {}
record DocumentText(String text) {}
@RestController
public class DocumentController {
private Document current = new Document(1, "first draft", 3);
@PutMapping("/api/documents/{id}")
synchronized ResponseEntity<Document> replace(@PathVariable long id,
@RequestHeader("If-Match") String ifMatch,
@RequestBody DocumentText body) {
String currentTag = "\"" + current.version() + "\"";
if (!currentTag.equals(ifMatch)) {
// Someone else saved first: refuse instead of overwriting their change.
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}
current = new Document(id, body.text(), current.version() + 1);
return ResponseEntity.ok().eTag("\"" + current.version() + "\"").body(current);
}
}
PUT as create-or-replace
When the client chooses the identifier, PUT can create the resource as well as replace it. Returning 201 on the first write and 200 afterwards keeps the semantics clear.
import java.net.URI;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.http.ResponseEntity;
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.RestController;
record Preference(String value) {}
@RestController
public class PreferenceController {
private final Map<String, Preference> store = new ConcurrentHashMap<>();
// PUT with a client-chosen key: create (201) or replace (200).
@PutMapping("/api/preferences/{key}")
ResponseEntity<Preference> put(@PathVariable String key, @RequestBody Preference body) {
Preference previous = store.put(key, body);
if (previous == null) {
return ResponseEntity.created(URI.create("/api/preferences/" + key)).body(body);
}
return ResponseEntity.ok(body);
}
}
Discussion