---
title: 'Correct Use of HTTP Methods'
source: 'https://academia.sh/en/courses/api-design/correct-use-of-http-methods'
course: 'Web API Design'
language: en
updated: '2026-08-19T05:19:30+00:00'
license: 'CC BY-SA 4.0'
---

# Correct Use of HTTP Methods

The promises a method carries: a safe method not changing data, and an idempotent method producing a single effect when repeated; testing each property by counting rows in the database, PATCH's increment and assignment faces, and the identity choice between PUT and POST.

The previous lesson set the path aside for a resource's identity and moved on with "the
method says what to do." That sentence holds more than a division of labor. An HTTP method
is not just a command sent to the server; it is a promise that the client, caches, and every
component in between rely on when they look at the request.

This lesson defines those promises and measures each one. The measurement is uniform: the
same request is sent twice in a row, and the row count and field values in the database are
counted at every step. Whether a method keeps its promise is settled by numbers, not by
argument.

## A Method Is Not a Name, It Is a Promise

The How the Internet Works course introduced two properties. A **safe** method leaves no
observable change on the server; it reads, it does not write. An **idempotent** method makes
no difference to the server's final state whether the same request is sent once or five
times in a row. The two are independent: every safe method is idempotent, but not every
idempotent method is safe.

| Method | Safe | Idempotent | Carries a body |
|---|---|---|---|
| GET | yes | yes | no |
| HEAD | yes | yes | no |
| OPTIONS | yes | yes | no |
| POST | no | no | yes |
| PUT | no | yes | yes |
| PATCH | no | depends on design | yes |
| DELETE | no | yes | usually no |

These properties are not the server's internal business; they are commitments made outward.
A cache can store a GET response and answer the next request without going to the server —
trusting that GET has no side effect. A client can resend a PUT request after a network
error — trusting that repeating it is harmless. The request layer in the Application
Architecture course encoded this trust as **retryability**.

If the commitment is not kept, what breaks is not the server, but the parties that trust it.

## Safety: A Read Does Not Change Data

The service below carries a deliberate flaw for the measurement: a loan return can be made
both the right way and through a read-shaped address.

```js
// method-server.mjs — a service set up to measure method properties
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync("library.db");
const readBody = (request) => new Promise((resolve) => {
  let data = ""; request.on("data", (p) => (data += p));
  request.on("end", () => resolve(data ? JSON.parse(data) : {}));
});
const respond = (response, status, data) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  response.end(data === null ? "" : JSON.stringify(data));
};

const server = createServer(async (request, response) => {
  const path = new URL(request.url, "http://127.0.0.1").pathname;
  const method = request.method;
  const book = /^\/books\/([^/]+)$/.exec(path);
  const loan = /^\/loans\/(\d+)$/.exec(path);
  const returnPath = /^\/loans\/(\d+)\/return$/.exec(path);

  if (method === "GET" && path === "/loans")
    return respond(response, 200, db.prepare("SELECT * FROM loan").all());

  // WRONG: a read-shaped address changes data.
  if (method === "GET" && returnPath) {
    db.prepare("UPDATE loan SET return = '2026-03-12' WHERE id = ? AND return IS NULL")
      .run(Number(returnPath[1]));
    return respond(response, 200, { result: "return received" });
  }

  if (method === "POST" && path === "/loans") {
    const g = await readBody(request);
    const s = db.prepare("INSERT INTO loan (member, isbn, issuedAt, return) VALUES (?,?,?,NULL)")
                .run(g.member, g.isbn, g.issuedAt);
    return respond(response, 201, { id: Number(s.lastInsertRowid) });
  }

  if (method === "PUT" && book) {
    const g = await readBody(request);
    // Full replacement: the sent representation determines the whole record.
    const exists = db.prepare("SELECT 1 FROM book WHERE isbn = ?").get(book[1]);
    db.prepare(`INSERT INTO book (isbn, title, author, year, branch, copies) VALUES (?,?,?,?,?,?)
                ON CONFLICT(isbn) DO UPDATE SET
                  title=excluded.title, author=excluded.author, year=excluded.year,
                  branch=excluded.branch, copies=excluded.copies`)
      .run(book[1], g.title, g.author, g.year, g.branch, g.copies);
    return respond(response, exists ? 200 : 201, db.prepare("SELECT * FROM book WHERE isbn = ?").get(book[1]));
  }

  if (method === "PATCH" && book) {
    const g = await readBody(request);
    if ("addCopies" in g)  // increment: depends on the previous value
      db.prepare("UPDATE book SET copies = copies + ? WHERE isbn = ?").run(g.addCopies, book[1]);
    if ("copies" in g)     // assignment: independent of the previous value
      db.prepare("UPDATE book SET copies = ? WHERE isbn = ?").run(g.copies, book[1]);
    return respond(response, 200, db.prepare("SELECT isbn, copies FROM book WHERE isbn = ?").get(book[1]));
  }

  if (method === "DELETE" && loan) {
    db.prepare("DELETE FROM loan WHERE id = ?").run(Number(loan[1]));
    return respond(response, 204, null);
  }

  respond(response, 404, { error: "path_not_found" });
});

server.listen(8476, "127.0.0.1", () => console.log("method server 127.0.0.1:8476"));
```

