REST API Basics: Resources, HTTP Methods and Status Codes
Web DevelopmentLesson 1 of 47 min read
What you'll learn
- Model a problem as collections and items addressed by URLs
- Choose between GET, POST, PUT, PATCH and DELETE and explain safety and idempotency
- Return accurate status codes, including 201 with Location, 204, 404, 405, 415 and 400
- Read raw HTTP responses with
curl -ito verify your API's behaviour
On this page
A support ticket arrives at a bike-share company: "The mobile app says the dock was updated, but nothing changed." The engineers look at the logs. The server answered every request with 200 OK, and the failures were reported inside the body as {"success": false}. The app only looked at the status line, so as far as it knew everything worked. This is the most common REST design bug, and it comes from treating HTTP as a pipe instead of as part of the contract. This lesson fixes that, using a small API for bike-share docking stations.
Resources are nouns, URLs are their addresses
REST (Representational State Transfer) is a set of architectural constraints for networked systems: a client-server split, stateless requests (each one carries everything the server needs), cacheable responses, and a uniform interface. In everyday usage, "REST API" means an HTTP API that exposes resources at URLs and lets clients act on them with the standard HTTP methods. Strictly following every REST constraint (including hypermedia links) is rare; what matters in practice is that the API is predictable.
A resource is a thing your system knows about: a station, a bike, a rental. Collections get a plural noun (/stations) and individual members are addressed by id (/stations/3). The URL names the thing; the HTTP method says what to do with it. That is why /getStation?id=3 and /deleteStation feel wrong: the verb is already in the request line.
Methods: what each one promises
Two properties decide how clients, proxies and retry logic treat a method. A safe method does not change server state, so it can be called freely. An idempotent method has the same effect whether you send it once or five times, which matters because networks drop responses and clients retry.
| Method | Typical use | Safe | Idempotent | Usual success status |
|---|---|---|---|---|
| GET | Read a resource or a collection | Yes | Yes | 200 |
| POST | Create a member in a collection, or trigger an action | No | No | 201 with Location, or 200/202 |
| PUT | Replace the whole resource | No | Yes | 200 or 204 |
| PATCH | Change only some fields | No | Not guaranteed | 200 |
| DELETE | Remove the resource | No | Yes | 204 |
"Idempotent" refers to the effect on the server, not the response. Deleting station 2 twice leaves the same state both times, even though the second call may answer 404. PATCH is not guaranteed idempotent: {"op": "add bike"} applied twice would add two bikes, whereas {"bikes": 5} applied twice is harmless.
A station API you can run
Install Flask (pip install flask), save this as stations.py and run python stations.py. It keeps stations in memory, accepts JSON only, and answers errors with JSON as well, so clients never have to parse an HTML error page. I ran every request in this lesson against this exact file.
from flask import Flask, Response, jsonify, request, url_for
from werkzeug.exceptions import HTTPException, MethodNotAllowed
app = Flask(__name__)
app.json.sort_keys = False
stations = {
1: {"id": 1, "name": "Riverside Dock", "capacity": 20, "bikes": 12},
2: {"id": 2, "name": "Central Library", "capacity": 15, "bikes": 0},
}
next_id = 3
@app.errorhandler(HTTPException)
def as_json(err):
headers = {}
if isinstance(err, MethodNotAllowed):
headers["Allow"] = ", ".join(sorted(err.valid_methods))
return jsonify(error=err.name, detail=err.description), err.code, headers
def require_json():
if not request.is_json:
return None, (jsonify(error="Unsupported Media Type", detail="use Content-Type: application/json"), 415)
body = request.get_json(silent=True)
if not isinstance(body, dict):
return None, (jsonify(error="Bad Request", detail="send a JSON object"), 400)
return body, None
@app.get("/stations")
def list_stations():
return jsonify(items=list(stations.values()))
@app.post("/stations")
def create_station():
global next_id
body, err = require_json()
if err:
return err
if not {"name", "capacity"} <= body.keys():
return jsonify(error="Bad Request", detail="name and capacity are required"), 400
station = {"id": next_id, "name": body["name"], "capacity": body["capacity"], "bikes": 0}
stations[next_id] = station
next_id += 1
resp = jsonify(station)
resp.status_code = 201
resp.headers["Location"] = url_for("get_station", station_id=station["id"])
return resp
@app.get("/stations/<int:station_id>")
def get_station(station_id):
station = stations.get(station_id)
if station is None:
return jsonify(error="Not Found", detail=f"no station {station_id}"), 404
return jsonify(station)
@app.put("/stations/<int:station_id>")
def replace_station(station_id):
if station_id not in stations:
return jsonify(error="Not Found", detail=f"no station {station_id}"), 404
body, err = require_json()
if err:
return err
if body.keys() != {"name", "capacity", "bikes"}:
return jsonify(error="Bad Request", detail="PUT needs name, capacity and bikes"), 400
stations[station_id] = {"id": station_id, **body}
return jsonify(stations[station_id])
@app.patch("/stations/<int:station_id>")
def update_station(station_id):
station = stations.get(station_id)
if station is None:
return jsonify(error="Not Found", detail=f"no station {station_id}"), 404
body, err = require_json()
if err:
return err
station.update({k: v for k, v in body.items() if k in {"name", "capacity", "bikes"}})
return jsonify(station)
@app.delete("/stations/<int:station_id>")
def delete_station(station_id):
if stations.pop(station_id, None) is None:
return jsonify(error="Not Found", detail=f"no station {station_id}"), 404
resp = Response(status=204)
del resp.headers["Content-Type"] # a 204 has no body, so no content type
return resp
if __name__ == "__main__":
app.run(port=5101)Watching the status codes
Use curl -i to print the status line and headers along with the body. The commands below run in order against a fresh server; I removed the Server, Date and Connection headers from the output to keep it readable.
curl -s -i http://localhost:5101/stations/1
curl -s -i -X POST http://localhost:5101/stations \
-H "Content-Type: application/json" \
-d '{"name": "Tech Park Gate", "capacity": 30}'
curl -s -i -X POST http://localhost:5101/stations -d 'name=Tech Park Gate'
curl -s -X PUT http://localhost:5101/stations/2 -H "Content-Type: application/json" \
-d '{"name": "Central Library", "capacity": 18, "bikes": 4}'
curl -s -X PUT http://localhost:5101/stations/2 -H "Content-Type: application/json" \
-d '{"name": "Central Library", "capacity": 18, "bikes": 4}'
curl -s -X PATCH http://localhost:5101/stations/2 -H "Content-Type: application/json" \
-d '{"bikes": 5}'
curl -s -i -X DELETE http://localhost:5101/stations/2
curl -s -i -X DELETE http://localhost:5101/stations/2
curl -s -i -X DELETE http://localhost:5101/stations
curl -s -i -X POST http://localhost:5101/stations \
-H "Content-Type: application/json" -d '{"name": "Tech Park Gate"}'Output
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 58
{"id":1,"name":"Riverside Dock","capacity":20,"bikes":12}
HTTP/1.1 201 CREATED
Content-Type: application/json
Content-Length: 57
Location: /stations/3
{"id":3,"name":"Tech Park Gate","capacity":30,"bikes":0}
HTTP/1.1 415 UNSUPPORTED MEDIA TYPE
Content-Type: application/json
Content-Length: 81
{"error":"Unsupported Media Type","detail":"use Content-Type: application/json"}
{"id":2,"name":"Central Library","capacity":18,"bikes":4}
{"id":2,"name":"Central Library","capacity":18,"bikes":4}
{"id":2,"name":"Central Library","capacity":18,"bikes":5}
HTTP/1.1 204 NO CONTENT
HTTP/1.1 404 NOT FOUND
Content-Type: application/json
Content-Length: 46
{"error":"Not Found","detail":"no station 2"}
HTTP/1.1 405 METHOD NOT ALLOWED
Content-Type: application/json
Content-Length: 91
Allow: GET, HEAD, OPTIONS, POST
{"error":"Method Not Allowed","detail":"The method is not allowed for the requested URL."}
HTTP/1.1 400 BAD REQUEST
Content-Type: application/json
Content-Length: 66
{"error":"Bad Request","detail":"name and capacity are required"}Walk through what happened. Creating a station returned 201 Created plus a Location header telling the client where the new resource lives. Sending form data instead of JSON produced 415 Unsupported Media Type (the server understands the request but not that format), while a JSON body with a missing field gave 400 Bad Request. The two PUT calls returned identical results, which is idempotency in action, and the PATCH changed one field and left the rest alone. The first DELETE returned 204 No Content, which by definition has no body, and the repeat returned 404. Finally, DELETE /stations hit a path that exists but does not allow that method, so the server answered 405 with an Allow header listing what it does accept.
Choosing a status code
| Code | Meaning | Use it when |
|---|---|---|
| 200 OK | Success with a body | Reads, updates that return the resource |
| 201 Created | A new resource exists | After POST; include Location |
| 204 No Content | Success, nothing to return | DELETE, or an update where the client needs no body |
| 400 Bad Request | Malformed request | Invalid JSON, missing required field |
| 401 Unauthorized | Not authenticated | No or invalid credentials |
| 403 Forbidden | Authenticated but not allowed | User lacks permission for this resource |
| 404 Not Found | No such resource | Unknown id or route |
| 405 Method Not Allowed | Path exists, method does not | Send an Allow header |
| 409 Conflict | Clashes with current state | Duplicate name, version clash, slot already taken |
| 415 Unsupported Media Type | Body format not accepted | Wrong or missing Content-Type |
| 422 Unprocessable Content | Well-formed but semantically invalid | Field-level validation failures |
| 500 / 503 | Server fault / temporarily unavailable | Never blame the client; 503 can carry Retry-After |
Common mistakes and the problems they cause
- Always returning 200 and putting the error in the body. Symptom: the mobile app, monitoring dashboards and HTTP client libraries all report success while users see failures, and error-rate alerts never fire.
- Verbs in URLs (`/createStation`, `/station/delete/3`). Symptom: every endpoint is a one-off, caches and proxies cannot reason about them, and a crawler or prefetcher that follows a
GETlink can trigger a deletion. - Using GET for something that changes state. Symptom: browsers, link previews and retries repeat the action without anyone clicking.
- PUT used as a partial update. Symptom: a client sends only
{"bikes": 5}and the name and capacity are silently wiped. PUT replaces; use PATCH for partial changes (the server above rejects an incomplete PUT with 400). - 201 without a `Location` header, or 200 for a created resource. Symptom: clients have to guess the new URL and cannot tell a create from an update.
- Returning an HTML error page from a JSON API. Symptom: the client crashes with a JSON parse error instead of showing the message. Register a handler so framework errors such as 404 and 405 come back as JSON too.
How this shows up in real projects and interviews
In code review, most REST feedback is about exactly these basics: wrong status codes, inconsistent naming, a POST that should have been a PUT. In interviews, expect "what is the difference between PUT and PATCH?" (full replacement versus partial change), "which methods are idempotent, and why does it matter?" (safe retries), and "401 versus 403". A solid answer is concrete: name the retry scenario, or describe what the client does differently on each code. If you have built a service with the built-in Node module, compare it with the Node.js course: the status codes there are the same ones used here. Next you will design the URLs and JSON for a bigger domain: REST API Design: Endpoints and Payloads.
Try it yourself
Add a bikes sub-collection to the station API. GET /stations/1/bikes returns the bikes docked there, POST /stations/1/bikes docks a bike (201 with a Location, 409 if the station is at capacity), and DELETE /stations/1/bikes/<bike_id> undocks it (204, then 404 on the repeat). Test every branch with curl -i and write down which of your endpoints are safe and which are idempotent.
Show a hint
Keep a bikes list per station and compare its length with capacity before appending. Remember that 404 should be returned both for an unknown station and for an unknown bike id.
Key takeaways
- Model things as resources: plural-noun collections (
/stations) and members (/stations/3), with the HTTP method as the verb. - GET is safe and idempotent, PUT and DELETE are idempotent, POST is neither, and PATCH is not guaranteed to be.
- Use real status codes: 201 plus
Locationon create, 204 for empty success, 400/415/422 for bad input, 404 for missing, 405 withAllow, 409 for conflicts. - Return JSON errors for every failure, including framework-level 404 and 405 responses.
- Verify behaviour with
curl -i, which shows the status line and headers that clients actually act on.
Test yourself
A trick from this lesson's ride: The status line is the headline, the body is the small print; tools read headlines. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What is the difference between PUT and PATCH?
PUT replaces the entire resource with the representation you send, so omitted fields are removed or reset, and repeating the same PUT gives the same result. PATCH applies a partial change to specific fields. Use PUT when the client holds the full object and PATCH when it only wants to change a few properties.
When should a REST API return 201 vs 200?
Return 201 Created when a request has created a new resource, usually from POST, and include a Location header with its URL. Return 200 OK when the request succeeded and you are returning a body for an existing resource, such as a GET or an update. If there is nothing to return, use 204 No Content.
Is DELETE idempotent in REST?
Yes. Idempotency is about the final server state, and after one DELETE or five, the resource is gone. The response code can still differ: many APIs return 204 the first time and 404 afterwards, and some return 204 both times to make retries easier for clients.