Design RESTful Routes & Methods
Learn to design RESTful routes and methods in Python web development. Master resource naming, HTTP verbs, status codes, and practical patterns.
Focus: design restful routes and methods
You’ve built routes that work, but now your endpoints are a mess of inconsistent names and verbs — /getUsers, /user/create, /delete-user?id=5. Every new developer on the team asks the same question: "Where do I add the update endpoint?" That chaos has a name: poor RESTful design. In this lesson, you’ll learn how to design RESTful routes and methods that are predictable, scalable, and self-documenting — so your API feels like a well-organized library instead of a junk drawer.
The problem this lesson solves
Imagine you’re maintaining a Flask or FastAPI app that has grown to 30+ endpoints. You need to add a "delete a comment" feature. You search the codebase and find three different naming styles: /delete-comment, /comments/delete, and /removeComment. Which one do you extend? You spend 20 minutes just figuring out the pattern — and you’re the one who wrote half of them.
That’s the real pain: inconsistent routes and methods increase cognitive load, invite bugs, and make your API hard to consume. Clients (web apps, mobile apps, third-party integrations) have to hard-code fragile URLs. Automated tests break when someone renames a route. Documentation becomes a lie within weeks.
RESTful design solves this by giving you a uniform interface — a small, predictable set of conventions that covers 90% of your CRUD needs. When you design restful routes and methods properly, anyone who understands one resource can guess the routes for every other resource. That’s not magic; that’s discipline.
By the end of this lesson, you’ll be able to:
- Explain the core idea behind design restful routes and methods
- Complete a practical exercise that maps resources to routes and methods
Core concept / mental model
Think of your API as a library catalog. Each book (resource) has a fixed address (URL), and each action you can take with it — read, check out, return, destroy — is a standard verb (HTTP method). You don’t write a new catalog entry for every action; you reuse the same book’s ID with different verbs.
In REST, a resource is a noun — users, orders, comments. The route is the URL path that identifies that resource. The HTTP method is the verb that says what to do with it. The combination of route + method = one endpoint.
Here’s the core convention:
| HTTP Method | Collection route (e.g., /users) |
Item route (e.g., /users/{id}) |
|---|---|---|
| GET | List all users (with pagination) | Retrieve a single user |
| POST | Create a new user | (Rarely used for sub-actions) |
| PUT | (Replace whole collection — rare) | Replace the user entirely |
| PATCH | (Bulk update — rare) | Partially update the user |
| DELETE | (Delete all — usually forbidden) | Delete the user |
Route naming rules:
- Use plural nouns for resources:
/users, not/useror/getUsers. - Use URL parameters for a specific item:
/users/42. - Avoid verbs in URLs:
/create-useris wrong;POST /usersis right. - Use hyphens for multi-word resources:
/user-profiles, not/user_profilesor/userProfiles. - Nest only when there’s a true ownership relationship:
/users/{user_id}/orders(orders belong to a user). Limit nesting to one or two levels deep.
Methods are the verbs — they tell the server what operation to perform. The same URL can have multiple methods, each doing something different. That’s the beauty: the route is stable, the method changes the intent.
How it works step by step
Designing restful routes and methods for a new feature is a repeatable process. Follow these steps every time:
- Identify the resource(s). Ask: What nouns does this feature involve? For a blog: users, posts, comments, tags.
- Map the resource to a base path. Use plural lowercase + hyphens:
/posts,/user-profiles. - List the actions you need. For each resource, list the CRUD operations you actually support. You don’t have to implement all five — only what your product needs.
- Assign method + route to each action. Use the table above as a cheat sheet. For a single item, always include the
{id}in the path. - Handle sub-resources. If a resource is owned by another, nest it:
/users/{user_id}/posts. But if you can access the sub-resource on its own, keep it flat:GET /posts/123is better thanGET /users/1/posts/123for most reads. - Choose response codes. A successful
GETreturns200 OK; aPOSTthat creates returns201 Created; aDELETEreturns204 No Content; bad input returns400 Bad Request; a missing item returns404 Not Found. - Document the contract. Even a simple table in your README goes a long way — but with consistent naming, the documentation writes itself.
Why does each step matter? Step 1 prevents you from sprouting endpoints like /getUserProfileData. Steps 3–4 enforce consistency so every resource follows the same pattern. Step 5 keeps your URLs clean — nesting too deep creates unreadable, brittle routes. Step 6 gives clients instant, unambiguous feedback. Step 7 makes your API approachable for the next developer (likely future you).
Hands-on walkthrough
Let’s put this into practice with a Flask app (the same pattern works in FastAPI or Django). We’ll build a minimal blog API with two resources: authors and posts.
First, the setup and the author routes:
# app.py
from flask import Flask, request, jsonify, abort
app = Flask(__name__)
# In-memory data for demonstration
posts = [
{"id": 1, "title": "REST in Peace", "author_id": 1},
{"id": 2, "title": "HTTP Verbs 101", "author_id": 1},
]
authors = [
{"id": 1, "name": "Ada"},
{"id": 2, "name": "Grace"},
]
# ---- Authors ----
@app.route("/authors", methods=["GET"])
def list_authors():
return jsonify(authors)
@app.route("/authors", methods=["POST"])
def create_author():
data = request.get_json()
if not data or "name" not in data:
abort(400, description="Name is required")
new_id = max(a["id"] for a in authors) + 1
author = {"id": new_id, "name": data["name"]}
authors.append(author)
return jsonify(author), 201
@app.route("/authors/<int:author_id>", methods=["GET"])
def get_author(author_id):
author = next((a for a in authors if a["id"] == author_id), None)
if author is None:
abort(404)
return jsonify(author)
@app.route("/authors/<int:author_id>", methods=["PUT"])
def replace_author(author_id):
author = next((a for a in authors if a["id"] == author_id), None)
if author is None:
abort(404)
data = request.get_json()
if not data or "name" not in data:
abort(400)
author["name"] = data["name"]
return jsonify(author)
@app.route("/authors/<int:author_id>", methods=["DELETE"])
def delete_author(author_id):
global authors
original_len = len(authors)
authors = [a for a in authors if a["id"] != author_id]
if len(authors) == original_len:
abort(404)
return ("", 204)
if __name__ == "__main__":
app.run(debug=True)
Now the posts — note how we reuse the same route /<int:post_id> for GET, PUT, and DELETE:
# Extract this into the same app.py, or a blueprint
@app.route("/posts", methods=["GET"])
def list_posts():
# Optionally filter by author? POST /posts?author_id=1
return jsonify(posts)
@app.route("/posts", methods=["POST"])
def create_post():
data = request.get_json()
if not data or "title" not in data or "author_id" not in data:
abort(400, description="Title and author_id required")
new_id = max(p["id"] for p in posts) + 1
post = {"id": new_id, "title": data["title"], "author_id": data["author_id"]}
posts.append(post)
return jsonify(post), 201
@app.route("/posts/<int:post_id>", methods=["GET"])
def get_post(post_id):
post = next((p for p in posts if p["id"] == post_id), None)
if post is None:
abort(404)
return jsonify(post)
@app.route("/posts/<int:post_id>", methods=["PATCH"])
def update_post(post_id):
post = next((p for p in posts if p["id"] == post_id), None)
if post is None:
abort(404)
data = request.get_json()
if "title" in data:
post["title"] = data["title"]
if "author_id" in data:
post["author_id"] = data["author_id"]
return jsonify(post)
@app.route("/posts/<int:post_id>", methods=["DELETE"])
def delete_post(post_id):
global posts
original_len = len(posts)
posts = [p for p in posts if p["id"] != post_id]
if len(posts) == original_len:
abort(404)
return ("", 204)
Test it yourself (run the app and use curl or Postman):
curl -i http://127.0.0.1:5000/authors
# HTTP/1.1 200 OK
# [{"id":1,"name":"Ada"},{"id":2,"name":"Grace"}]
curl -i -X POST http://127.0.0.1:5000/posts \
-H "Content-Type: application/json" \
-d '{"title":"Async Await","author_id":2}'
# HTTP/1.1 201 Created
curl -i -X PATCH http://127.0.0.1:5000/posts/1 \
-H "Content-Type: application/json" \
-d '{"title":"REST in Pieces"}'
# HTTP/1.1 200 OK
curl -i -X DELETE http://127.0.0.1:5000/posts/999
# HTTP/1.1 404 NOT FOUND
Watch the response codes: 201 for creation, 200 for successful reads/updates, 204 for delete, 404 for missing resources. Those are part of your contract — clients rely on them.
Pro tip: Use a REST client (like Insomnia or
httpie) while developing. It’s faster than the browser for testing methods other than GET.
Compare options / when to choose what
PUT vs PATCH:
| Method | Use when | Typical response | Idempotent? |
|---|---|---|---|
| PUT | Replacing the entire resource. Client sends all fields. | 200 or 204 | Yes |
| PATCH | Partial update — only send the fields you want to change. | 200 or 204 | No |
| POST | Creating a new resource, or triggering a non-idempotent action. | 201 | No |
| GET | Fetching data, never changes state. | 200 | Yes |
| DELETE | Removing a resource. | 204 | Yes |
Route nesting vs. flat routes:
| Approach | Example | Good for | Risk |
|---|---|---|---|
| Nested | /users/1/posts |
Clear ownership, e.g., "all posts by user 1" | Over-nesting creates deep, inflexible URLs |
| Flat | /posts?author_id=1 |
Independent resources, simpler | Loses the implicit relationship in code |
Practical guidance:
- Start flat for every resource. Only nest when the sub-resource is meaningless without its parent (e.g.,
/users/{user_id}/settings). - Use query parameters for filtering, sorting, and pagination — not for actions:
GET /posts?status=published&sort=-date. - For actions that don’t map to CRUD (e.g.,
approve,publish), don’t invent verbs. Either treat it as a sub-resource (e.g.,POST /posts/{id}/publish) or use a custom controller route as a last resort, but keep it consistent with your naming. - API versioning: if you need it, use a prefix:
/v1/users. It’s a predictable scheme, but deprecate old versions cleanly.
Troubleshooting & edge cases
Symptom: GET /users returns a 500 error — your list function broke.
- Fix: Are you mutating a global list while iterating? For
DELETE, recreate the list with a list comprehension (as in the example) instead oflist.remove()in a loop.
Symptom: POST /users fails because you used request.args instead of request.get_json().
- Fix: For POST/PUT/PATCH, you must parse the request body with
request.get_json()(orrequest.formfor form data). Remember to setContent-Type: application/jsonon the client.
Symptom: Your PUT request throws a 400 Bad Request even though the client sent data.
- Fix: The client is probably sending
application/x-www-form-urlencodedinstead ofapplication/json. Check your headers.
Symptom: You’re unsure whether to use POST/stuff or PATCH /stuff.
- Rule of thumb: If the client is creating a new item, use
POST. If it’s modifying an existing item, usePATCHfor partial changes,PUTfor full replacement.
Symptom: URLs with spaces or special characters break.
- Fix: URL-encode parameters. In
curl, use--data-urlencode; in JavaScript, useencodeURIComponent().
Symptom: DELETE returns 204 but the item still exists (when you test in a browser).
- Fix: Browsers often can’t send
DELETEvia regular forms. Use a REST client orfetchwithmethod: 'DELETE'.
Edge case: Some clients expect a JSON body in a
204response. It’s safer to return204with an empty body — Flask’s default is fine.
What you learned & what's next
You’ve just learned how to design restful routes and methods — the foundation of any well-built API. You now know:
- Why consistent resource naming (plural nouns, hyphens, no verbs) prevents chaos
- How to map HTTP methods to CRUD actions on collection vs. item routes
- How to structure nested resources without over-nesting
- How to implement each pattern in Flask (and the same logic applies to FastAPI/Django)
- How to choose between PUT/PATCH or nested/flat routes
- How to debug common route/method mistakes
You’ve completed the practical exercise of building a two-resource API with proper routes and methods. This skill is the single most transferable concept in web development — every framework speaks REST.
Next up in the Python web development track: you’ll connect this design to the rest of the stack — perhaps wiring your routes to a database or adding authentication. With restful routes in place, those layers become readable and testable. Keep your URLs clean, your verbs honest, and your API will scale with your ambitions.
Pro tip: Before you write a new endpoint, write a one-line comment:
# GET /authors/123 — retrieve one author. If that line feels awkward, your route is wrong. Fix it before you code it.
Practice recap
Open your existing Flask or FastAPI project and pick one resource that has grown messy. Refactor its endpoints to follow the plural-noun + method mapping you just learned. Write a small table in your README documenting each endpoint and its expected status codes. Then test all five CRUD calls with curl or Insomnia — check that you get 201 on create, 404 on missing items, and 204 on delete.
Common mistakes
- Using verbs in URLs like
/getUsersor/createUser— the HTTP method already tells the client what you’re doing. - Nesting resources too deep, e.g.,
/users/1/posts/2/comments/9— flatten and use query params unless the sub-resource is meaningless without the parent. - Returning
200 OKfor a POST that creates a resource — the correct code is201 Created; clients may cache or behave differently. - Using
PUTfor partial updates — that’s whatPATCHis for;PUTshould replace the whole entity. - Forgetting to set
Content-Type: application/jsonon client requests, causing400errors withrequest.get_json().
Variations
- Class-based views (Flask's
MethodViewor Django REST Framework'sAPIView) organize the five CRUD methods into a single class, reducing duplication. - API versioning with a prefix like
/v1/usersis a common pattern when you need to evolve the contract without breaking existing clients. - Using a single generic CRUD factory (e.g., a
register_crud_routes(app, resource_name, model)) can generate standard routes dynamically — handy for prototypes but less explicit.
Real-world use cases
- A mobile app backend that needs a standard
POST /users/registerandGET /users/me— consistent routes make client SDK generation trivial. - An e-commerce API handling orders:
GET /orders?status=paid,PATCH /orders/123for shipping updates — clear, predictable endpoints for internal and third-party integrations. - A team migrating from a legacy
RPC-styleAPI (/getUserById.php?id=5) to a RESTful design; this pattern becomes the team's standard for all new microservices.
Key takeaways
- RESTful routes treat resources as nouns: plural, lowercase, hyphenated — the HTTP method is the verb that says what to do.
- Every CRUD action maps to a predictable method + route combination:
GET/POST /users,GET/PUT/PATCH/DELETE /users/{id}. - Nest only when there’s true ownership, and never deeper than two levels — otherwise your URLs become rigid and unreadable.
- Choose
PUTfor full replacement,PATCHfor partial updates, and always return meaningful status codes (201,204,404). - Consistent route design turns your API into self-documenting code — new endpoints become guessable, testable, and easy to extend.
- Query parameters are for filtering, sorting, and pagination — never put actions in the URL.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.