Now suppose a client that follows the links on a page in order arrives at this service. Such
a client only walks GET; it does not know what is behind a link.

```bash
# What happens when a link crawler follows a read-shaped address?
rm -f library.db && sqlite3 library.db < schema.sql
sqlite3 library.db "ALTER TABLE book ADD COLUMN copies INTEGER NOT NULL DEFAULT 1;"
node method-server.mjs & server=$!
sleep 0.4

open() { sqlite3 library.db "SELECT COUNT(*) FROM loan WHERE return IS NULL;"; }
printf 'open loans at start : %s\n' "$(open)"

# A client that follows every link on a page: it only walks GET.
for path in /loans /loans/1/return /loans/3/return; do
  printf 'GET %-22s -> %s\n' "$path" "$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:8476$path")"
done
printf 'open loans after GET: %s\n' "$(open)"

kill $server
```

```
method server 127.0.0.1:8476
open loans at start : 2
GET /loans                 -> 200
GET /loans/1/return        -> 200
GET /loans/3/return        -> 200
open loans after GET: 0
```

Two open loan records closed without any user performing a return. The client broke no
rule: for a link-following program, GET is a question it is free to ask. The server is the
one that broke the rule.

The scale of this flaw goes beyond a design mistake. A page-preview tool, a link-checking
script, a browser that prefetches an address bar entry — all operate on the same assumption.
In an example schema with two tables, two rows are lost; in a real catalog, the loss is as
large as the number of links followed.

In a correct design, an address like `/loans/{id}/return` is never opened; a return is made
with a `PATCH /loans/{id}` request, as shown in the previous lesson. If the address is
read-shaped, the method must be a read too, and the reverse holds as well.

## Idempotency: Sending the Same Request Twice

Idempotency measurement is direct. Every method is called twice in a row; if the result of
two calls differs from that of one, the method is not idempotent.

```bash
# Every method is called twice in a row; the effect on the database is counted after each call.
rm -f library.db && sqlite3 library.db < schema.sql
sqlite3 library.db "ALTER TABLE book ADD COLUMN copies INTEGER NOT NULL DEFAULT 1;"
node method-server.mjs & server=$!
sleep 0.4

T=http://127.0.0.1:8476
say() { printf '%-26s loans=%s  copies=%s\n' "$1" \
  "$(sqlite3 library.db 'SELECT COUNT(*) FROM loan;')" \
  "$(sqlite3 library.db "SELECT copies FROM book WHERE isbn='978-0131103627';")"; }

say "start"
curl -sS -o /dev/null $T/loans; curl -sS -o /dev/null $T/loans
say "GET x2"
for i in 1 2; do curl -sS -o /dev/null -X POST -H 'content-type: application/json' \
  -d '{"member":"U-1002","isbn":"978-0131103627","issuedAt":"2026-03-10"}' $T/loans; done
say "POST x2"
for i in 1 2; do curl -sS -o /dev/null -X PUT -H 'content-type: application/json' \
  -d '{"title":"The C Programming Language","author":"Ritchie","year":1988,"branch":"S-01","copies":4}' \
  $T/books/978-0131103627; done
say "PUT x2"
for i in 1 2; do curl -sS -o /dev/null -X PATCH -H 'content-type: application/json' \
  -d '{"addCopies":2}' $T/books/978-0131103627; done
say "PATCH increment x2"
for i in 1 2; do curl -sS -o /dev/null -X PATCH -H 'content-type: application/json' \
  -d '{"copies":6}' $T/books/978-0131103627; done
say "PATCH assignment x2"
printf '%-26s %s %s\n' "DELETE x2 (status code)" \
  "$(curl -sS -o /dev/null -w '%{http_code}' -X DELETE $T/loans/2)" \
  "$(curl -sS -o /dev/null -w '%{http_code}' -X DELETE $T/loans/2)"
say "DELETE x2"

kill $server
```

