When to Use PUT vs PATCH for Partial Updates
This page is part of the HTTP Method Mapping Guidelines and the broader API Design Fundamentals & Architecture reference.
Choosing the wrong HTTP method for a mutation endpoint is one of the most common sources of contract drift: clients send partial payloads to PUT endpoints and receive 422 errors, or they send full DTOs to PATCH endpoints and silently overwrite fields they never intended to change. The decision between PUT and PATCH is not stylistic — it is a contractual commitment about resource semantics that flows from server implementation through OpenAPI spec into every generated SDK.
Decision Trigger
Use this table to identify which scenario you are in before writing any code or spec:
| Signal | Route to | Reason |
|---|---|---|
| Client sends the entire resource representation | PUT |
RFC 9110 §9.3.4 defines PUT as full replacement |
| Client sends only changed fields | PATCH |
RFC 5789 defines PATCH as partial modification |
| Client needs to clear a field explicitly | PATCH with null |
Merge Patch RFC 7396: null = delete, omission = preserve |
| Client must operate on array elements or move nested values | PATCH with JSON Patch |
RFC 6902 add/remove/move/test operations |
| Operation must be unconditionally idempotent | PUT |
PUT idempotency is guaranteed by RFC 9110; PATCH is not |
| Request updates are retried by the client automatically | PATCH + Idempotency-Key header |
Adds idempotency to PATCH without changing semantics |
The most frequent symptom of a wrong choice is a 422 Unprocessable Content from a PUT endpoint: the server correctly enforces that all required fields must be present, but the client sent only the two fields it wanted to change.
OpenAPI 3.1 Spec Snippet
Declare both endpoints in your spec with distinct request body schemas. The PUT schema carries a required array covering every non-nullable field. The PATCH schema omits required entirely and uses application/merge-patch+json.
# openapi.yaml — OpenAPI 3.1.0
paths:
/users/{id}:
put:
operationId: replaceUser
summary: Replace a user (full representation required)
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id, username, email, status]
properties:
id: { type: string, format: uuid }
username: { type: string, minLength: 3, maxLength: 64 }
email: { type: string, format: email }
status: { type: string, enum: [active, suspended] }
responses:
"200": { description: User replaced }
"422": { description: Missing required fields }
patch:
operationId: updateUser
summary: Partially update a user
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/merge-patch+json:
schema:
type: object
additionalProperties: false
properties:
username: { type: string, minLength: 3, maxLength: 64 }
email: { type: string, format: email, nullable: true }
status: { type: string, enum: [active, suspended] }
responses:
"200": { description: User updated }
"400": { description: Idempotency-Key missing or invalid }
Key decisions in the spec above:
PUTlistsrequired: [id, username, email, status]— any missing field is a contract violation.PATCHdeclaresapplication/merge-patch+json— code generators produce partial-aware serializers.emailonPATCHisnullable: true— clients can explicitly clear it by sending"email": null.additionalProperties: falseon thePATCHschema prevents clients from inventing fields.
Step-by-Step Decision Guide
Step 1 — Identify the update intent
If the client owns the full resource representation and the server should treat the request as authoritative for all fields, use PUT. If the client knows only about the fields it wants to change and expects the server to preserve everything else, use PATCH.
A useful heuristic: if you would have to re-fetch the resource before writing it back to avoid data loss, that is a signal you need PATCH, not PUT.
Step 2 — Choose the PATCH media type
application/merge-patch+json (RFC 7396) — the simpler format. The request body is a JSON object containing only the fields to change. null means “delete this field”; absence means “leave this field alone”. Suitable for flat or moderately nested resources.
application/json-patch+json (RFC 6902) — an array of operation objects (add, remove, replace, move, copy, test). Necessary when targeting specific array elements by index, conditionally applying changes only if a field has an expected value (test), or moving data between paths. More expressive but more complex to generate on the client.
Step 3 — Implement server-side merge logic
TypeScript/Node.js (Express + merge-patch+json):
import express, { Request, Response } from "express";
import { applyMergePatch } from "json-merge-patch"; // npm: json-merge-patch
const router = express.Router();
router.patch("/users/:id", async (req: Request, res: Response) => {
const idempotencyKey = req.headers["idempotency-key"] as string | undefined;
if (!idempotencyKey) {
return res.status(400).json({ error: "Idempotency-Key header is required" });
}
const cached = await cache.get(idempotencyKey);
if (cached) return res.status(200).json(cached);
const existing = await db.users.findById(req.params.id);
if (!existing) return res.status(404).json({ error: "User not found" });
// applyMergePatch returns a new object; never mutates existing
const updated = applyMergePatch(existing, req.body);
await db.users.save(req.params.id, updated);
await cache.set(idempotencyKey, updated, { ttl: 86400 });
return res.status(200).json(updated);
});
Python/FastAPI (Pydantic v2):
from typing import Optional
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel, EmailStr
app = FastAPI()
class UserPatch(BaseModel):
username: Optional[str] = None
email: Optional[EmailStr] = None # None = preserve; explicit null = clear
status: Optional[str] = None
model_config = {"extra": "forbid"} # mirrors additionalProperties: false
@app.patch("/users/{user_id}", response_model=UserDB)
async def update_user(
user_id: str,
patch: UserPatch,
idempotency_key: str = Header(..., alias="Idempotency-Key"),
):
cached = await cache.get(idempotency_key)
if cached:
return cached
user = await db.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# exclude_unset=True: only fields the client explicitly sent
delta = patch.model_dump(exclude_unset=True)
for field, value in delta.items():
if value is None:
# Explicit null: clear the field
setattr(user, field, None)
else:
setattr(user, field, value)
await db.save_user(user)
await cache.set(idempotency_key, user, ttl=86400)
return user
The critical line in both examples is the distinction between “field not sent” and “field sent as null”. In Pydantic, exclude_unset=True on model_dump() drops fields the client never included. In TypeScript, applyMergePatch from the json-merge-patch library handles this automatically per RFC 7396.
Step 4 — Enforce in CI with a Spectral rule
# .spectral.yaml
rules:
put-must-declare-required-fields:
description: PUT endpoints must enumerate all required fields
severity: error
given: "$.paths[*].put.requestBody.content.['application/json'].schema"
then:
field: required
function: truthy
patch-must-not-use-plain-json:
description: PATCH endpoints must use application/merge-patch+json or application/json-patch+json
severity: error
given: "$.paths[*].patch.requestBody.content"
then:
function: schema
functionOptions:
schema:
type: object
not:
required: ["application/json"]
Run this as a required check in your CI pipeline:
npx @stoplight/spectral-cli lint openapi.yaml --ruleset .spectral.yaml --fail-on-warn
RFC Compliance Reference
| RFC / Standard | Clause | Requirement |
|---|---|---|
| RFC 9110 §9.3.4 | PUT | Server MUST replace the target resource’s state with the representation in the request |
| RFC 9110 §9.3.4 | PUT | Idempotent — N identical requests yield the same state as one |
| RFC 5789 §2 | PATCH | Partial update; server applies a patch document to the resource |
| RFC 5789 §2 | PATCH | NOT inherently idempotent — depends on patch document semantics |
| RFC 7396 | Merge Patch | null = delete field; omission = preserve field |
| RFC 6902 | JSON Patch | Array of operation objects; test enables conditional patching |
| RFC 9110 §15.5.1 | 400 | Malformed request (syntax error, missing Content-Type) |
| RFC 9110 §15.5.23 | 422 | Semantically invalid — valid JSON but fails schema constraints |
Idempotency and Safety
PUT is safe in the idempotency sense: the RFC guarantees that sending the same PUT request any number of times leaves the server in the same state. You do not need Idempotency-Key headers for PUT — the method itself is the contract.
PATCH is not inherently idempotent. JSON Merge Patch applied twice to the same resource produces the same result (idempotent), but a JSON Patch add operation on an array appends a new element each time (not idempotent). To make PATCH safe to retry — critical in mobile apps, service meshes, and any client that retries on network failure — implement Idempotency-Key headers and a server-side deduplication store. See Idempotency Key Implementation for the full implementation pattern, including key generation, storage TTL, and cache eviction strategy.
PUT is not “safe” in the RFC 9110 sense (that term is reserved for read-only methods like GET/HEAD/OPTIONS). It is only idempotent — side effects are allowed.
SDK and Codegen Downstream Effect
The requestBody content type in your OpenAPI spec directly controls how generated clients serialize mutation payloads. The difference is significant:
# Generated TypeScript client — PUT endpoint
- async replaceUser(id: string, body: UserCreate): Promise<User> {
+ // body must include ALL fields; undefined fields serialize as null
return this.request("PUT", `/users/${id}`, { body: JSON.stringify(body) });
}
# Generated TypeScript client — PATCH endpoint (after declaring merge-patch+json)
+ async updateUser(id: string, body: Partial<UserUpdate>): Promise<User> {
+ // Only send fields the caller provides; omit the rest
+ return this.request("PATCH", `/users/${id}`, {
+ headers: { "Content-Type": "application/merge-patch+json" },
+ body: JSON.stringify(removeUndefined(body)),
+ });
+ }
Generators that see application/json on a PATCH endpoint produce full-DTO serializers — the same shape as PUT. Generators that see application/merge-patch+json produce Partial<T> wrappers that filter out undefined fields before serialization. The spec content-type key is the single control point.
If you consume a third-party API that uses bare application/json on PATCH, add a client-side filter to prevent serializing untouched fields:
// TypeScript — strip undefined before sending to application/json PATCH
const buildPartialPayload = <T extends Record<string, unknown>>(dto: T): Partial<T> =>
Object.fromEntries(
Object.entries(dto).filter(([, v]) => v !== undefined)
) as Partial<T>;
Common Mistakes
| Mistake | Correct approach |
|---|---|
Sending only changed fields to a PUT endpoint |
Expose a PATCH endpoint; PUT requires the full representation |
Treating PATCH as always idempotent |
Verify your patch format: Merge Patch is idempotent; JSON Patch add on arrays is not; add Idempotency-Key regardless |
Using application/json as the PATCH content type |
Declare application/merge-patch+json or application/json-patch+json so generators produce partial-aware clients |
Ignoring null semantics — treating null the same as omission |
Merge Patch defines null as “delete this field”; use exclude_unset=True (Pydantic) or explicit undefined-filtering (TypeScript) to distinguish |
Omitting required from PUT schemas |
Without required, validators accept partial payloads and silently store incomplete records |
FAQ
Does PATCH guarantee idempotency like PUT?
No. PATCH is not inherently idempotent. JSON Merge Patch (RFC 7396) is idempotent for field overwrites, but JSON Patch (RFC 6902) operations such as add on an array are not. Use Idempotency-Key headers and server-side deduplication to make retries safe regardless of the patch format.
Why does my OpenAPI-generated client send a full object on PATCH?
Most code generators default to full DTO serialization when they see application/json on a PATCH endpoint. Change the content type to application/merge-patch+json in your spec, and configure exclude_unset=True (Pydantic) or undefined-filtering (TypeScript) in your server and client code.
What media type should I use for PATCH?
Use application/merge-patch+json (RFC 7396) for simple field overrides where null means clear and omission means preserve. Use application/json-patch+json (RFC 6902) for complex operations on nested arrays or objects. Avoid bare application/json unless your documentation explicitly defines the merge semantics.
How do I prevent 422 errors when clients send partial payloads to a PUT endpoint?
PUT requires a complete resource representation per RFC 9110 §9.3.4. If you need partial updates, expose a PATCH endpoint. Add a Spectral rule in CI to enforce that PUT schemas carry required constraints, so clients are never misled into sending partial payloads there.
Related
- HTTP Method Mapping Guidelines — parent reference covering all HTTP verb semantics and routing rules
- API Design Fundamentals & Architecture — top-level reference for contract-first REST API design
- Idempotency Key Implementation — how to make PATCH and POST retries safe with deduplication keys
- RFC 7807 Problem+JSON Implementation — standardise the 422 and 400 error responses your PUT/PATCH endpoints return
- HTTP Status Code Mapping — when to return 400 vs 422 vs 415 for method and media-type errors