JSON Requests and Responses in Axum With Serde Step by Step
Accept and return JSON in Axum with the Json extractor and Serde: validate input, set status codes, handle bad bodies and avoid common content type mistakes.

JSON is how most Axum services talk, and the happy path takes four lines. The work worth doing is on the other side: what a client sees when it sends something wrong, what your handler can and cannot be tricked into accepting and what leaves the building when you serialize a struct that was meant for the database. This post builds a small user-creation endpoint, sends it every kind of bad request and shows the real response to each, then covers the wire-format attributes and the body size limit. Compiled and run on axum 0.8.9, serde 1 and serde_json 1 with Rust 1.99.
Request in, response out
use axum::{Json, Router, http::StatusCode, routing::post};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct NewUser {
name: String,
email: String,
#[serde(default)]
admin: bool,
}
#[derive(Serialize)]
struct User {
id: u32,
name: String,
email: String,
#[serde(rename = "isAdmin")]
is_admin: bool,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
async fn create_user(Json(input): Json<NewUser>) -> (StatusCode, Json<User>) {
let user = User {
id: 1,
name: input.name,
email: input.email,
is_admin: input.admin,
note: None,
};
(StatusCode::CREATED, Json(user))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/users", post(create_user));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}curl -i -X POST localhost:3000/users \
-H 'content-type: application/json' \
-d '{"name":"Ada","email":"[email protected]"}'HTTP/1.1 201 Created
content-type: application/json
{"id":1,"name":"Ada","email":"[email protected]","isAdmin":false}The tuple return sets the status and the body together, the idiom from routing explained. Json(user) serializes with serde_json and adds the content type for you. The Cargo dependencies are axum = "0.8", tokio with the full features, serde with derive, and nothing else, because Axum depends on serde_json itself. Two attributes are already shaping the output: rename = "isAdmin" gives the field a camelCase wire name while the Rust field stays is_admin, and skip_serializing_if omits note entirely when it is None, so clients see an absent key rather than null.
Five ways a request can be wrong
The four statuses are different on purpose, and an API client benefits from telling them apart. Send each bad request to the endpoint above:
curl -i -X POST localhost:3000/users -d '{"name":"Ada"}'
curl -i -X POST localhost:3000/users -H 'content-type: application/json' -d '{"name":'
curl -i -X POST localhost:3000/users -H 'content-type: application/json' -d '{"name":"Ada"}'
curl -i -X POST localhost:3000/users -H 'content-type: application/json' -d '{"name":"A","email":"e","role":"x"}'
curl -i -X POST localhost:3000/users -H 'content-type: application/json' -d '{"name":5,"email":"e"}'HTTP/1.1 415 Unsupported Media Type
Expected request with `Content-Type: application/json`
HTTP/1.1 400 Bad Request
Failed to parse the request body as JSON: name: EOF while parsing a value at line 1 column 8
HTTP/1.1 422 Unprocessable Entity
Failed to deserialize the JSON body into the target type: missing field `email` at line 1 column 14
HTTP/1.1 422 Unprocessable Entity
Failed to deserialize the JSON body into the target type: role: unknown field `role`, expected one of `name`, `email`, `admin` at line 1 column 30
HTTP/1.1 422 Unprocessable Entity
Failed to deserialize the JSON body into the target type: name: invalid type: integer `5`, expected a string at line 1 column 9Read them as a decision tree. No JSON content type: 415, and the body is never looked at, which is why curl -d without -H fails (curl sends a form content type by default; see form data and file uploads for that route). A body that is not JSON at all: 400. A body that is valid JSON but the wrong shape, whether a missing field, an unknown one or a wrong type: 422, with a message that includes the path to the field and the line and column. The messages are written for developers, which is useful in development and noisy as a public contract; the wrapper in error handling with IntoResponse maps all of these into your own JSON error shape. A client should treat 400 and 422 as "my request is wrong, do not retry".
Separate what comes in from what goes out
The example has two structs, NewUser and User, and that is the most important design decision on this page. A single User struct that derives both Deserialize and Serialize is tempting, and it quietly turns every field of the model into something a client can set. If the database row has id, is_admin and password_hash, a request body of {"is_admin": true} becomes a privilege escalation, and a serialized row leaks the hash. This is called mass assignment, and the type system can prevent it for free: the input type lists exactly what a client may provide. In the example, admin deserializes from a request, which you would not allow on a public endpoint; take it out there, and let only a trusted internal route set it. The output type, likewise, lists exactly what may leave. Resist #[serde(skip)] on a shared struct as a patch, because the next field added is exposed by default; an explicit output struct fails safe.
deny_unknown_fields on the input is the companion guard, and the role request above shows what it does: a client that tries to send a field you did not declare gets a 422 naming the accepted fields instead of having it silently dropped. As with query strings, it costs forward compatibility with clients that send extra fields, so choose it for strict internal APIs and omit it for public ones that must tolerate newer clients. The query-string version is in path and query validation.
Defaults, renames and optional fields
| Attribute | Effect on the wire |
|---|---|
#[serde(rename = "isAdmin")] | the JSON key differs from the Rust field name |
#[serde(rename_all = "camelCase")] | renames every field on the struct at once |
#[serde(default)] | a missing field takes Default::default() instead of being an error |
#[serde(skip_serializing_if = "Option::is_none")] | the key is omitted from output when empty |
#[serde(alias = "old_name")] | input accepts a second name, for migrations |
A distinction that causes real bugs is between a missing field and a null one. Option<String> deserializes both an absent key and an explicit null as None, so a PATCH endpoint cannot tell "do not change the name" from "clear the name". When that difference matters, model it: a three-state enum, or Option<Option<T>> with a custom deserializer, or two separate endpoints. Decide it explicitly instead of discovering it when a client wipes a field it meant to leave alone. For numbers, remember that JSON has no 64-bit integer guarantee: JavaScript clients lose precision above 2^53, so serialize large ids as strings if browsers consume the API.
Limit the body
Axum applies a default limit of 2 MB to extractors that buffer the body, Json included, and answers anything larger with 413. That default is a decent floor and a poor ceiling. A user-creation endpoint expects bytes, not megabytes, and every kilobyte you accept is memory a hostile client can make you allocate. Set the limit to what the route needs:
use axum::extract::DefaultBodyLimit;
let app = Router::new()
.route("/users", post(create_user))
.layer(DefaultBodyLimit::max(1024));curl -i -X POST localhost:3000/users -H 'content-type: application/json' --data-binary @big.jsonHTTP/1.1 413 Payload Too Large
Failed to buffer the request body: length limit exceededThat is the result with a 1 KiB limit and a 2 KB body. Apply a small limit to the router and raise it only on the routes that need more, such as an upload. DefaultBodyLimit governs the buffering extractors; the tower-http RequestBodyLimitLayer rejects earlier, by Content-Length before reading, and also covers streaming bodies, and the two are compared in timeouts and body size caps. Pair size limits with a request timeout, because a client that sends a body one byte per second ties up a connection without ever tripping a size cap.
Returning errors and lists
A handler returns Result<Json<T>, AppError> when it can fail, with the error type from the previous section. For collections, return a struct wrapping the array, {"items": [...], "next": "..."}, instead of a bare array: a top-level array leaves no room to add paging or metadata without breaking clients. Serialize timestamps as RFC 3339 strings, which every language parses, and money as integers in minor units or decimal strings, never floats. If a response is large and not needed in full, stream it as newline-delimited JSON rather than building a giant Vec; for push updates, server-sent events carry JSON per event. Testing these handlers needs no running server, as testing with Tower oneshot shows, and the serialization details behind them are in serde_json basics.
When JSON is the wrong body
Browsers posting an HTML form send application/x-www-form-urlencoded, which Json rejects with the 415 above; use the Form extractor for those. Large binary payloads belong in multipart or a direct upload to object storage, not base64 inside JSON, which costs a third more bytes and a full buffer in memory. And an endpoint whose payload is a stream of records should read the body incrementally instead of extracting Json<Vec<T>>, so one request cannot hold a million structs in memory. The shape of the whole service, with its extractors, errors and state, is in an Axum production service.