```
method server 127.0.0.1:8476
start                      loans=3  copies=1
GET x2                     loans=3  copies=1
POST x2                    loans=5  copies=1
PUT x2                     loans=5  copies=4
PATCH increment x2         loans=5  copies=8
PATCH assignment x2        loans=5  copies=6
DELETE x2 (status code)    204 204
DELETE x2                  loans=4  copies=6
```

Read the lines one at a time.

**GET x2** changed no number: the method is safe, so it is idempotent.

**POST x2** raised the loan count from three to five: two requests, two records. This is
POST's definition — a new member is added to the collection and the server determines its
identity. A POST resent because the network dropped produces a second loan record. This is
not a design flaw, it is POST's nature; its fix is the ninth lesson's subject.

**PUT x2** set the copy count to 4 and left it there. Because the sent body determines the
whole record, the second request produces the same final state as the first. How many times
it is sent does not change the result.

**PATCH increment x2** raised the copy count from 4 to 8. The body carries an `addCopies`
field, so the new value depends on the old one. This is not a flaw in PATCH; it is proof
that PATCH's idempotency depends on the body. **PATCH assignment x2** used the same method to
produce the value 6, and the second request did not change it.

The design rule this yields is clear: a partial-update body built on assignment rather than
increment makes the method idempotent. If an increment is genuinely needed — a counter, a
balance, stock — idempotency cannot be achieved through the body; the key mechanism from the
ninth lesson is needed.

**DELETE x2** returned 204 to both requests, and the loan count dropped from five to four,
not dropping further on the second delete. This is idempotency's definition: the final state
is the same. Returning 404 to the second request is also a defensible choice and does not
break idempotency, because idempotency is a property of server state, not of the response
code. Which one to choose is the next lesson's subject.

## The Choice Between PUT and POST

Both write; what draws the line is who determines the identity.

**POST** is used when the server determines the identity. The client sends a body to the
`/loans` collection, the server generates the new record's identity and reports it in the
response. The client cannot know the target address before sending the request.

**PUT** is used when the client knows the identity. A PUT to `/books/978-0131103627` creates
the resource at that address if it does not exist, or replaces it with the sent
representation if it does. The server above reflects this distinction in the status code
too: 201 if the record did not exist, 200 if it did.

PUT's second property is often overlooked: **fields left out are emptied.** PUT is a full
replacement, not a partial update. If the `author` field is absent from the body, the
record's author counts as cleared. If this behavior is not wanted, the right method is
PATCH. A service that runs PUT like a partial update does not break the client's idempotency
assumption, but it makes the contract ambiguous: the same body produces two different
results across two services.

## Method Mismatch

If an address exists but does not support the requested method — say, a DELETE arrives at
the `/members/U-1001/status` resource — the response should not be "not found." The resource
is right there; the method just cannot be applied. HTTP defines a separate status code for
this and expects the response to report which methods are accepted. The server above does
not make this distinction; it returns 404 to anything that does not match. The next lesson
measures this gap and closes it.

## Summary

- A method is a commitment the server makes outward: a safe method leaves no observable
  change, an idempotent one does not change the final state when repeated.
- A read-shaped address changing data turns every link-following program into something
  destructive; in the measurement, three GET requests closed two open loan records.
- POST is not idempotent and does not try to be; because the server generates the identity,
  every request produces a new record.
- PUT is a full replacement and is idempotent; fields left out are emptied, so it is not
  suited to partial updates.
- PATCH's idempotency depends on the body: an assignment-shaped body is idempotent, an
  increment-shaped one is not — in the measurement, the copy count rose from 4 to 8.
- DELETE is idempotent; a second delete request returning a different status code does not
  break this property, because idempotency is a property of server state.

## Next Step

This lesson largely assumed the response status codes: 201 on create, 204 on delete, 404 for
everything unmatched. Yet each of these is a choice, and when the choice is made wrong, the
client does not notice the error. Does a resource's absence return the same code as lacking
permission to access it? How is a request rejected by a business rule told apart from one
with a malformed body? The next lesson asks the same set of scenarios of two different
servers, sets the codes they produce side by side, and shows what a wrong mapping produces
on the client.
