Skip to main content
Axum provides extractors for handling both URL-encoded forms and multipart form data (commonly used for file uploads).

URL-encoded forms

The Form extractor deserializes form data from requests:

Basic form handling

1

Define your form data structure

Create a struct that implements Deserialize:
use serde::Deserialize;

#[derive(Deserialize)]
struct SignUpForm {
    username: String,
    email: String,
    password: String,
}
2

Use the Form extractor

Extract form data in your handler:
use axum::{Form, response::Html};

async fn accept_form(Form(input): Form<SignUpForm>) -> Html<String> {
    Html(format!(
        "Welcome, {}! We'll send confirmation to {}",
        input.username, input.email
    ))
}
3

Create routes

Set up GET and POST routes for your form:
use axum::{routing::get, Router};

let app = Router::new()
    .route("/signup", get(show_form).post(accept_form));

Form data sources

The Form extractor automatically handles data from different sources based on the request method:
// Form data comes from query string
// GET /search?q=axum&page=1

#[derive(Deserialize)]
struct SearchQuery {
    q: String,
    page: Option<u32>,
}

async fn search(Form(query): Form<SearchQuery>) -> String {
    format!("Searching for '{}' on page {}", query.q, query.page.unwrap_or(1))
}
For POST requests, the Form extractor requires the Content-Type header to be application/x-www-form-urlencoded. Otherwise, it will reject the request with a 415 Unsupported Media Type error.

Complete form example

use axum::{
    extract::Form,
    response::Html,
    routing::get,
    Router,
};
use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct ContactForm {
    name: String,
    email: String,
    message: String,
}

async fn show_form() -> Html<&'static str> {
    Html(r#"
        <!doctype html>
        <html>
            <body>
                <form action="/contact" method="post">
                    <label>
                        Name:
                        <input type="text" name="name" required>
                    </label>
                    <label>
                        Email:
                        <input type="email" name="email" required>
                    </label>
                    <label>
                        Message:
                        <textarea name="message" required></textarea>
                    </label>
                    <button type="submit">Send</button>
                </form>
            </body>
        </html>
    "#)
}

async fn handle_form(Form(form): Form<ContactForm>) -> Html<String> {
    println!("Received: {form:?}");
    Html(format!("<h1>Thank you, {}!</h1>", form.name))
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/contact", get(show_form).post(handle_form));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();
    axum::serve(listener, app).await;
}

Form responses

You can also use Form to return form-encoded data:
use axum::Form;
use serde::Serialize;

#[derive(Serialize)]
struct ApiResponse {
    success: bool,
    message: String,
}

async fn api_handler() -> Form<ApiResponse> {
    Form(ApiResponse {
        success: true,
        message: "Operation completed".to_string(),
    })
}
This will automatically set Content-Type: application/x-www-form-urlencoded.

Multipart form data

For file uploads, use the Multipart extractor:

Basic file upload

use axum::{
    extract::Multipart,
    response::Html,
    routing::get,
    Router,
};

async fn show_upload_form() -> Html<&'static str> {
    Html(r#"
        <!doctype html>
        <html>
            <body>
                <form action="/upload" method="post" enctype="multipart/form-data">
                    <label>
                        Upload file:
                        <input type="file" name="file" multiple>
                    </label>
                    <button type="submit">Upload</button>
                </form>
            </body>
        </html>
    "#)
}

async fn accept_upload(mut multipart: Multipart) {
    while let Some(field) = multipart.next_field().await.unwrap() {
        let name = field.name().unwrap().to_string();
        let file_name = field.file_name().unwrap().to_string();
        let content_type = field.content_type().unwrap().to_string();
        let data = field.bytes().await.unwrap();

        println!(
            "Field: {name}, File: {file_name}, Type: {content_type}, Size: {} bytes",
            data.len()
        );
    }
}

let app = Router::new()
    .route("/upload", get(show_upload_form).post(accept_upload));

Processing multipart fields

// Get entire field contents as bytes
while let Some(field) = multipart.next_field().await.unwrap() {
    let data = field.bytes().await.unwrap();
    println!("Received {} bytes", data.len());
}

Saving uploaded files

