When to use PUT vs PATCH for partial updates: Debugging & Client Workflows
Partial update routing failures are among the most frequent sources of contract drift in modern API ecosystems. Misaligned expectations between PUT (full resource replacement) and PATCH (targeted field modification) trigger cascading validation errors, silent data loss, and SDK generation mismatches. This guide provides diagnostic workflows, exact OpenAPI configurations, and CI guardrails to enforce RFC-compliant method routing.
Symptom Diagnosis: 400 Bad Request vs 422 Unprocessable Entity
When clients submit partial payloads to a PUT endpoint, the server typically rejects the request. Distinguishing between 400 and 422 dictates the remediation path.
| Status | Root Cause | Diagnostic Signal |
|---|---|---|
400 Bad Request |
Malformed JSON, missing Content-Type, or structural violation of the schema (e.g., type mismatch). |
Accept/Content-Type mismatch; parser throws early. |
422 Unprocessable Entity |
Syntactically valid JSON, but semantically invalid per schema constraints (e.g., missing required fields on PUT). |
Validation layer rejects after parsing; error payload contains field and message. |
Reproducible Steps
- Intercept the failing request via proxy or APM.
- Verify
Content-Type: application/jsonis explicitly set. - Compare the payload against the OpenAPI
requiredarray. - If
422is returned, the server is correctly enforcing full-resource representation. Route toPATCHinstead.
CI & Spec Guardrails
- Enforce OpenAPI 3.0+
requiredarray validation at the schema level. - Configure CI linting to distinguish
strict(reject unknown fields) vsloose(ignore unknown fields) enforcement. - Spectral Rule: Block PRs where
PUTendpoints omitrequiredconstraints for primary resource attributes.
# openapi.yaml snippet: PUT requires full representation
paths:
/users/{id}:
put:
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id, username, email, status]
properties:
id: { type: string, format: uuid }
username: { type: string }
email: { type: string, format: email }
status: { type: string, enum: [active, suspended] }
RFC 7231/5789 Compliance & Idempotency Mapping
PUT is strictly idempotent: repeated identical requests yield the same server state. PATCH is not inherently idempotent; its behavior depends entirely on the merge semantics defined in the payload. Aligning implementation with the HTTP Method Mapping Guidelines prevents cache invalidation storms and duplicate mutations.
Idempotency Enforcement for PATCH
Network retries on PATCH can cause duplicate side effects if not guarded. Implement deterministic Idempotency-Key headers and server-side deduplication.
// Idempotency middleware (Node.js/Express example)
const idempotencyStore = new Map<string, { status: number; body: any }>();
app.patch('/users/:id', async (req, res) => {
const key = req.headers['idempotency-key'] as string;
if (!key) return res.status(400).json({ error: 'Missing Idempotency-Key' });
if (idempotencyStore.has(key)) {
return res.status(200).json(idempotencyStore.get(key));
}
const result = await applyPatch(req.params.id, req.body);
idempotencyStore.set(key, { status: 200, body: result });
return res.status(200).json(result);
});
CI Validation
- Inject
Idempotency-Keyheaders in integration tests. - Run deterministic state transition tests: send identical
PATCHpayloads 3x, assert final state matches single execution.
OpenAPI Spec Mismatch & Client SDK Generation
Auto-generated SDKs frequently default to full-object serialization, causing PATCH endpoints to receive complete DTOs. This triggers unintended overwrites or null deletions.
Fix: Enforce Partial Media Types
Explicitly declare application/merge-patch+json (RFC 7396) or application/json-patch+json (RFC 6902) in your spec. Code generators will then produce partial-aware clients.
paths:
/users/{id}:
patch:
x-partial-update: true
requestBody:
content:
application/merge-patch+json:
schema:
type: object
additionalProperties: false
properties:
email: { type: string, nullable: true }
status: { type: string, enum: [active, suspended] }
Client-Side Serialization Fix
Prevent SDKs from sending undefined or null for untouched fields.
TypeScript fetch wrapper:
const buildPartialPayload = (dto: Record<string, any>) =>
Object.fromEntries(
Object.entries(dto).filter(([_, v]) => v !== undefined)
);
const response = await fetch('/users/123', {
method: 'PATCH',
headers: { 'Content-Type': 'application/merge-patch+json' },
body: JSON.stringify(buildPartialPayload({ email: '[email protected]' }))
});
CI Contract Testing
- Run
openapi-diffagainst generated client payloads. - Assert that
PATCHroutes never serializerequiredfields unless explicitly modified.
Null Semantics vs Field Omission in Partial Updates
RFC 7396 JSON Merge Patch defines strict null semantics: null explicitly clears a field, while field omission preserves existing state. Misinterpreting this causes silent data loss. Reference foundational patterns in API Design Fundamentals & Architecture when designing mutation contracts.
Backend Null-Coalescing Logic
# Python/FastAPI route handling
from pydantic import BaseModel
from typing import Optional
class UserPatch(BaseModel):
email: Optional[str] = None
status: Optional[str] = None
@app.patch("/users/{user_id}")
def patch_user(user_id: str, patch: UserPatch):
# Only apply fields explicitly sent in payload
update_data = patch.model_dump(exclude_unset=True)
if "email" in update_data and update_data["email"] is None:
# Explicit deletion/clearing
db.clear_field(user_id, "email")
elif "email" in update_data:
db.update_field(user_id, "email", update_data["email"])
return db.get_user(user_id)
CI Snapshot Testing
- Implement state mutation snapshots: assert that omitting a field leaves the database record unchanged.
- Assert that sending
"field": nulltriggers aDELETEorSET NULLoperation. - Run differential tests across
application/jsonvsapplication/merge-patch+jsoncontent types.
CI/CD Guardrails for Method Routing & Contract Drift
Automated routing validation prevents partial updates from reaching full-replacement endpoints. Block PRs that violate partial update contracts before deployment.
Spectral Linting Rule
# .spectral.yaml
rules:
patch-must-not-require-non-pk:
description: PATCH endpoints must not enforce required constraints on non-primary-key fields
severity: error
given: "$.paths[*].patch.requestBody.content[*].schema"
then:
field: "required"
function: pattern
functionOptions:
match: "^id$" # Only PKs should be required on PATCH
Mock Server Validation Pipeline
- Generate mock server from OpenAPI spec (
prismorwiremock). - Run contract tests sending partial payloads to
PUTandPATCH. - Assert:
PUTwith missingrequiredfields →422PATCHwithapplication/json→415 Unsupported Media Type(if strict)PATCHwithapplication/merge-patch+json→200with partial state mutation
Common Pitfalls & Remediation Matrix
| Pitfall | Symptom | Exact Fix |
|---|---|---|
Treating PATCH as inherently non-idempotent |
Duplicate mutations on retries | Implement Idempotency-Key headers + server-side deduplication cache |
Serializing full DTOs to PATCH endpoints |
Unintended field overwrites or null deletions |
Use .exclude_unset=True (Pydantic) or Object.fromEntries filtering (TS) |
Missing Content-Type negotiation |
Server defaults to application/json, rejects merge semantics |
Explicitly set application/merge-patch+json in client & OpenAPI spec |
SDKs auto-generating PUT-style validation for PATCH |
False-positive 400/422 on partial payloads |
Add x-partial-update: true extension; configure generator templates |
| Ignoring RFC 7396 null-handling rules | Silent data loss instead of explicit field clearing | Differentiate null (clear) vs omission (preserve); add CI snapshot tests |
Frequently Asked Questions
Does PATCH guarantee idempotency like PUT?
No. PATCH is not inherently idempotent. Idempotency depends on the payload format (e.g., JSON Merge Patch is idempotent, JSON Patch with add/remove may not be). Implement explicit Idempotency-Key headers and server-side deduplication.
Why does my OpenAPI-generated client send a full object on PATCH?
Most code generators default to full DTO serialization. Configure exclude_unset or partial: true flags in your generator templates, and enforce application/merge-patch+json in the spec.
How do I prevent 422 errors when clients send partial updates to PUT?
PUT requires complete resource representation per RFC 7231. If partial updates are required, route to PATCH. Use CI contract tests to block PUT endpoints with incomplete required fields.
What media type should I use for PATCH?
Use application/merge-patch+json (RFC 7396) for simple field overrides, or application/json-patch+json (RFC 6902) for complex array/object operations. Avoid bare application/json unless explicitly documented.