Choosing Between POST and PUT for Resource Creation

This guide is part of HTTP Method Mapping Guidelines within the API Design Fundamentals & Architecture reference. Both POST and PUT can create a resource, so the choice is not about whether creation is possible but about who owns the identifier and what idempotency guarantee the contract must offer. Getting this right also settles the closely related question of when to use PUT vs PATCH for partial updates and how you wire in idempotency keys.

The decision trigger

You are designing a create endpoint and two reasonable-looking options are on the table: POST /orders and PUT /orders/{id}. A network blip causes the client to retry, and now you have two orders where the user intended one — or you have a race between two clients that both invented the same id. The symptom that forces this decision is duplicate resources under retries, or a client that needs to create a resource at a URI it already knows (an import, a sync, an upsert). The method you pick determines whether a naive retry is safe by construction or a liability you must patch over.

The core rule: use POST when the server assigns the identifier; use PUT when the client supplies the identifier as part of the target URI.

Decision table

Question POST to collection PUT to item URI
Who chooses the id? Server Client
Target URI /orders (collection) /orders/{id} (known)
Idempotent? No (each call may create) Yes (same body → same state)
Success on create 201 Created + Location 201 Created
Success on replace n/a 200 OK or 204 No Content
Safe to retry blindly? Only with an idempotency key Yes
Typical use Server-generated UUIDs, auto-increment ids Client-known ids, upserts, imports, syncs

Method semantics per RFC 9110

Standard callout — RFC 9110 §9.3.3 (POST) and §9.3.4 (PUT). POST requests that the target resource process the enclosed representation according to the resource’s own semantics; the origin server decides how (and whether) to create a new resource and what its URI is. PUT requests that the target resource’s state be created or replaced with the state defined by the enclosed representation. PUT is idempotent: the effect of one successful request is identical to that of N identical successful requests. POST is not listed among the idempotent methods. When PUT creates a new resource, the origin server MUST send a 201 (Created) response; when it modifies an existing one, it sends 200 (OK) or 204 (No Content).

The load-bearing word is idempotent. PUT carries a full representation to a specific URI, so replaying it converges on the same state. POST delegates all creation semantics to the server, which is free to mint a new resource on every call — that is exactly why it is not idempotent.

POST vs PUT creation decision flow A flow starting from who owns the identifier: if the server assigns it, use POST to the collection returning 201 with Location; if the client supplies it, use PUT to the item URI, idempotent, returning 201 on create or 200/204 on replace. Who owns the id? Server or client? server client POST /orders not idempotent to the collection PUT /orders/{id} idempotent to the item URI 201 Created Location: /orders/{new-id} 201 on create 200 / 204 on replace

OpenAPI 3.1 contract for both shapes

# openapi.yaml (OpenAPI 3.1.0)
paths:
  /orders:
    post:
      summary: Create an order (server assigns the id)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrderInput' }
      responses:
        '201':
          description: Order created.
          headers:
            Location:
              required: true
              description: URI of the newly created order.
              schema: { type: string, format: uri-reference, example: /orders/8f3a }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
  /orders/{id}:
    put:
      summary: Create or replace an order at a client-chosen id
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrderInput' }
      responses:
        '201': { description: Order created at the supplied id. }
        '200': { description: Existing order replaced. }
        '204': { description: Existing order replaced, no body returned. }

Step-by-step: implementing each path

Step 1 — POST with a server-assigned id

The server mints the id, persists the resource, and returns 201 with a Location header. The Location header is mandatory here because the client has no other way to learn the URI the server chose.

Node (Express):

app.post('/orders', async (req, res) => {
  const id = crypto.randomUUID();                 // server owns the id
  const order = await db.orders.insert({ id, ...req.body });
  res
    .status(201)
    .location(`/orders/${id}`)                     // required on 201
    .json(order);
});

Because POST is not idempotent, a retried request creates a second order. To make retries safe without switching to PUT, require an Idempotency-Key header and deduplicate replays — see Handling Idempotency Key Conflicts.

Step 2 — PUT with a client-supplied id