use axum::extract::Multipart;
use std::io::Write;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;

async fn save_file(mut multipart: Multipart) -> String {
    while let Some(field) = multipart.next_field().await.unwrap() {
        let file_name = field.file_name().unwrap().to_string();
        let data = field.bytes().await.unwrap();

        // Save to disk
        let path = format!("./uploads/{}", file_name);
        tokio::fs::write(&path, &data).await.unwrap();
        
        return format!("Saved file: {file_name}");
    }
    "No file uploaded".to_string()
}
Always validate and sanitize file names before saving to prevent directory traversal attacks.

Handling mixed form fields

Multipart forms can contain both files and regular fields:
use axum::extract::Multipart;

#[derive(Debug)]
struct FileUpload {
    title: String,
    description: String,
    file_data: Vec<u8>,
    file_name: String,
}

async fn handle_mixed_form(mut multipart: Multipart) -> String {
    let mut upload = FileUpload {
        title: String::new(),
        description: String::new(),
        file_data: Vec::new(),
        file_name: String::new(),
    };

    while let Some(field) = multipart.next_field().await.unwrap() {
        let name = field.name().unwrap().to_string();
        
        match name.as_str() {
            "title" => upload.title = field.text().await.unwrap(),
            "description" => upload.description = field.text().await.unwrap(),
            "file" => {
                upload.file_name = field.file_name().unwrap().to_string();
                upload.file_data = field.bytes().await.unwrap().to_vec();
            }
            _ => {}
        }
    }

    format!(
        "Uploaded '{}': {} ({} bytes)",
        upload.title,
        upload.file_name,
        upload.file_data.len()
    )
}

File size limits

By default, Axum limits request bodies to 2MB. Configure this for file uploads:
use axum::{
    extract::DefaultBodyLimit,
    Router,
};
use tower_http::limit::RequestBodyLimitLayer;

let app = Router::new()
    .route("/upload", post(accept_upload))
    // Disable default limit
    .layer(DefaultBodyLimit::disable())
    // Set custom limit (250 MB)
    .layer(RequestBodyLimitLayer::new(250 * 1024 * 1024));
The DefaultBodyLimit::disable() must come before RequestBodyLimitLayer in the middleware chain.

Form validation

Integrate with validation libraries like validator:
use axum::{Form, http::StatusCode, response::IntoResponse};
use serde::Deserialize;
use validator::Validate;

#[derive(Deserialize, Validate)]
struct NewUser {
    #[validate(length(min = 3, max = 20))]
    username: String,
    
    #[validate(email)]
    email: String,
    
    #[validate(length(min = 8))]
    password: String,
}

async fn create_user(Form(user): Form<NewUser>) -> impl IntoResponse {
    match user.validate() {
        Ok(_) => {
            // Process valid user
            (StatusCode::CREATED, "User created")
        }
        Err(e) => {
            // Return validation errors
            (StatusCode::BAD_REQUEST, format!("Validation error: {e}"))
        }
    }
}

Testing form handlers

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        body::Body,
        http::{self, Request, StatusCode},
    };
    use http_body_util::BodyExt;
    use tower::ServiceExt;

    #[tokio::test]
    async fn test_form_submission() {
        let app = Router::new()
            .route("/", post(accept_form));

        let response = app
            .oneshot(
                Request::post("/")
                    .header(
                        http::header::CONTENT_TYPE,
                        mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(),
                    )
                    .body(Body::from("username=john&email=john@example.com"))
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_eq!(response.status(), StatusCode::OK);
        
        let body = response.into_body().collect().await.unwrap().to_bytes();
        let body_str = std::str::from_utf8(&body).unwrap();
        assert!(body_str.contains("john"));
    }
}
Missing Content-Type header: POST forms require Content-Type: application/x-www-form-urlencoded. Check your client is sending this header.Form extractor must be last: If you have multiple extractors, Form and Multipart must come last since they consume the request body.Field name mismatch: Ensure your struct field names match the HTML form field names, or use serde’s #[serde(rename = "...")] attribute.Large file uploads fail: Increase the body size limit using DefaultBodyLimit and RequestBodyLimitLayer.