KAHIBARO
Discord Login Register

6.2. Routing

Understanding Routing in Web Backends

Routing is how your backend decides which piece of code should run when a request comes in for a particular URL and HTTP method. It is like a map from URLs to functions.

In this chapter you will learn what routing is, how it works conceptually, and how typical backend frameworks handle it, with many concrete examples.


What Is Routing?

When a client (like a browser) sends a request, it includes:

Your backend needs to answer a question:

β€œFor this method + URL, which function should I call?”

The rules that answer this question are called routes.

A route is a combination of:

We usually describe a route like this:

HTTP MethodPath patternHandler functionDescription
GET/home()Show homepage
GET/aboutabout()Show about page
GET/users/{id}get_user(id)Show one user
POST/userscreate_user()Create a new user
DELETE/users/{id}delete_user(id)Delete a user

The routing system finds the first route that matches the request and calls its handler.

Rule: A route is defined by method + path pattern.
The same path pattern can have multiple routes as long as the HTTP method is different.


Static vs Dynamic Routes

There are two broad kinds of route patterns you will see in all backend frameworks.

Static routes

Static routes match exactly one URL path.

Examples:

They do not contain placeholders. Only this exact path will match.

Conceptually, in Python-like pseudocode:

python
if method == "GET" and path == "/":
    return home()
elif method == "GET" and path == "/about":
    return about()

Concrete example in a Flask-like style:

python
@app.get("/")
def home():
    return "Welcome to my site"
@app.get("/about")
def about():
    return "About this project"

Here:

Dynamic routes

Dynamic routes have variable parts in the path. They match a family of URLs and extract values.

Examples of patterns:

If the actual URL is /users/42 and the pattern is /users/<id>, then id will be "42" inside your handler function.

Pseudocode:

python
if method == "GET" and path matches "/users/<id>":
    id = extract_variable(path, pattern="/users/<id>")
    return get_user(id)

Example:

python
@app.get("/users/<int:user_id>")
def get_user(user_id):
    return f"User id is {user_id}"

Dynamic routes are very important for REST APIs, where you often work with resources identified by IDs.


Order and Specificity of Routes

Most frameworks use some form of pattern matching to decide which route to call. Two important ideas:

  1. More specific routes should usually come before more generic ones, especially in frameworks that match in order.
  2. Patterns must not conflict in a way that confuses the router.

Conflicting routes example

Imagine these routes:

python
@app.get("/users/profile")
def user_profile():
    return "This is your profile page"
@app.get("/users/<username>")
def user_by_username(username):
    return f"User: {username}"

Consider the request GET /users/profile.

You can get surprising behavior if you are not careful with the order and specificity of patterns.

A safe rule to follow:

Rule: Put fixed (static) routes like /users/profile before dynamic routes like /users/<username> when your framework matches in order.

Many modern frameworks compute a "best match" automatically, but you should still design your routes to avoid ambiguity.


Routes, Methods, and Handlers

A single path can have different handlers depending on the HTTP method.

For example, for /users:

python
@app.get("/users")
def list_users():
    ...
@app.post("/users")
def create_user():
    ...

Now:

Table for the same path with different methods:

PathMethodTypical use
/usersGETList all users
/usersPOSTCreate a new user
/usersPUTReplace a user list
/usersDELETEDelete all users (rare)

Remember that in a REST-specific chapter you will learn the semantic meaning of these methods. Here we focus only on their role in routing.

Key idea:

Rule: A route is unique by its (method, path) pair.
Two routes can share the same path as long as methods differ.


Route Parameters vs Query Parameters

Routing is about matching the path, not the query string.

The full URL might be /users/123?active=true&sort=name, but routing usually cares only about /users/123 for matching.

From the routing perspective:

We will later have dedicated chapters for URL and query parameters. For now, notice the separation:

PartExampleUsed for routing?
Path/users/123Yes
Query string?active=true&sort=nameNo
HTTP methodGET, POST, etc.Yes
Headers, bodyVariousNo (for route matching)

So a router typically matches based on:

$$
\text{route} = f(\text{HTTP method}, \text{path})
$$

and ignores query, headers, and body when deciding which handler to call.


Path Patterns and Variables

Routing systems support path parameters inside the pattern. These are placeholders which match part of the path.

Common forms:

The exact syntax depends on the framework. Conceptually they do the same thing.

Example: Single parameter

Pattern: /posts/<id>

Matches:

Not matched:

Example: Multiple parameters

Pattern: /posts/<year>/<month>

Requests and captures:

Request URLCaptured variables
/posts/2024/08year = "2024", month = "08"
/posts/abc/xyzyear = "abc", month = "xyz"

Example: Type-constrained parameters

Some frameworks support types in the pattern, for example:

If you define:

python
@app.get("/users/<int:id>")
def get_user(id: int):
    return f"User id: {id}"

Then:

Rule: Type-constrained path parameters only match values of that type.
If the value cannot be converted, the route is considered not matched.


Trailing Slashes and Route Matching

Many beginners are surprised by the effect of trailing slashes.

Consider this route:

python
@app.get("/users")
def list_users():
    ...

What happens with different paths:

PathMatches /users route? (typical)
/usersYes
/users/Often no
/users/1No

Some frameworks automatically redirect to add or remove a slash, others do not.

To avoid confusion:

Example of two separate routes:

python
@app.get("/users")
def list_users():
    return "List users (no slash)"