The client already knows the id (a UUID it generated, a natural key like a SKU, or an id from an upstream system). PUT targets that exact URI and creates-or-replaces. The status distinguishes the two outcomes.

Python (FastAPI):

from fastapi import FastAPI, Response

app = FastAPI()

@app.put("/orders/{order_id}")
def upsert_order(order_id: str, body: OrderInput, response: Response):
    existed = db.orders.exists(order_id)
    db.orders.upsert(order_id, body.model_dump())   # full replace
    if existed:
        response.status_code = 204                  # replaced, no body
        return
    response.status_code = 201                      # created
    response.headers["Location"] = f"/orders/{order_id}"
    return db.orders.get(order_id)

Retrying this PUT is safe by construction: the second call finds the resource already in the intended state and returns 204, with no duplicate created. That is the whole point of choosing PUT when the client owns the id.

Step 3 — Get the status codes right

The Location header contract

Location on a 201 tells the client where the new resource lives (RFC 9110 §10.2.2). For POST it is required — the server picked the URI. For PUT the target URI is the resource URI, so Location is optional; if you send it, it must equal the request URI. Return the URI as a path or absolute-form reference and keep it stable, because clients will store it and issue follow-up GETs against it.

Idempotency and safety implications

PUT is both safe to retry and, being a full replacement, easy to reason about: the request body is the complete desired state. POST is neither idempotent nor safe, so any automatic retry layer — a proxy, an SDK, a message queue — can multiply resources. When your create endpoint must tolerate retries and the id is server-owned, the standard fix is an idempotency key. When the id is client-owned, prefer PUT and get idempotency for free. Choosing PUT for a full replace also keeps a clean boundary with PATCH, which conveys a partial change and is likewise not guaranteed idempotent.

SDK and codegen downstream effect

The method and status choices propagate directly into generated clients. A 201-with-Location contract generates a client that returns the new resource URI; a PUT upsert generates an idempotent method a caller can safely wrap in a retry:

// Generated from the POST contract
async function createOrder(input: OrderInput): Promise<{ order: Order; location: string }> {
  const res = await http.post('/orders', input);
  return { order: res.body, location: res.headers['location'] };
}

// Generated from the PUT contract — safe to retry
async function putOrder(id: string, input: OrderInput): Promise<Order | void> {
  const res = await http.put(`/orders/${id}`, input);
  return res.status === 204 ? undefined : res.body;
}

If the OpenAPI response omits the Location header on POST, the generated createOrder cannot return the new URI and callers resort to guessing it from the body — a contract test asserting Location on every creating POST prevents that regression.

Common mistakes

Mistake Correct approach
Using POST for a client-known id and then deduplicating manually Use PUT /resource/{id} — creation becomes idempotent for free
Returning 200 from a POST that created a resource Return 201 Created so clients can distinguish creation from a no-op
Omitting Location on a 201 from POST Always send Location — it is the only way the client learns the URI
PUT that merges instead of replacing the full representation PUT replaces the entire resource; use PATCH for partial changes
Treating POST retries as safe without an idempotency key POST is not idempotent; require an Idempotency-Key to dedupe replays

FAQ

Is PUT always idempotent and POST never idempotent?

PUT is defined as idempotent: sending the same full representation to the same URI repeatedly leaves the resource in the same final state, so a retry cannot create a duplicate. POST is not defined as idempotent, so repeated POSTs to a collection may each create a new resource. You can make POST safe to retry by attaching an idempotency key, but the method itself carries no built-in guarantee.

What status code should creation return, 200 or 201?

Return 201 Created whenever a new resource is created, for both POST and PUT, and include a Location header pointing at the new resource. Return 200 OK or 204 No Content only when PUT replaces an existing resource rather than creating one. Reserving 201 for creation lets clients and caches tell a create apart from a replace.

Do I need a Location header on PUT?

For PUT the target URI is already the resource URI, so Location is optional; when you do send it, it should match the request URI. For POST to a collection the Location header is required on 201 because the server chose the new resource’s URI and the client has no other way to learn it.