Routing, the where
of every request.
HTTP methods describe the what of a request — your intent. Routing describes the where: which resource on the server your intent is aimed at. This manual covers routing end to end — static and dynamic routes, path and query parameters, nesting, versioning, and catch-alls — with worked routers in Go and Python.
What Routing Is
Every request carries two things bundled together. The HTTP method answers “what do I want to do?” — fetch, add, update, or delete data. That is your intent, your action. The route answers “where do I want to do it?” — which resource, at which address on the server, you want to perform that action on.
You have to tell the server where to go. Send a GET request (intent: fetch) to the route /users (the address), and the server replies with an array of users. Routing is the machinery that takes the what and the where, maps them to a particular handler, and lets that handler run the business logic and return the data.
Routing is mapping URL paths (plus the HTTP method) to server-side logic — a handler that performs the business logic, database operations, and whatever else is needed, then returns a response. That is all there is to it.
The words used in this manual
- Intent / action — what the request wants to do, expressed by the HTTP method (fetch, add, update, delete).
- Resource — the “thing” the request acts on, usually a noun in the path such as
booksorusers. (Resources are studied properly under REST APIs; here we treat them simply as the route’s target.) - Route / route path / URL path — the address part that says where on the server you are going, e.g.
/api/books. - Handler — the set of instructions the server runs when a request matches a route; it owns the logic for that endpoint.
Method + Path → Handler
This is the heart of routing. The server takes the method and the route and treats the two together as a unique key that maps to one handler. It checks the method first, then the route, concatenates them, and forms a unique routing path.
Consider two requests to the same address. GET /api/books fetches the books; POST /api/books creates one. The route is identical, but the method differs — so the keys are different and they route to different handlers. The methods differentiate between two otherwise identical routes, and the keys never clash.
Routing only needs to know the method as the other half of the key. The common verbs and their intent: GET fetch · POST add/create · PUT / PATCH update · DELETE remove. The deep study of methods belongs to the HTTP topic; here they simply pair with a route to form the key.
Static Routes
A static route is a fixed path with no variable parameters inside it. /api/books is static: the string stays consistent — it is a constant. You always use that same string when you make the request, nothing in it changes, and it always returns the same kind of response. That is exactly why it is called “static.”
A constant path string with no dynamic segments, e.g. /api/books. The first two examples from the source — GET and POST on /api/books — are both static routes.
Matching a static route is a literal string comparison. Most APIs begin here: one endpoint that lists a collection and one that creates an item, both on the same constant path.
Dynamic Routes & Path Parameters
A dynamic route has a variable parameter inside the path, so one pattern can serve many specific resources. GET /api/users/123 fetches the details of one user — the 123 is the user’s ID, which the server extracts from the route to do its work (fetch that user from the database and return them).
On the server side, the route definition for this looks like a method plus a pattern. Written out, the matching part is something like:
r.get("/api/users/:id", handler) // └── ":id" = a dynamic parameter slot
The method part matches GET; the path part matches /api/users/ followed by any string. The colon convention — :id — says “this segment is dynamic; the client supplies a value here.” You will find this :name convention across Node, Python, Go, Rust, and Java; it is an industry-wide practice.
A dynamic value that lives inside the path, right after a forward slash. Because it is part of the route, it is called a path parameter or route parameter, and it carries semantic meaning — it identifies which resource the request is about.
- Everything in a path arrives as a string.
123looks like a number, but in route paths numbers and special characters are all treated as strings; the handler casts it if needed. - Read aloud,
GET /api/users/123says “fetch the data of the user whose ID is 123.” That human-readable construct is the whole idea behind REST-style routing — it gives routes a clear semantic meaning.
Query Parameters
A query parameter is a key/value pair attached to the end of the route after a question mark. Multiple pairs are joined by an ampersand. For example, /api/search?query=some+value — the route is /api/search, and after the ? comes the key query with the value some+value.
Why query parameters exist
POST and PUT requests have a body — a container for sending user-defined data to the server. But GET requests, by REST convention, have no body. So how does a GET send extra values? Query parameters are the answer: they let a GET ship a set of key/value pairs carrying metadata about the request — not the identity of the resource, but instructions for shaping the response.
An optional key=value pair in the query string, used to filter, sort, paginate, or search. Typically used with GET requests because they have no body. Optional, repeatable, and order-independent.
The main example: pagination
Imagine an endpoint that returns a list of books in paged format. The first call, /api/books, takes the server defaults and returns a chunk of data plus some metadata describing the whole set:
{ "data": [ /* …the first 20 books (the default page)… */ ], "total": 100, // how many books exist in total "currentPage": 1, // which page this is (default = 1) "limit": 20, // page size (default limit) "totalPages": 5 // 100 / 20 = 5 pages }
The client uses this response metadata to make the next request. The default needed no parameter; to get page two the client sends /api/books?page=2. The same mechanism carries filters (e.g. filter by a user-defined value) and sort order (ascending or descending) — all as key/value pairs in the query.
Path vs Query Parameters
Both put data in the URL, and it is easy to confuse them, so it is worth being precise. They serve different purposes.
Path parameters serve a particular purpose: a semantic expression of which resource — “the user whose ID is 123.” You could technically push a search value into the path (/api/search/some+value), but it is hard to maintain and defeats the whole purpose of giving routes a clear semantic meaning. Metadata like a search term belongs in the query string instead: /api/search?query=some+value.
Nested Routes
Nesting is not really a separate type of route — it is a practice you will see everywhere. In REST-style APIs, you often nest different resources to express a semantic relationship. Each deeper level narrows the meaning and maps to its own handler, returning a different granularity of data.
Walking the deepest example, /api/users/123/posts/456:
/api/users— a static part; on its own it returns the list of all users.123— a path parameter for the user ID;/api/users/123returns that one user.posts— a static part naming a sub-collection;/api/users/123/postsreturns all posts of that user.456— a path parameter for a specific post; the full route fetches “the post with ID 456 belonging to the user with ID 123.”
That is why it is called a nested route — you nest different types of information at different levels to express different semantic meanings, and each produces a different response. You will see this used pretty much everywhere once an API has even medium complexity.
The Routing Lifecycle
Putting the pieces together, here is the path a single request travels — the routing step in context.
Versioning & Deprecation
Route versioning is a very common practice for managing change in API endpoints. You put a version keyword in the route — /api/v1/products and /api/v2/products. It looks like an ordinary route with a v1 or v2 segment added.
Why bother? Suppose v1 returns each product as { id, name, price }. Later, new requirements arrive — say you now serve a mobile app and must change the response shape, renaming name to title. If you mutate the live route, you break every existing client. Versioning lets both shapes live at once:
| /api/v1/products | /api/v2/products |
|---|---|
{ id, name, price } | { id, title, price } |
This does two things. It expresses intent clearly — v1 of the response and v2 of the response side by side. And it means you did not have to change the whole route (you didn’t need a /api/new-products); you just bumped the version.
Announcing that an endpoint (e.g. v1) is going away once v2 is released. Engineers get a defined migration window to move their requests over to v2; only after that does the team remove v1. Versioning plus deprecation gives a stable workflow for shipping breaking changes without breaking clients in the moment.
Catch-All Routes
A catch-all route handles requests for routes the server does not actually serve. Suppose a request comes in for /api/v3/products but there is no handler for it. The server places a catch-all last, after all the specific routes and methods — typically written with a wildcard, /*. Any request that falls through every earlier match lands there.
Without catch-all handling, the default behaviour is often a null response, which is confusing. With it, the handler returns a user-friendly message — “this route does not exist” / “route not found” — so the client clearly understands the server does not cater to that endpoint.
The * wildcard matches anything, which is exactly why the catch-all must be registered last. Placed early, it would intercept every request before the real routes get a turn. The reliable rule: specific routes first, the catch-all at the very end.
A Router in Go
A router is, at bottom, a small object-oriented dispatch problem: a table from “method + route” to a handler. Go expresses the four OOP pillars through interfaces (abstraction + polymorphism), unexported fields (encapsulation), and struct embedding (composition — Go’s form of inheritance). The comments tagged OOP point out each one.
package main import ("fmt"; "strings") // ABSTRACTION: Handler is an interface — it names a capability // ("be able to Handle a request") without saying how. Any type // with a Handle method IS-A Handler. The router depends only on // this contract, never on a concrete type. type Handler interface { Handle(req Request) Response } type Request struct { Method string Path string Params map[string]string // extracted path params (:id) Query map[string]string // query params (?page=2) } type Response struct { Status int; Body string } // ENCAPSULATION: "routes" is lowercase => unexported => private to // the package. Outsiders cannot touch the table directly; they go // through the methods we expose (Register / Dispatch). type Router struct { routes map[string]Handler // key = "METHOD /route" } func NewRouter() *Router { return &Router{routes: make(map[string]Handler)} } // Register binds method + route -> handler (the dispatch table). func (r *Router) Register(method, pattern string, h Handler) { r.routes[method+" "+pattern] = h // method + path = the unique key } // POLYMORPHISM: Dispatch calls h.Handle(req) without knowing which // concrete type h is. Same call, different behaviour per handler. func (r *Router) Dispatch(req Request) Response { for key, h := range r.routes { method, pattern, _ := strings.Cut(key, " ") if method == req.Method && match(pattern, req.Path, &req) { return h.Handle(req) // polymorphic dispatch } } return Response{404, "route not found"} // catch-all fallback } // match compares "/users/:id" against "/users/123", filling Params. func match(pattern, path string, req *Request) bool { p, q := strings.Split(pattern, "/"), strings.Split(path, "/") if len(p) != len(q) { return false } for i := range p { if strings.HasPrefix(p[i], ":") { // dynamic segment req.Params[p[i][1:]] = q[i] // bind :id => "123" } else if p[i] != q[i] { // static segment must match exactly return false } } return true } // "INHERITANCE" via COMPOSITION: BaseHandler holds shared logic; // concrete handlers EMBED it and reuse its methods. type BaseHandler struct{ Name string } func (b BaseHandler) log(req Request) { fmt.Printf("[%s] %s %s\n", b.Name, req.Method, req.Path) } // GetUser embeds BaseHandler (composition) and satisfies Handler. type GetUser struct{ BaseHandler } func (h GetUser) Handle(req Request) Response { h.log(req) // reused (inherited) behaviour return Response{200, "user id = " + req.Params["id"]} } type ListBooks struct{ BaseHandler } func (h ListBooks) Handle(req Request) Response { h.log(req) page := req.Query["page"] // query param drives pagination if page == "" { page = "1" } return Response{200, "books page " + page} } func main() { r := NewRouter() r.Register("GET", "/api/users/:id", GetUser{BaseHandler{"users"}}) r.Register("GET", "/api/books", ListBooks{BaseHandler{"books"}}) req := Request{Method: "GET", Path: "/api/users/123", Params: map[string]string{}, Query: map[string]string{}} fmt.Println(r.Dispatch(req).Body) // => user id = 123 }
A Router in Python
The same architecture in classic class syntax: an abstract base class for the contract (abstraction), real subclassing (inheritance), overridden methods (polymorphism), and a name-mangled private attribute (encapsulation).
from abc import ABC, abstractmethod from dataclasses import dataclass, field from urllib.parse import urlsplit, parse_qs # ABSTRACTION: Handler is an Abstract Base Class. @abstractmethod # forces every subclass to define handle(). You cannot create a # Handler directly — it is a pure contract. class Handler(ABC): @abstractmethod def handle(self, req: "Request") -> "Response": ... @dataclass class Request: method: str path: str params: dict = field(default_factory=dict) # path params (:id) query: dict = field(default_factory=dict) # query params (?page=2) @dataclass class Response: status: int body: str # INHERITANCE: BaseHandler is a concrete parent holding shared # behaviour (logging). Subclasses inherit and reuse _log(). class BaseHandler(Handler): def __init__(self, name): self._name = name # protected by convention def _log(self, req): print(f"[{self._name}] {req.method} {req.path}") # POLYMORPHISM: each subclass OVERRIDES handle() differently, yet # the router calls them all identically — handler.handle(req). class GetUser(BaseHandler): # IS-A BaseHandler IS-A Handler def handle(self, req): self._log(req) # inherited from parent return Response(200, f"user id = {req.params['id']}") class ListBooks(BaseHandler): def handle(self, req): self._log(req) page = req.query.get("page", ["1"])[0] # query param return Response(200, f"books page {page}") # ENCAPSULATION: self.__routes is name-mangled to _Router__routes, # so it is effectively private. The public surface is register() # and dispatch(); the table stays hidden. class Router: def __init__(self): self.__routes: dict[tuple[str, str], Handler] = {} def register(self, method, pattern, handler): self.__routes[(method, pattern)] = handler # method + path key def dispatch(self, method, url): parts = urlsplit(url) query = parse_qs(parts.query) # ?a=1&b=2 -> dict for (m, pattern), handler in self.__routes.items(): params = self.__match(pattern, parts.path) if m == method and params is not None: return handler.handle( Request(method, parts.path, params, query)) # polymorphic return Response(404, "route not found") # catch-all @staticmethod def __match(pattern, path): p = pattern.strip("/").split("/") q = path.strip("/").split("/") if len(p) != len(q): return None params = {} for seg, val in zip(p, q): if seg.startswith(":"): # dynamic segment params[seg[1:]] = val # bind :id -> "123" elif seg != val: # static must match exactly return None return params if __name__ == "__main__": r = Router() r.register("GET", "/api/users/:id", GetUser("users")) r.register("GET", "/api/books", ListBooks("books")) print(r.dispatch("GET", "/api/users/123").body) # user id = 123 print(r.dispatch("GET", "/api/books?page=2").body) # books page 2
Go reaches it through interfaces + embedding; Python through ABCs + class inheritance. Both are a private dispatch table keyed on method + route, an abstract handler contract, polymorphic dispatch, and a catch-all fallback — the whole routing model from this manual, in code.
Routing Glossary
Every routing term used above, in one place.
| Term | Meaning |
|---|---|
| Routing | Mapping a URL path (+ HTTP method) to a server-side handler / logic. |
| Intent / action | What the request wants to do — fetch, add, update, delete (the HTTP method). |
| Route / route path | The address part saying where on the server — e.g. /api/books. |
| Handler | The instructions that run when a route matches; runs the business logic. |
| Resource | The "thing" a route targets — a noun like users or books. |
| Unique key | Method + route concatenated; what the server looks a handler up by. |
| Static route | Fixed, constant path with no variable parameters. |
| Dynamic route | Route with a variable parameter in the path. |
| Path parameter | A dynamic value inside the path (:id); identifies which resource. Also "route parameter". |
| Query parameter | A key=value pair after ?; metadata for filter/sort/paginate/search. |
| Pagination | Returning a list in pages, driven by query params like page and limit. |
| Nested route | Hierarchy of segments expressing ownership — /users/123/posts/456. |
| Semantic meaning | A route written so it reads like a clear, human-readable statement (the goal of REST routing). |
| Versioning | Putting /v1/, /v2/ in the route to evolve an API safely. |
| Deprecation | Marking an old version as going away; clients get a migration window. |
| Catch-all route | Last-resort /* route returning a friendly "route not found". |
| Wildcard | A token (*) that matches anything; lives in the catch-all. |
A request bundles a method (the what) and a route (the where). The server joins them into a unique key, matches it against its table — static routes first, dynamic next, catch-all last — extracts any path params from the route and query params from the query string, then calls the matched handler. Version the route to evolve, deprecate to retire, and catch-all to fail gracefully.