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 fails
use 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 fails

Json 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-urlencoded

For 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.