Routing & Extractors
In Phase 1 you stood up an App, handed it to an HttpServer, and wrote a single handler that
answered one path. That's the skeleton. This phase is where it grows a nervous system: how
actix-web decides which handler runs for an incoming request, and how that handler gets the
pieces of the request it cares about without you ever touching the raw bytes.
Hold onto two mental models and everything else falls into place.
📝 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. The shape we're working toward is the familiar one:
Registering routes: two styles, same idea
actix-web gives you two ways to attach a handler to a route. They produce identical behavior — the difference is purely where the route lives in your source. You'll see both in the wild, so learn to read both.
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." We chained two .route(...) calls onto the App, so the
routing table now has 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")] is a macro that bundles the method + path with the
function. The function now is a service, so we register it 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; lots of codebases prefer it
because each handler carries its own route declaration right above it, so you don't have to scroll
to a central list to see what URL a function answers.
💡 Pick one style and stay consistent within a project. Mixing them works, but 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 you're 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 easy to get
wrong. 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. When you cut a v2 of
the API, you add a second scope and leave v1 untouched. Scopes can also carry their own state and
middleware (we'll get there in Phases 4 and 5), which makes them the natural seam for "everything
under this prefix behaves this way."
Extractors: turning a request into typed arguments
Now the payoff. 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 — you
write zero validation code 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, and we
destructure it in one line. The order of the tuple 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: we defined a Pagination struct with #[derive(Deserialize)] so serde knows
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. (You'll
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 just field access
like body.title) gets at the data. A malformed body or a 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 into path, deserialized the JSON body into body — and only then
called create_for_author. You can list as many extractors as you need; they're just function
parameters.
⚠️ There's one real constraint: only one extractor may read the request body.
web::Jsonconsumes the body stream, and a body can only be read once. So a handler can have manyPathandQueryextractors but effectively one body extractor (Json, orForm, orBytes). Asking for two body extractors won't give you the data twice — it's a design error. We'll lean harder onJsonfor both requests and responses in Phase 3: Responders, where we make handlers return JSON too.
Recap
- A route is
method + path → handler.GET /articlesandPOST /articlesare distinct routes; the method is part of the match. - 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, which is how you version and organize an API.- Extractors turn a request into typed arguments:
web::Path(URL segments,.into_inner()),web::Query(query string into aDeserializestruct), andweb::Json(the request body). - Failed extraction means your handler never runs — the client gets a
400automatically, so your function body only sees valid, typed data. - You can combine many extractors as parameters, but only one of them 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."
}
]
← Phase 1: What actix-web Is & Your First Server · Guide overview · Phase 3: Responders →
Check your understanding
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?