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

  1. Intercept the failing request via proxy or APM.
  2. Verify Content-Type: application/json is explicitly set.
  3. Compare the payload against the OpenAPI required array.
  4. If 422 is returned, the server is correctly enforcing full-resource representation. Route to PATCH instead.

CI & Spec Guardrails

# 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

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: 'new@example.com' }))
});

CI Contract Testing

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

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

  1. Generate mock server from OpenAPI spec (prism or wiremock).
  2. Run contract tests sending partial payloads to PUT and PATCH.
  3. Assert:

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.