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

Sponsored

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 /user or /getUsers.
  • Use URL parameters for a specific item: /users/42.
  • Avoid verbs in URLs: /create-user is wrong; POST /users is right.
  • Use hyphens for multi-word resources: /user-profiles, not /user_profiles or /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:

  1. Identify the resource(s). Ask: What nouns does this feature involve? For a blog: users, posts, comments, tags.
  2. Map the resource to a base path. Use plural lowercase + hyphens: /posts, /user-profiles.
  3. 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.
  4. 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.
  5. 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/123 is better than GET /users/1/posts/123 for most reads.
  6. Choose response codes. A successful GET returns 200 OK; a POST that creates returns 201 Created; a DELETE returns 204 No Content; bad input returns 400 Bad Request; a missing item returns 404 Not Found.
  7. 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 of list.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() (or request.form for form data). Remember to set Content-Type: application/json on 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-urlencoded instead of application/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, use PATCH for partial changes, PUT for full replacement.

Symptom: URLs with spaces or special characters break.

  • Fix: URL-encode parameters. In curl, use --data-urlencode; in JavaScript, use encodeURIComponent().

Symptom: DELETE returns 204 but the item still exists (when you test in a browser).

  • Fix: Browsers often can’t send DELETE via regular forms. Use a REST client or fetch with method: 'DELETE'.

Edge case: Some clients expect a JSON body in a 204 response. It’s safer to return 204 with 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 /getUsers or /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 OK for a POST that creates a resource — the correct code is 201 Created; clients may cache or behave differently.
  • Using PUT for partial updates — that’s what PATCH is for; PUT should replace the whole entity.
  • Forgetting to set Content-Type: application/json on client requests, causing 400 errors with request.get_json().

Variations

  1. Class-based views (Flask's MethodView or Django REST Framework's APIView) organize the five CRUD methods into a single class, reducing duplication.
  2. API versioning with a prefix like /v1/users is a common pattern when you need to evolve the contract without breaking existing clients.
  3. 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/register and GET /users/me — consistent routes make client SDK generation trivial.
  • An e-commerce API handling orders: GET /orders?status=paid, PATCH /orders/123 for shipping updates — clear, predictable endpoints for internal and third-party integrations.
  • A team migrating from a legacy RPC-style API (/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 PUT for full replacement, PATCH for 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.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.