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.


PUT vs PATCH: resource state transitions Two side-by-side diagrams. Left: PUT sends all four fields and the server replaces all four. Right: PATCH sends only the email field and the server updates only that field, leaving the others unchanged. PUT /users/42 Client payload id: "42" name: "Alice" email: "a@x.io" status: "active" replaces Server state id: "42" name: "Alice" email: "a@x.io" status: "active" All fields replaced — required array enforced PATCH /users/42 Client payload email: "b@x.io" merges Server state id: "42" name: "Alice" email: "b@x.io" status: "active" Only email updated — other fields preserved

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:


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.