JSON in Axum: Json Extractor, Json Responder, serde & Error Handling
Last updated:
Axum handles JSON with a single Json<T> wrapper that works both as an extractor (function parameter) and a responder (return type). async fn handler(Json(payload): Json<CreateUser>) -> Json<User> deserializes the request body using serde's Deserialize trait and serializes the return value using Serialize — both at compile time with zero runtime reflection. If deserialization fails, axum returns HTTP 422 with a JSON error body automatically. Returning (StatusCode::CREATED, Json(user)) as a tuple sends a custom status code with a JSON body. axum is built on Tower and Hyper, integrating with the full tokio async ecosystem.
#[serde(rename_all = "camelCase")] converts Rust snake_case fields to JSON camelCase globally. This guide covers the Json extractor and responder, serde attributes, custom error types implementing IntoResponse, State for shared data, Router for JSON API structure, and middleware for JSON logging.
Json<T> as an Extractor: Deserializing Request Bodies
The Json<T> extractor reads the request body, checks the Content-Type: application/json header, and deserializes the bytes into T using serde_json. It implements FromRequest, so axum calls it automatically for any handler parameter typed as Json<T>. When deserialization fails for any reason — wrong Content-Type, invalid JSON syntax, missing required field, type mismatch — axum returns a 422 Unprocessable Entity response with a JSON error body describing the failure. No manual error handling is needed for basic structural validation.
use axum::{extract::Json, Router, routing::post};
use serde::Deserialize;
// ── Derive Deserialize — serde generates all code at compile time ──
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
age: Option<u32>, // optional field — missing key is fine
}
// ── Json<T> extractor — destructure with Json(payload) ────────────
// The Json(payload) pattern extracts the inner T out of the wrapper.
// payload is typed as CreateUser in the handler body.
async fn create_user(
Json(payload): Json<CreateUser>,
) -> String {
format!("Creating user: {} <{}>", payload.name, payload.email)
}
// ── Router setup ───────────────────────────────────────────────────
let app = Router::new()
.route("/users", post(create_user));
// ── What happens on bad input ──────────────────────────────────────
// POST /users Content-Type: text/plain → 415 Unsupported Media Type
// POST /users body: "not json" → 422 Unprocessable Entity
// POST /users body: {"email":"x"} → 422 (missing required field "name")
// POST /users body: {"name":1,"email":"x"} → 422 (name must be a string)
// ── Multiple extractors in one handler ────────────────────────────
use axum::{extract::{Path, State}, http::StatusCode};
use std::sync::Arc;
#[derive(Clone)]
struct AppState { db_url: String }
async fn update_user(
Path(user_id): Path<u64>, // path parameter
State(state): State<Arc<AppState>>, // shared state
Json(payload): Json<CreateUser>, // JSON body — must be last
) -> StatusCode {
println!("Updating user {} using {}", user_id, state.db_url);
println!("New name: {}", payload.name);
StatusCode::NO_CONTENT
}
// Note: Json<T> must be the last extractor — it consumes the request bodyThe Json extractor buffers the entire request body in memory before calling serde_json::from_slice. This is fine for typical REST API payloads (a few kilobytes). For large JSON bodies — bulk import endpoints, file-adjacent JSON data — consider reading axum::body::Body as a stream and passing it to serde_json::from_reader to avoid buffering gigabytes in RAM. The Json extractor must always be the last parameter in a handler signature because it consumes the request body; all other extractors (Path, Query, State, Headers) must appear before it.
Json<T> as a Responder: Returning JSON Responses
Returning Json(value) from a handler serializes value with serde_json::to_vec, sets Content-Type: application/json, and sends the bytes with HTTP 200 OK. The Json wrapper implements IntoResponse, the axum trait that converts a handler return value into a hyper::Response. Any type T: Serialize works as the inner value — structs, enums, Vec<T>, HashMap<K, V>, or serde_json::Value for dynamic JSON.
use axum::{extract::Json, routing::get, Router};
use serde::Serialize;
// ── Derive Serialize — serde generates all code at compile time ────
#[derive(Serialize)]
struct User {
id: u64,
name: String,
email: String,
}
// ── Return Json<T> for a 200 OK JSON response ─────────────────────
async fn get_user() -> Json<User> {
Json(User {
id: 42,
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
})
}
// Response: 200 OK Content-Type: application/json
// Body: {"id":42,"name":"Alice","email":"alice@example.com"}
// ── Return a JSON array ────────────────────────────────────────────
async fn list_users() -> Json<Vec<User>> {
Json(vec![
User { id: 1, name: "Alice".to_string(), email: "a@example.com".to_string() },
User { id: 2, name: "Bob".to_string(), email: "b@example.com".to_string() },
])
}
// ── Dynamic JSON with serde_json::Value ───────────────────────────
use serde_json::{json, Value};
async fn dynamic_response() -> Json<Value> {
Json(json!({
"status": "ok",
"count": 2,
"items": ["a", "b"],
}))
}
// ── Result return type — Ok(Json) on success, Err on failure ──────
use axum::http::StatusCode;
async fn maybe_user() -> Result<Json<User>, StatusCode> {
let found = true; // imagine a database lookup
if found {
Ok(Json(User { id: 1, name: "Alice".to_string(), email: "a@example.com".to_string() }))
} else {
Err(StatusCode::NOT_FOUND) // plain 404, no body
}
}When serde_json::to_vec fails inside the Json responder — for example, because a custom Serialize implementation returns an error — axum catches the panic and returns a 500 Internal Server Error response. In practice, derived Serialize implementations never fail for well-typed data. The only way to trigger this in normal usage is returning a serde_json::Value that contains a non-finite float (f64::INFINITY or NaN), which JSON cannot represent. Use serde_json::Value::Number with checked construction if you handle float data from external sources.
Status Codes with JSON: Tuple Return Types
Returning Json<T> always sends HTTP 200. To send a different status code alongside a JSON body, return a tuple: (StatusCode, Json<T>). Axum implements IntoResponse for two-element tuples where the first element provides status and the second provides the body. This pattern covers all common JSON API response shapes: 201 Created for resource creation, 202 Accepted for async work, 400/422 for validation errors, 404 for missing resources, and 500 for server errors.
use axum::{
extract::Json,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct User { id: u64, name: String }
#[derive(Serialize)]
struct Created { id: u64 }
#[derive(Serialize)]
struct ErrorBody { error: String }
// ── 201 Created — resource was created ───────────────────────────
async fn create_user(
Json(payload): Json<User>,
) -> (StatusCode, Json<Created>) {
let new_id = 99_u64; // imagine a DB insert
(StatusCode::CREATED, Json(Created { id: new_id }))
}
// ── 404 Not Found — resource missing ─────────────────────────────
async fn get_user() -> (StatusCode, Json<ErrorBody>) {
(
StatusCode::NOT_FOUND,
Json(ErrorBody { error: "user not found".to_string() }),
)
}
// ── Conditional response — different status, same body type ───────
async fn find_or_create(
Json(payload): Json<User>,
) -> (StatusCode, Json<User>) {
let existing = false; // imagine a lookup
if existing {
(StatusCode::OK, Json(payload))
} else {
(StatusCode::CREATED, Json(payload))
}
}
// ── Adding response headers alongside status + body ───────────────
use axum::http::{HeaderMap, HeaderValue, header};
async fn create_with_location(
Json(payload): Json<User>,
) -> impl IntoResponse {
let id = 42_u64;
let mut headers = HeaderMap::new();
headers.insert(
header::LOCATION,
HeaderValue::from_str(&format!("/users/{}", id)).unwrap(),
);
(StatusCode::CREATED, headers, Json(User { id, name: payload.name }))
}
// Three-element tuple: (StatusCode, HeaderMap, Json<T>) also worksAxum's IntoResponse is implemented for tuples of up to 8 elements, where one element may be a StatusCode, one may be a HeaderMap or individual headers, and one may be the body. The most common patterns in production axum APIs are (StatusCode, Json<T>) for simple error responses and (StatusCode, HeaderMap, Json<T>) for creation responses that include a Location header. For handlers returning multiple possible response shapes, define a custom error type implementing IntoResponse rather than using increasingly complex tuple types.
serde Attributes for JSON Field Control
serde proc-macro attributes control how Rust struct fields map to JSON keys. All processing happens at compile time — the generated serialization and deserialization code is as fast as hand-written C. The most important attributes for axum JSON APIs are rename_all, rename, skip_serializing_if, default, and flatten.
use serde::{Deserialize, Serialize};
// ── rename_all — convert all field names at once ──────────────────
// Options: "camelCase", "snake_case", "PascalCase", "SCREAMING_SNAKE_CASE", etc.
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UserResponse {
user_id: u64, // serializes as "userId"
first_name: String, // serializes as "firstName"
last_name: String, // serializes as "lastName"
created_at: String, // serializes as "createdAt"
}
// ── rename — rename a specific field ─────────────────────────────
#[derive(Serialize, Deserialize)]
struct ApiToken {
#[serde(rename = "access_token")]
token: String,
#[serde(rename = "expires_in")]
expires_in: u32,
token_type: String,
}
// ── skip_serializing_if — omit None fields from JSON output ───────
#[derive(Serialize)]
struct Profile {
id: u64,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
bio: Option<String>, // omitted from JSON when None
#[serde(skip_serializing_if = "Vec::is_empty")]
tags: Vec<String>, // omitted from JSON when empty
}
// ── default — use Default::default() when field is absent ─────────
#[derive(Deserialize)]
struct PaginationParams {
#[serde(default = "default_page")]
page: u32, // defaults to 1 if missing from JSON
#[serde(default = "default_limit")]
limit: u32, // defaults to 20 if missing from JSON
}
fn default_page() -> u32 { 1 }
fn default_limit() -> u32 { 20 }
// ── flatten — merge nested struct fields into parent JSON ─────────
#[derive(Serialize, Deserialize)]
struct Timestamps {
created_at: String,
updated_at: String,
}
#[derive(Serialize, Deserialize)]
struct Post {
id: u64,
title: String,
#[serde(flatten)]
timestamps: Timestamps, // fields appear at the top level in JSON
}
// Serializes as: {"id":1,"title":"Hello","created_at":"...","updated_at":"..."}
// ── alias — accept multiple JSON key names for one field ──────────
#[derive(Deserialize)]
struct FlexInput {
#[serde(alias = "user_id", alias = "userId")]
id: u64, // accepts "id", "user_id", or "userId" from JSON
}#[serde(rename_all = "camelCase")] is the single most impactful serde attribute for REST APIs — it bridges the Rust convention of snake_case identifiers with the JavaScript convention of camelCase JSON keys across an entire struct with one line. Combine it with #[serde(skip_serializing_if = "Option::is_none")] on optional fields to produce clean JSON output that omits null values for absent fields, matching the behavior most API clients expect. The flatten attribute is useful for composing reusable timestamp and audit structs into domain structs without wrapping them in a nested JSON object.
Custom Error Types with IntoResponse
For production axum APIs, define a custom error enum implementing IntoResponse and use Result<Json<T>, AppError> as the handler return type. This pattern centralizes error-to-response mapping, supports the ? operator for propagating errors, and produces consistent JSON error shapes across all handlers. The error type is transparent to axum — it only requires IntoResponse, not any axum-specific trait.
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
// ── Error body — the JSON shape sent to the client ────────────────
#[derive(Serialize)]
struct ErrorBody {
error: String,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<String>,
}
// ── Application error enum ────────────────────────────────────────
enum AppError {
NotFound(String),
BadRequest(String),
Conflict(String),
Internal(String),
}
// ── IntoResponse — maps each variant to (StatusCode, Json) ────────
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, error_body) = match self {
AppError::NotFound(msg) => (
StatusCode::NOT_FOUND,
ErrorBody { error: msg, details: None },
),
AppError::BadRequest(msg) => (
StatusCode::BAD_REQUEST,
ErrorBody { error: msg, details: None },
),
AppError::Conflict(msg) => (
StatusCode::CONFLICT,
ErrorBody { error: msg, details: None },
),
AppError::Internal(msg) => (
StatusCode::INTERNAL_SERVER_ERROR,
ErrorBody {
error: "Internal server error".to_string(),
details: Some(msg), // could omit in production
},
),
};
(status, Json(error_body)).into_response()
}
}
// ── Convert external errors into AppError with From ───────────────
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
AppError::Internal(e.to_string())
}
}
// ── Handler using ? for ergonomic error propagation ───────────────
use serde::{Deserialize};
#[derive(Deserialize)]
struct CreateUser { name: String, email: String }
#[derive(Serialize)]
struct User { id: u64, name: String, email: String }
async fn create_user(
Json(payload): Json<CreateUser>,
) -> Result<(StatusCode, Json<User>), AppError> {
if payload.name.is_empty() {
return Err(AppError::BadRequest("name cannot be empty".to_string()));
}
// db.insert(...)? would convert sqlx::Error → AppError via From
let user = User { id: 1, name: payload.name, email: payload.email };
Ok((StatusCode::CREATED, Json(user)))
}The From<sqlx::Error> for AppError implementation is the key to ergonomic error handling with ?: any sqlx::Error returned by a database query converts to AppError::Internal automatically. In production, log the original error internally and return a generic message to the client rather than leaking internal details. Consider implementing From<AppError> for a shared ProblemDetails struct that follows RFC 7807, which standardizes the JSON error shape for HTTP APIs — this makes error responses predictable across all endpoints and API clients.
Sharing State Across JSON Handlers with State
axum's State extractor injects application-wide shared data — database pools, HTTP clients, configuration, caches — into handlers. State is registered on the Router with .with_state() and extracted in handlers as State(value): State<T>. axum clones the state on each request; wrapping expensive resources in Arc<T>makes cloning O(1) (reference count increment) regardless of the data's size.
use axum::{
extract::{Json, Path, State},
http::StatusCode,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::HashMap;
// ── Application state — wrap in Arc for cheap cloning ─────────────
#[derive(Clone)]
struct AppState {
// In a real app: sqlx::Pool<sqlx::Postgres>
users: Arc<RwLock<HashMap<u64, User>>>,
config: Arc<AppConfig>,
}
#[derive(Clone)]
struct AppConfig {
max_page_size: u32,
}
#[derive(Clone, Serialize, Deserialize)]
struct User { id: u64, name: String, email: String }
// ── Handler: extract State alongside Json ─────────────────────────
async fn list_users(
State(state): State<AppState>,
) -> Json<Vec<User>> {
let users = state.users.read().await;
Json(users.values().cloned().collect())
}
async fn get_user_handler(
Path(id): Path<u64>,
State(state): State<AppState>,
) -> Result<Json<User>, StatusCode> {
let users = state.users.read().await;
users
.get(&id)
.cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
async fn create_user_handler(
State(state): State<AppState>,
Json(payload): Json<User>, // Json<T> must be last extractor
) -> (StatusCode, Json<User>) {
let mut users = state.users.write().await;
let id = users.len() as u64 + 1;
let user = User { id, name: payload.name, email: payload.email };
users.insert(id, user.clone());
(StatusCode::CREATED, Json(user))
}
// ── Build the router and register state ───────────────────────────
#[tokio::main]
async fn main() {
let state = AppState {
users: Arc::new(RwLock::new(HashMap::new())),
config: Arc::new(AppConfig { max_page_size: 100 }),
};
let app = Router::new()
.route("/users", get(list_users).post(create_user_handler))
.route("/users/:id", get(get_user_handler))
.with_state(state); // register state once on the router
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}The compiler enforces state type consistency: if a handler requests State<AppState> but the router registers State<OtherState>, the build fails with a clear type error rather than a runtime panic. For large applications, split state into sub-routers with different state types using Router::with_state()on each sub-router before merging them. axum's state is not a global — it is passed explicitly through the type system, making dependencies visible and testable.
Building JSON APIs with Router and Nested Routes
axum's Router composes HTTP routes into a full JSON API. Routes are defined with .route(path, method_handler) using method routing helpers (get(), post(), put(), patch(), delete()). Sub-routers nest under a path prefix with .nest() and merge at the same level with .merge(). Tower middleware applies to all routes on a router with .layer().
use axum::{
extract::{Json, Path, Query, State},
http::StatusCode,
middleware::{self, Next},
response::Response,
routing::{delete, get, patch, post},
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tower_http::cors::CorsLayer;
#[derive(Clone)]
struct AppState { /* db pool etc */ }
#[derive(Serialize, Deserialize, Clone)]
struct Post { id: u64, title: String, body: String }
#[derive(Deserialize)]
struct Pagination { page: Option<u32>, limit: Option<u32> }
// ── Handler functions ──────────────────────────────────────────────
async fn list_posts(
Query(params): Query<Pagination>,
State(_state): State<Arc<AppState>>,
) -> Json<Vec<Post>> {
let _page = params.page.unwrap_or(1);
let _limit = params.limit.unwrap_or(20).min(100);
Json(vec![])
}
async fn create_post(
State(_state): State<Arc<AppState>>,
Json(payload): Json<Post>,
) -> (StatusCode, Json<Post>) {
(StatusCode::CREATED, Json(payload))
}
async fn get_post(Path(id): Path<u64>) -> Result<Json<Post>, StatusCode> {
Err(StatusCode::NOT_FOUND) // replace with DB lookup
}
async fn update_post(
Path(id): Path<u64>,
Json(payload): Json<Post>,
) -> Json<Post> {
Json(Post { id, ..payload })
}
async fn delete_post(Path(_id): Path<u64>) -> StatusCode {
StatusCode::NO_CONTENT
}
// ── Custom logging middleware ──────────────────────────────────────
async fn log_json_requests(
req: axum::http::Request<axum::body::Body>,
next: Next,
) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
let res = next.run(req).await;
println!("{} {} → {}", method, path, res.status());
res
}
// ── Compose into a versioned JSON API ─────────────────────────────
fn posts_router() -> Router<Arc<AppState>> {
Router::new()
.route("/", get(list_posts).post(create_post))
.route("/:id", get(get_post).patch(update_post).delete(delete_post))
}
fn api_v1_router() -> Router<Arc<AppState>> {
Router::new()
.nest("/posts", posts_router())
// .nest("/users", users_router())
// .nest("/comments", comments_router())
}
#[tokio::main]
async fn main() {
let state = Arc::new(AppState {});
let app = Router::new()
.nest("/api/v1", api_v1_router())
.layer(CorsLayer::permissive()) // Tower CORS middleware
.layer(middleware::from_fn(log_json_requests)) // custom middleware
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listening on :3000");
axum::serve(listener, app).await.unwrap();
}axum's Router is built on Tower's Service trait, which means any Tower-compatible middleware — from tower-http (compression, CORS, request ID, tracing) or custom async functions — applies with .layer(). Middleware added via .layer() on the outermost router wraps all nested routes. For middleware that only applies to specific route groups, add .layer() to the sub-router before nesting it. The layering order matters: layers are applied in reverse order of addition, so the last .layer() call wraps the outermost layer around all requests.
Key Terms
- extractor
- An extractor is a type that implements axum's
FromRequestorFromRequestPartstrait, allowing axum to extract data from the incoming HTTP request and pass it as a handler function parameter automatically. Common extractors includeJson<T>(request body),Path<T>(URL path parameters),Query<T>(query string parameters),State<T>(shared application state), andHeaders(request headers). Extractors that consume the request body (likeJson<T>) implementFromRequestand must appear last in a handler's parameter list. Extractors that only inspect request parts implementFromRequestPartsand may appear in any order. Custom extractors are implemented by writingimpl FromRequest for MyType. - IntoResponse
IntoResponseis the axum trait that converts a handler's return type into ahyper::Response<Body>. Handler functions may return any type implementingIntoResponse— axum calls.into_response()after the handler returns. Built-inIntoResponseimplementations includeJson<T>(JSON body, 200 OK),StatusCode(status code, empty body),String(text/plain body), and tuples like(StatusCode, Json<T>). Custom error types implementIntoResponseto control the exact response shape sent to the client. Theimpl IntoResponsereturn type (using Rust'simpl Trait) is useful for handlers that return different concrete types on different code paths — it lets the compiler unify them into a single opaque response type.- serde
- serde (short for serialize/deserialize) is the Rust ecosystem's standard serialization framework. It defines the
SerializeandDeserializetraits and a data model that intermediate representation formats (JSON, MessagePack, TOML, YAML, Bincode) implement. The#[derive(Serialize, Deserialize)]macro generates trait implementations at compile time via a proc-macro — producing code equivalent to hand-written serialization with zero runtime overhead from reflection or dynamic dispatch.serde_jsonis serde's JSON format crate, providingto_string,from_str,to_writer,from_reader, and the dynamicValueenum. axum'sJson<T>wrapper usesserde_jsoninternally for all serialization and deserialization. - Tower
- Tower is a Rust library of composable, asynchronous middleware components built around the
Servicetrait. A TowerServicetakes a request and asynchronously produces a response, making it composable via wrapping: middleware wraps an inner service, adding behavior before and after calling the inner service. axum is built on Tower — every axumRouterimplementsService, and every axum middleware is a Tower layer. This means any Tower-compatible middleware (fromtower-http— compression, CORS, rate limiting, tracing, request ID — or third-party crates) works directly with axum via.layer(). The Tower ecosystem allows axum APIs to share middleware with other Tower-based servers in the same process without duplication. - JsonRejection
JsonRejectionis the error type returned by axum'sJson<T>extractor when it cannot parse the request body. It is an enum with variants for different failure modes:MissingJsonContentType(Content-Type header is absent or notapplication/json— returns 415),JsonDataError(body is valid JSON but does not match typeT— returns 422),JsonSyntaxError(body is not valid JSON — returns 400), andBytesRejection(body bytes could not be read — returns 500).JsonRejectionimplementsIntoResponse, so axum sends the appropriate error response automatically. To customize the error shape, create a newtype extractor wrappingJson<T>that catchesJsonRejectionand converts it to your preferred error format.- State
State<T>is axum's extractor for injecting shared application state into handlers. State is registered on aRouterwith.with_state(value)and extracted in handler functions asState(value): State<T>. axum clones the state on each request — for this reason, state types typically implementClonecheaply, usually by wrapping the actual data inArc<T>so cloning only increments an atomic reference count. The state type is part of the router's type signature, enforced at compile time: a handler requestingState<AppState>will fail to compile if the router registers a different state type. This type-level enforcement makes dependency injection in axum safe and self-documenting.
FAQ
How do I deserialize a JSON request body in axum?
Add Json<T> as a function parameter, where T implements serde::Deserialize. Axum reads the full request body, checks that Content-Type is application/json, and calls serde_json to deserialize the bytes into T. If the body is malformed, the Content-Type header is missing or wrong, or any required field is absent, axum automatically returns HTTP 422 Unprocessable Entity with a JSON error body describing the problem — no manual error handling required. The struct T must derive #[derive(serde::Deserialize)], which generates all deserialization code at compile time via a proc-macro with zero runtime reflection. For a handler accepting a CreateUser struct: async fn create_user(Json(payload): Json<CreateUser>) -> Json<User>. The Json(payload) destructuring pattern pulls the inner T out of the wrapper so payload is typed directly as CreateUser in the handler body.
How do I return a JSON response in axum?
Return Json(value) from your handler, where value implements serde::Serialize. Axum serializes value using serde_json, sets the response Content-Type to application/json, and sends the result with HTTP 200 OK. The Json wrapper implements the IntoResponse trait, which axum calls automatically when it converts the handler return value into a hyper Response. Your struct must derive #[derive(serde::Serialize)] for Json to work. A minimal example: async fn get_user() -> Json<User> {'{ Json(User { id: 1, name: "Alice".to_string() }) }'}. For handlers that might fail, return Result<Json<T>, AppError> — axum calls IntoResponse on both Ok and Err variants. No explicit status code is needed for 200 responses; the Json responder sets it automatically.
How do I return a JSON error with a custom status code in axum?
Return a tuple of (StatusCode, Json<T>) from your handler. Axum implements IntoResponse for tuples where the first element is a StatusCode and the second is any type implementing IntoResponse. For example: return (StatusCode::NOT_FOUND, Json(ErrorBody {'{ error: "user not found".to_string() }'}));. You can use this pattern inline or wrap it in a custom error type. For handlers using Result, define an AppError enum that implements IntoResponse: the impl converts each error variant into the appropriate (StatusCode, Json<ErrorBody>) tuple. The 11 standard 4xx/5xx status codes in axum map to axum::http::StatusCode constants — NOT_FOUND (404), BAD_REQUEST (400), UNPROCESSABLE_ENTITY (422), INTERNAL_SERVER_ERROR (500), and others. Returning a tuple is the idiomatic axum pattern for any non-200 JSON response.
What happens when JSON deserialization fails in axum?
Axum returns HTTP 422 Unprocessable Entity with a JSON error body automatically. The error body is a serde_json-formatted description of what went wrong — missing field, wrong type, or malformed JSON syntax. This behavior comes from the Json extractor's built-in rejection handling: axum calls Json::from_request, which returns a JsonRejection error if deserialization fails, and JsonRejection implements IntoResponse with a 422 status. If the Content-Type header is missing or is not application/json, the extractor returns 415 Unsupported Media Type before even attempting deserialization. To customize the error response shape — for example, to match a standard RFC 7807 Problem Details format — implement a custom extractor that wraps Json and handles JsonRejection yourself. In practice, the default 422 behavior handles over 95% of API validation scenarios without customization.
How do I share database connections across axum JSON handlers?
Use axum's State extractor to inject shared state into handlers. Define a struct holding your database pool — typically a sqlx::Pool<sqlx::Postgres> from sqlx — then pass it to Router::with_state() when building the router. Handlers receive it as State(db): State<Arc<Pool<Postgres>>>. The State extractor clones the Arc on every request (O(1) reference count increment), so the underlying pool is shared across all concurrent handlers. Your state struct must implement Clone; wrapping in Arc satisfies this for non-Clone types. A complete pattern: define AppState { db: Pool<Postgres> }, create the pool at startup with PgPoolOptions::new().connect(url).await?, call router.with_state(Arc::new(state)), and add State(state): State<Arc<AppState>> as a handler parameter alongside Json(payload): Json<T>. The compiler enforces that every handler extracting Stateuses a type compatible with the router's registered state type — mismatches are compile errors, not runtime panics.
How do I rename JSON fields in axum responses?
Use serde attributes on your struct fields. #[serde(rename = "userId")] renames a single field in both serialization and deserialization. #[serde(rename_all = "camelCase")] on the struct itself converts all snake_case Rust field names to camelCase JSON keys globally — the most common choice for REST APIs that follow JavaScript naming conventions. #[serde(rename_all = "camelCase")] turns user_id into "userId", created_at into "createdAt", and so on for all fields at once. For separate input and output naming, combine #[serde(rename_all = "camelCase")] with #[serde(alias = "user_id")] to accept both camelCase and snake_case in deserialization while always serializing to camelCase. serde processes all rename attributes at compile time via the proc-macro — there is zero runtime overhead compared to not using renames. The serde documentation lists over 30 field and container attributes for fine-grained JSON field control.
How is axum different from actix-web for JSON APIs?
Both axum and actix-web use serde and serde_json for JSON serialization, but they differ in architecture and ergonomics. Axum is built on Tower middleware and Hyper, integrating natively with the tokio async ecosystem and composing middleware as Tower services — a design that allows sharing middleware with any other Tower-based service in the same process. Actix-web uses its own runtime (also built on tokio) with a different middleware model based on actix-web middleware traits. For JSON specifically: axum's Json extractor returns a structured JsonRejection error with a 422 status on failure; actix-web's web::Json extractor returns 400 Bad Request by default. Axum's handler type system enforces extractor compatibility at compile time through the FromRequest and FromRequestParts traits, catching misuse as compiler errors rather than runtime panics. Both frameworks deliver comparable throughput in benchmarks — the choice depends more on ecosystem familiarity and middleware reuse strategy than on raw JSON parsing performance.
How do I validate JSON input in axum?
Axum's built-in Json extractor handles structural validation (correct JSON syntax, required fields present, correct types) automatically, returning 422 on failure. For business-rule validation — minimum lengths, value ranges, email format, cross-field invariants — add the axum-valid crate, which integrates the validator::Validate derive macro with axum extractors. Derive #[derive(Validate)] on your input struct, annotate fields with #[validate(email)], #[validate(length(min = 1, max = 100))], or #[validate(range(min = 0, max = 150))], then use Valid(Json(payload)): Valid<Json<CreateUser>> as your extractor. The Valid wrapper calls .validate() on the inner type after deserialization and returns a 422 with validation error details if any constraint fails. For complex validation needing database lookups — uniqueness checks for email addresses — implement the validation as a service call inside the handler body after the Json extractor succeeds, and return (StatusCode::CONFLICT, Json(error)) if the check fails.
Generate Rust serde structs from JSON automatically
Paste any JSON object into Jsonic's generator to get a complete Rust struct with serde derives instantly.
Open JSON to Rust GeneratorFurther reading and primary sources
- axum Documentation — Official axum crate docs covering extractors, responses, routing, and middleware
- serde Documentation — The serde framework guide: attributes, custom serializers, supported formats, and data model
- serde_json Documentation — serde_json crate docs: from_str, to_string, Value enum, and streaming APIs
- axum Examples (GitHub) — Official axum example projects: JSON API, error handling, middleware, authentication, and more
- tower-http Documentation — Tower HTTP middleware collection: CORS, compression, request ID, tracing, and more for axum