Routing & Extractors
Phase 1 stood up an App, an HttpServer, and one handler answering one path. This phase covers
how actix-web decides which handler runs for an incoming request, and how that handler gets the
request pieces it needs without ever touching the raw bytes.
Two mental models cover everything else.
📝 Mental model #1 - a route is a tiny rule:
method + path → handler. When a request arrives, actix-web walks its list of registered rules looking for the first one whose HTTP method and path pattern match.GET /articlesandPOST /articlesare two different routes even though the path is identical, because the method is part of the key.
📝 Mental model #2 - a handler's parameters are extractors. You don't reach into the request object. Instead you declare what you want as typed arguments -
web::Path<u32>,web::Query<Pagination>,web::Json<NewArticle>- and actix-web extracts those values from the request before your function body ever runs. If extraction fails (bad path segment, malformed JSON), your handler isn't called at all; the framework returns an error response for you. Your function body only ever sees already-valid, already-typed data.
We'll keep growing the articles API from Phase 1 - same familiar shape:
Registering routes: two styles, same idea
actix-web gives you two ways to attach a handler to a route, producing identical behavior - the difference is purely where the route lives in your source. You'll see both in the wild.
Style 1: the builder
You spell out the route on the App itself with .route(path, method().to(handler)):
use ;
async
async
async
What just happened: web::get() builds a route guard that only matches GET requests; .to(list)
says "when it matches, call list." Chaining two .route(...) calls gives the routing table two
rules. The {id} in the second path is a placeholder - it matches any single path segment
(/articles/7, /articles/42) and captures it for an extractor to read later. Routes are checked
top to bottom, and the first match wins.
Style 2: attribute macros
The same routes, but the method and path move up onto the handler as an attribute, and you
register the handler with .service(...):
use ;
async
async
async
What just happened: #[get("/articles")] bundles the method + path with the function. The
function now is a service, registered with .service(list) instead of a .route(...) call.
There's a macro for each verb - #[post(...)], #[put(...)], #[delete(...)], and so on.
Behaviorally this is the same routing table as Style 1; many codebases prefer it because each
handler carries its own route declaration right above it instead of a central list elsewhere.
💡 Pick one style and stay consistent within a project - a reader shouldn't have to check two places to learn where routes are defined. Macros read nicely for CRUD-style apps; the builder shines when composing routes programmatically.
Grouping routes with web::scope
Real APIs version their endpoints and share common prefixes - /api/v1/articles,
/api/v1/authors, and so on. Typing /api/v1/... in front of every route is noisy and error-prone.
web::scope mounts a whole group of routes under a shared prefix:
use ;
async
async
async
What just happened: web::scope("/api/v1") creates a sub-router whose prefix is /api/v1. Every
route registered inside it is relative to that prefix, so /articles actually answers
GET /api/v1/articles and /articles/{id} answers GET /api/v1/articles/7. To cut a v2 of the
API, add a second scope and leave v1 untouched. Scopes can also carry their own state and
middleware (Phases 4 and 5).
Extractors: turning a request into typed arguments
A handler's parameters are how it asks for pieces of the request. The three you'll reach for
constantly are Path, Query, and Json.
web::Path - values from the URL
That {id} placeholder captures a path segment. web::Path<T> pulls it out and parses it into the
type you ask for:
use ;
async
What just happened: the handler declared path: web::Path<u32>. actix-web took the {id} segment
from the URL, parsed it as a u32, and only then called show. path.into_inner() unwraps the
Path wrapper to give you the plain u32 inside. If the URL had been /articles/banana, the parse
would fail, show would never run, and the client would get a 400 Bad Request automatically - zero
validation code needed for "is this segment actually a number."
When a route has several placeholders, ask for a tuple and destructure it:
use ;
// route registered as "/authors/{name}/articles/{id}"
async
What just happened: with two placeholders, web::Path<(String, u32)> captures both in order -
{name} becomes the String, {id} becomes the u32. into_inner() hands back the tuple to
destructure in one line. The tuple's order matches the order the placeholders appear in the path,
not their names, so keep them lined up.
web::Query - values from the query string
Query parameters (?page=2&per_page=20) come in through web::Query<T>, where T is a struct that
derives serde's Deserialize:
use ;
use Deserialize;
async
What just happened: #[derive(Deserialize)] on Pagination tells serde how to build it from
key/value pairs. web::Query<Pagination> reads the query string, matches each field by name, and
parses the values into the field types. A request to /articles?page=2&per_page=20 gives you
q.page == 2 and q.per_page == 20. As with Path, a missing or unparseable field means the
handler is never called and the client gets a 400. (Add serde to Cargo.toml with
features = ["derive"]; we lean on it heavily from here on.)
web::Json - the request body
For POST/PUT bodies sent as JSON, web::Json<T> deserializes the body into your type:
use ;
use Deserialize;
async
What just happened: web::Json<NewArticle> read the raw request body, parsed it as JSON, and
deserialized it into a NewArticle before create ran. body.into_inner() (or field access like
body.title) gets at the data. A malformed body or missing required field produces a 400
automatically - your handler only ever sees a fully-formed NewArticle.
Combining extractors - and the one body rule
Extractors compose: a handler can ask for several at once, and actix-web fills them all in before calling you. A create-under-an-author handler might want the author from the path and the article from the body:
use ;
use Deserialize;
// route: "/authors/{name}/articles"
async
What just happened: the handler declared two extractors as separate parameters. actix-web ran both
- pulled
{name}from the URL intopath, deserialized the JSON body intobody- and only then calledcreate_for_author. List as many extractors as you need; they're just function parameters.
⚠️ One real constraint: only one extractor may read the request body.
web::Jsonconsumes the body stream, and a body can only be read once. A handler can have manyPathandQueryextractors but effectively one body extractor (Json,Form, orBytes) - asking for two won't give you the data twice, it's a design error. More onJsonin Phase 3: Responders.
Recap
- A route is
method + path → handler.GET /articlesandPOST /articlesare distinct routes. - Register routes two ways: the builder (
.route("/articles", web::get().to(list))) or attribute macros (#[get("/articles")]+.service(list)) - same behavior, pick one and stay consistent. web::scope("/api/v1")mounts a group of routes under a shared prefix, for versioning an API.- Extractors turn a request into typed arguments:
web::Path(URL segments,.into_inner()),web::Query(query string into aDeserializestruct),web::Json(the request body). - Failed extraction means your handler never runs - the client gets a
400automatically. - You can combine many extractors as parameters, but only one may read the body.
Quick check
[
{
"q": "What two things together make up a route in actix-web?",
"choices": ["The path and the handler's return type", "The HTTP method and the path pattern", "The query string and the body", "The scope prefix and the port"],
"answer": 1,
"explain": "actix-web matches an incoming request by both its HTTP method and its path pattern, which is why GET /articles and POST /articles are different routes."
},
{
"q": "Which extractor reads values out of the URL path, like the 7 in /articles/7?",
"choices": ["web::Query", "web::Json", "web::Path", "web::Data"],
"answer": 2,
"explain": "web::Path<T> captures the {id} placeholder from the path and parses it into T; you unwrap it with .into_inner()."
},
{
"q": "How many extractors in a single handler may read the request body?",
"choices": ["As many as you want", "Exactly one", "Two, one for JSON and one for form data", "Zero - the body is never extracted"],
"answer": 1,
"explain": "The body stream can only be read once, so a handler can have many Path/Query extractors but effectively only one body extractor such as web::Json."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. What two things together make up a route in actix-web?
2. Which extractor reads values out of the URL path, like the 7 in /articles/7?
3. How many extractors in a single handler may read the request body?