@app.get("/users/")
def list_users_slash():
    return "List users (with slash)"

Now:

In practice you usually do not want both to exist. Keeping your routes consistent makes your API easier to use.


Route Conflicts and Ambiguity

A route conflict happens when multiple routes could match the same request. You want to avoid this.

Example of an ambiguous setup

python
@app.get("/files/<path:name>")
def get_file(name):
    ...
@app.get("/files/static")
def static_file():
    ...

For a request GET /files/static:

Depending on the framework:

To reduce ambiguity:

  1. Favor clear, non-overlapping patterns.
  2. Use types or prefixes to separate concerns.
  3. Avoid "catch-all" routes like / <path:rest> unless you really need them.

Catch-all routes

A pattern like:

can match almost any path. It is usually used for special cases, such as:

But it also easily creates conflicts if not handled carefully.

Rule: Use catch-all routes only when necessary, and put them after your specific routes so they do not swallow everything.


Grouping and Organizing Routes

As your application grows, you will have many routes. Good structure is important.

Common ways to organize routes

  1. By resource or feature

For example:

Pseudocode:

python
   # users_routes.py
   @app.get("/users")
   def list_users(): ...
   @app.post("/users")
   def create_user(): ...
  1. Route prefixes

Some frameworks let you define a prefix for a group of routes, for example /api or /api/v1.

Pseudocode:

python
   api = APIRouter(prefix="/api")
   @api.get("/users")
   def list_users(): ...

Now the final path is /api/users.

  1. Separate routers / blueprints / controllers

Frameworks often have concepts like "router objects" or "blueprints" that you can mount under a prefix.

Imagine:

python
   app.include_router(users_router, prefix="/users")
   app.include_router(posts_router, prefix="/posts")

Then routes in users_router are under /users, in posts_router are under /posts.

This kind of organization is vital for larger backends, but the key routing idea remains the same: pattern + method β†’ handler.


Routing and HTTP Status Codes

Routing is closely tied to how you respond with status codes, although full details of status codes are covered in a separate chapter.

Within routing, you commonly see these:

Example

Assume you defined only:

python
@app.get("/users")
def list_users():
    return "Users"

Requests:

RequestResult (typical)Why
GET /users200 OKRoute matches
POST /users405 Method Not AllowedPath exists for GET but not POST
GET /unknown404 Not FoundNo route matches /unknown

The routing system decides if the request is recognized (path and method combination exists). If not, the framework sends an appropriate error code.


Minimal Router Implementation (Conceptual)

To really understand routing, it helps to think about how a simple router could be implemented.

Very simple example using a dictionary

This handles only static paths, no variables:

python
routes = {}
def add_route(method, path, handler):
    routes[(method, path)] = handler
def handle_request(method, path):
    handler = routes.get((method, path))
    if handler is None:
        return "404 Not Found"
    return handler()

Usage:

python
def home():
    return "Hello"
def about():
    return "About"
add_route("GET", "/", home)
add_route("GET", "/about", about)
print(handle_request("GET", "/"))        # "Hello"
print(handle_request("GET", "/about"))   # "About"
print(handle_request("POST", "/about"))  # "404 Not Found"

With very naive dynamic paths

You could implement very basic variables like this:

python
dynamic_routes = []  # list of (method, pattern, handler)
def add_dynamic_route(method, pattern, handler):
    dynamic_routes.append((method, pattern, handler))
def match_dynamic(method, path):
    for m, pattern, handler in dynamic_routes:
        if m != method:
            continue
        if pattern.count("/") != path.count("/"):
            continue
        pattern_parts = pattern.split("/")
        path_parts = path.split("/")
        params = {}
        matched = True
        for p_part, a_part in zip(pattern_parts, path_parts):
            if p_part.startswith("<") and p_part.endswith(">"):
                name = p_part[1:-1]
                params[name] = a_part
            elif p_part != a_part:
                matched = False
                break
        if matched:
            return handler, params
    return None, {}

Then for a request, you would:

  1. Try static routes dictionary.
  2. If no match, try dynamic patterns.

Real frameworks are much more efficient and flexible, but the main idea is still to:

  1. Store a list of route patterns.
  2. On each request, find the first pattern that fits.
  3. Extract parameters.
  4. Call the handler.

Common Routing Pitfalls for Beginners

Here are frequent mistakes newcomers make when defining routes.

1. Duplicate routes

Defining two routes with the same method and path:

python
@app.get("/users")
def list_users():
    ...
@app.get("/users")
def list_users2():
    ...

Typically the second one overwrites the first or the framework raises an error.

Avoid: Duplicate route definitions.

2. Misusing trailing slashes

Defining /users/ but calling /users from the browser or client, and wondering why it returns 404. Or the opposite.

Fix: Pick a trailing slash style and use it consistently.

3. Overly generic routes early

Defining a route like /<anything> before more specific routes and then wondering why the specific ones never run.

Fix: Put generic patterns later and specific ones first.

4. Forgetting the method

Defining only GET /item and then trying to send a POST /item from a client.

Fix: Remember that each method and path combination is a separate route.


Summary

Routing is the part of your backend that:

Key practical ideas:

In later chapters you will build on this foundation when you learn about URL parameters, query parameters, and how frameworks like FastAPI implement routing in detail.

Views: 9

Comments

Please login to add a comment.

Don't have an account? Register now!