What is the difference between axum::Json and axum::Form for extracting typed request bodies?
Json extracts request bodies serialized as JSON while Form extracts bodies encoded as application/x-www-form-urlencoded or multipart/form-data. Both extractors deserialize into typed Rust structures, but they expect different content types and body formatsâJson requires the Content-Type: application/json header and parses JSON text, while Form expects form-encoded data from HTML forms or multipart uploads.
Content-Type Requirements
use axum::{
extract::Json,
http::header::CONTENT_TYPE,
response::IntoResponse,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct UserData {
name: String,
email: String,
}
// Json: Requires application/json
async fn json_handler(Json(data): Json<UserData>) -> impl IntoResponse {
// Request must have:
// Content-Type: application/json
// Body: {"name":"Alice","email":"alice@example.com"}
format!("Received: {} ({})", data.name, data.email)
}
// If Content-Type is not application/json, extraction fails
// If body is not valid JSON, extraction failsuse axum::{
extract::Form,
response::IntoResponse,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct UserData {
name: String,
email: String,
}
// Form: Requires application/x-www-form-urlencoded
async fn form_handler(Form(data): Form<UserData>) -> impl IntoResponse {
// Request must have:
// Content-Type: application/x-www-form-urlencoded
// Body: name=Alice&email=alice%40example.com
format!("Received: {} ({})", data.name, data.email)
}
// If Content-Type is not form-urlencoded, extraction fails
// If body is not valid form encoding, extraction failsJson and Form expect different Content-Type headers and body formats.
Request Body Formats
use axum::{extract::Json, response::IntoResponse};
use serde::Deserialize;
#[derive(Deserialize)]
struct CreateUser {
username: String,
age: u32,
active: bool,
}
// JSON format
async fn create_user_json(Json(user): Json<CreateUser>) -> impl IntoResponse {
// HTTP Request:
// POST /users
// Content-Type: application/json
//
// {
// "username": "alice",
// "age": 30,
// "active": true
// }
format!("Created user: {}", user.username)
}use axum::{extract::Form, response::IntoResponse};
use serde::Deserialize;
#[derive(Deserialize)]
struct CreateUser {
username: String,
age: u32,
active: bool,
}
// Form-urlencoded format
async fn create_user_form(Form(user): Form<CreateUser>) -> impl IntoResponse {
// HTTP Request:
// POST /users
// Content-Type: application/x-www-form-urlencoded
//
// username=alice&age=30&active=true
format!("Created user: {}", user.username)
}JSON uses key-value pairs with quotes and type literals; form encoding uses URL-encoded key=value pairs.
Type Handling Differences
use axum::Json;
use serde::Deserialize;
#[derive(Deserialize)]
struct ComplexData {
id: i64,
name: String,
tags: Vec<String>, // Array in JSON
metadata: Option<String>, // Optional field
}
async fn json_types(Json(data): Json<ComplexData>) {
// JSON body supports:
// - Nested objects
// - Arrays
// - Null for optional fields
// - Various number types
// - Boolean values
// Example body:
// {
// "id": 12345,
// "name": "Item",
// "tags": ["tag1", "tag2"],
// "metadata": null
// }
}use axum::Form;
use serde::Deserialize;
#[derive(Deserialize)]
struct FormData {
id: i64,
name: String,
tags: Vec<String>, // Repeated key in form
metadata: Option<String>, // Omitted key if missing
}
async fn form_types(Form(data): Form<FormData>) {
// Form body supports:
// - String values (parsed to types)
// - Repeated keys for arrays
// - Omitted keys for Option None
// Example body:
// id=12345&name=Item&tags=tag1&tags=tag2
// Note: metadata omitted (becomes None)
}JSON handles complex types naturally; form encoding requires special conventions for arrays and optionals.
Nested Structures
use axum::Json;
use serde::Deserialize;
#[derive(Deserialize)]
struct NestedJson {
user: UserInfo,
settings: Settings,
}
#[derive(Deserialize)]
struct UserInfo {
name: String,
email: String,
}
#[derive(Deserialize)]
struct Settings {
notifications: bool,
theme: String,
}
async fn nested_json(Json(data): Json<NestedJson>) {
// JSON handles nested structures naturally
// {
// "user": {
// "name": "Alice",
// "email": "alice@example.com"
// },
// "settings": {
// "notifications": true,
// "theme": "dark"
// }
// }
}use axum::Form;
use serde::Deserialize;
#[derive(Deserialize)]
struct NestedForm {
#[serde(rename = "user.name")]
user_name: String,
#[serde(rename = "user.email")]
user_email: String,
#[serde(rename = "settings.notifications")]
settings_notifications: bool,
#[serde(rename = "settings.theme")]
settings_theme: String,
}
async fn nested_form(Form(data): Form<NestedForm>) {
// Form encoding flattens nested structures
// user.name=Alice&user.email=alice@example.com&
// settings.notifications=true&settings.theme=dark
// Note: Use serde rename to map flat keys to nested fields
}JSON handles nesting naturally; form encoding flattens with dot notation or requires serde renames.
Multipart Form Data
use axum::{
extract::Multipart,
response::IntoResponse,
};
// Form can also handle multipart/form-data
async fn multipart_handler(mut multipart: Multipart) -> impl IntoResponse {
// multipart/form-data is used for:
// - File uploads
// - Mixed binary and text data
while let Some(field) = multipart.next_field().await.unwrap() {
let name = field.name().unwrap().to_string();
let data = field.bytes().await.unwrap();
println!("Field {} with {} bytes", name, data.len());
}
"Processed multipart data"
}
// Note: Use axum::extract::Multipart for file uploads
// Form<T> works with application/x-www-form-urlencodedFor file uploads and multipart data, use Multipart extractor instead of Form<T>.
Client-Side Usage
// JSON requests (from JavaScript fetch)
async fn send_json() {
let response = fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
name: "Alice",
email: "alice@example.com"
})
});
}
// Form requests (from HTML form or JavaScript)
async fn send_form() {
// HTML form submission
// <form method="POST" action="/submit">
// <input name="name" value="Alice">
// <input name="email" value="alice@example.com">
// </form>
// JavaScript FormData
let form = new FormData();
form.append("name", "Alice");
form.append("email", "alice@example.com");
let response = fetch("/submit", {
method: "POST",
body: form // Automatically sets multipart/form-data
});
}JSON is typical for API clients; forms are typical for HTML pages and some API clients.
Error Handling
use axum::{
extract::Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
#[derive(Deserialize)]
struct User {
name: String,
}
// Json extraction errors are returned as:
// - 415 Unsupported Media Type (wrong Content-Type)
// - 400 Bad Request (invalid JSON or deserialization failed)
async fn json_handler(Json(user): Json<User>) -> impl IntoResponse {
user.name
}
// Custom error handling
async fn json_with_error(Json(result): Json<Result<User, String>>) -> Response {
match result {
Ok(user) => (StatusCode::OK, user.name).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, e).into_response(),
}
}use axum::{
extract::Form,
http::StatusCode,
response::IntoResponse,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct User {
name: String,
}
// Form extraction errors are returned as:
// - 415 Unsupported Media Type (wrong Content-Type)
// - 400 Bad Request (invalid form encoding or deserialization failed)
async fn form_handler(Form(user): Form<User>) -> impl IntoResponse {
user.name
}Both extractors return appropriate HTTP status codes for different failure modes.
Combined Extractor Patterns
use axum::{
extract::{Form, Json},
response::IntoResponse,
routing::post,
Router,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct UserData {
name: String,
email: String,
}
// Accept both JSON and form data
async fn flexible_handler(
// Use Option to check both formats
) -> impl IntoResponse {
// You'd typically use a custom extractor or
// different endpoints for JSON vs form
}
fn app() -> Router {
Router::new()
// Separate endpoints for different content types
.route("/api/users", post(create_user_json)) // JSON
.route("/submit", post(create_user_form)) // Form
}
async fn create_user_json(Json(user): Json<UserData>) -> impl IntoResponse {
format!("JSON user: {}", user.name)
}
async fn create_user_form(Form(user): Form<UserData>) -> impl IntoResponse {
format!("Form user: {}", user.name)
}Best practice is to use separate endpoints for JSON and form data.
Validation and Sanitization
use axum::Json;
use serde::Deserialize;
#[derive(Deserialize)]
struct ValidatedUser {
#[serde(deserialize_with = "validate_name")]
name: String,
#[serde(deserialize_with = "validate_email")]
email: String,
}
fn validate_name<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: String = serde::Deserialize::deserialize(deserializer)?;
if s.len() < 3 {
Err(serde::de::Error::custom("Name too short"))
} else {
Ok(s)
}
}
fn validate_email<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: String = serde::Deserialize::deserialize(deserializer)?;
if s.contains('@') {
Ok(s)
} else {
Err(serde::de::Error::custom("Invalid email"))
}
}
async fn validated_json(Json(user): Json<ValidatedUser>) -> impl IntoResponse {
format!("Valid user: {}", user.name)
}Both Json and Form support serde validation through custom deserializers.
Choosing Between Json and Form
use axum::{extract::Json, extract::Form};
// Use Json when:
// - Building REST APIs
// - Client is JavaScript/TypeScript
// - Complex nested data structures
// - Arrays and complex types
// - Mobile app backends
// - Microservices
// Use Form when:
// - Handling HTML form submissions
// - Traditional web applications
// - Simple key-value data
// - URL-encoded query data
// - Compatibility with existing forms
// - CSRF token handling needed
// Example API endpoint (JSON)
async fn api_create_user(Json(user): Json<UserData>) {
// RESTful API, returns JSON
}
// Example web endpoint (Form)
async fn web_create_user(Form(user): Form<UserData>) {
// HTML form submission, returns HTML redirect
}Use Json for APIs and Form for HTML form handling.
Practical Example: Same Data, Different Endpoints
use axum::{
extract::{Form, Json},
response::IntoResponse,
routing::post,
Router,
http::StatusCode,
};
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)]
struct Product {
name: String,
price: f64,
in_stock: bool,
}
// API endpoint for mobile/frontend apps
async fn api_create_product(Json(product): Json<Product>) -> impl IntoResponse {
// Content-Type: application/json
// Body: {"name":"Widget","price":19.99,"in_stock":true}
// Process product...
(StatusCode::CREATED, Json(product))
}
// Web endpoint for HTML forms
async fn web_create_product(Form(product): Form<Product>) -> impl IntoResponse {
// Content-Type: application/x-www-form-urlencoded
// Body: name=Widget&price=19.99&in_stock=true
// Process product...
// Return redirect or HTML
(StatusCode::SEE_OTHER, [("Location", "/products")])
}
fn app() -> Router {
Router::new()
.route("/api/products", post(api_create_product))
.route("/products/new", post(web_create_product))
}Same data structure works with both extractors; the difference is in the HTTP request format.
Synthesis
Comparison table:
| Aspect | Json |
Form |
|---|---|---|
| Content-Type | application/json |
application/x-www-form-urlencoded |
| Body format | JSON object | URL-encoded key=value pairs |
| Complex types | Arrays, objects, null naturally | Requires conventions |
| Nested structures | Native support | Flattened with dot notation |
| Typical clients | API clients, JavaScript apps | HTML forms, traditional web |
| Use case | REST APIs, microservices | Web forms, traditional apps |
Key insight: Json and Form are both extractors that deserialize request bodies into typed Rust structures, but they expect different wire formats. Json parses application/json contentânatural for nested structures, arrays, and complex typesâmaking it ideal for REST APIs and JavaScript clients. Form parses application/x-www-form-urlencoded contentâthe format HTML forms submit by defaultâmaking it ideal for traditional web applications. Both use serde for deserialization, so the same Rust struct can work with both extractors, but the HTTP request body must match the expected format. Use Json for API endpoints and Form for HTML form submissions; use Multipart for file uploads. The choice is primarily driven by your client: JavaScript apps send JSON, HTML forms send form-encoded data.
