How to handle form data

Web
Use the `axum::extract::Form` extractor with the `form_urlencoded` crate to automatically parse `application/x-www-form-urlencoded` data into a strongly typed struct.

When the browser hits submit

A user fills out a login page and clicks the button. The browser packages the inputs into a string, attaches an HTTP header, and sends it to your server. Your Rust application receives a request with a body full of raw bytes. You need to turn those bytes into a String, verify the credentials, and send back a response. If you try to parse the raw body manually, you will spend hours writing string splitting logic that breaks on the first special character or unicode sequence.

Web frameworks solve this with extractors. An extractor is a type that tells the router how to pull data out of a request and convert it into a Rust type. You declare the type in your handler signature, and the framework does the parsing, validation, and error handling. You only write code for the success path.

The two shapes of form data

HTML forms submit data in two distinct formats. Standard text forms use application/x-www-form-urlencoded. The data arrives as a flat key-value string like username=alice&password=secret123. Spaces become + signs. Special characters get percent-encoded. The entire payload fits in a single buffer.

File uploads use multipart/form-data. The data arrives as a continuous stream of chunks. Each chunk carries its own headers, boundaries, and payload. You cannot load a multi-gigabyte video upload into RAM all at once. You have to process it piece by piece as it flows through the network.

Axum provides two extractors for these scenarios. Form handles the flat key-value strings. Multipart handles the streaming chunks. Picking the wrong one causes immediate rejections before your handler runs.

Parsing standard text forms

Define a struct that matches your HTML input names. Derive Deserialize from serde. Axum reads the body, decodes the percent-encoding, splits the string, and feeds the pairs into serde. serde matches the keys to your struct fields and constructs the instance.

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

/// Represents the exact fields expected from the login form.
#[derive(Debug, Deserialize)]
struct LoginInput {
    username: String,
    password: String,
}

/// Handles the POST request and returns a greeting.
async fn handle_login(Form(input): Form<LoginInput>) -> Html<String> {
    // The Form extractor already parsed the body and validated the structure.
    // If parsing failed, Axum returned a 400 Bad Request automatically.
    Html(format!("Hello, {}!", input.username))
}

#[tokio::main]
async fn main() {
    // Build the router and attach the handler to the /login path.
    let app = Router::new().route("/login", post(handle_login));
    // axum::serve(listener, app).await.unwrap();
}

The pattern match on the left side of the parameter extracts the inner value. The type on the right tells Axum what to do. It looks verbose at first, but it makes the data flow explicit. You can see exactly what type arrives and what type your handler receives.

Let the extractor fail fast. Your handler should only see valid data.

How the extractor works under the hood

When the request arrives, Axum reads the Content-Type header. It sees application/x-www-form-urlencoded and hands the body to the Form extractor. The extractor decodes the percent-encoded string, splits it on ampersands and equals signs, and feeds the key-value pairs into serde.

serde looks at your LoginInput struct. It matches the field names to the incoming keys. If the body contains username=alice but omits password, serde fails to build the struct. Axum catches that failure and immediately sends a 400 Bad Request response. Your handler never runs. This keeps your business logic clean. You do not need to write manual if let checks for missing fields.

If you send a JSON payload to a Form handler, Axum returns a 415 Unsupported Media Type. The extractor checks the header before touching the body. Mismatched types cause immediate rejections.

Convention aside: the community treats serde validation as a first-class concern. Deriving Deserialize only guarantees the shape matches. It does not check if a password is too short or if an email contains an @ symbol. Add a validation crate like validator to run checks after deserialization. Keep the extraction and validation steps separate. Let serde handle the structure. Let your validation logic handle the business rules.

Handling file uploads with streams

File uploads require a different approach. The Multipart extractor streams the body. You iterate over it, process each field, and write it to disk or a database. The stream yields one part at a time. Each part carries metadata like the Content-Type and the Content-Disposition header, which holds the original filename.

use axum::{
    extract::Multipart,
    response::Html,
    routing::post,
    Router,
};
use tokio::fs::File;
use tokio::io::AsyncWriteExt;

/// Handles multipart form data and saves uploaded files to disk.
async fn handle_upload(mut multipart: Multipart) -> Html<String> {
    let mut saved_filename = String::from("none");

    // Iterate through each field in the multipart stream.
    while let Some(mut field) = multipart.next_field().await.unwrap() {
        // Check if the field contains a file or just text data.
        if let Some(filename) = field.content_disposition().unwrap().get_filename() {
            // Read the entire field content into memory for this example.
            // In production, stream chunks to avoid OOM on large files.
            let data = field.bytes().await.unwrap();

            // Create a new file on disk using async I/O.
            let mut file = File::create(filename).await.unwrap();
            file.write_all(&data).await.unwrap();

            saved_filename = filename.to_string();
        }
    }

    Html(format!("Successfully uploaded: {}", saved_filename))
}

The Multipart stream does not buffer the entire request. It yields one field at a time. You must call next_field() in a loop. The stream ends when there are no more parts. If you try to read a field twice, you will get an error. The stream is consumed as you iterate.

Notice the unwrap() calls. They are acceptable in a minimal example, but production code should handle missing filenames, broken streams, and disk write failures gracefully. The tokio::fs module provides async file operations that do not block the runtime thread. If you use the standard std::fs, you will freeze your server while waiting for the disk.

Convention aside: always specify enctype="multipart/form-data" on your HTML <form> tag. Without it, the browser sends the file as a raw string, which breaks the multipart parser and corrupts binary data.

Stream the bytes. Never trust the client to send a small file.

The memory reality of multipart data

The example above calls field.bytes().await, which reads the entire field into a Bytes object. This works for small images or documents. It fails catastrophically for large videos or disk images. The Bytes type allocates heap memory for the entire payload. A single malicious upload can exhaust your server RAM.

Production code streams chunks directly to disk. You read a buffer, write it, and repeat. The memory footprint stays constant regardless of file size.

use axum::body::Body;
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;

// Inside the while loop, replace the bytes() call with:
let mut stream = field.into_stream();
let mut file = File::create(filename).await.unwrap();

while let Some(chunk) = stream.next().await {
    let data = chunk.unwrap();
    file.write_all(&data).await.unwrap();
}

This pattern keeps memory usage flat. You process the data as it arrives over the network. You never hold the entire file in RAM. The futures_util::StreamExt trait provides the next() method for async iteration. Add it to your Cargo.toml if you do not have it.

Do not buffer what you can stream. The network is slow. Memory is finite.

Common failure modes and compiler signals

Form parsing fails in predictable ways. If you forget to derive Deserialize on your struct, the compiler rejects the code with E0277 (trait bound not satisfied). The Form extractor requires the type to implement serde::Deserialize. If you try to use a custom type without the derive macro, the type system stops you before runtime.

Missing fields trigger a 400 Bad Request. If your HTML form has name="user_name" but your struct expects username, serde cannot match them. You can fix this with #[serde(rename = "user_name")] on the struct field. The rename attribute tells serde to accept the HTML name while keeping your Rust naming conventions.

Length limits are another silent killer. A malicious user can send a fifty-megabyte string to a username field. Axum does not limit body size by default. You must add a body limit middleware to prevent memory exhaustion. The middleware rejects oversized requests before they reach your handler.

If you accidentally use std::fs inside an async handler, the runtime will warn you. Blocking the async thread starves other connections. Your server throughput drops to zero while waiting for disk I/O. Always use tokio::fs or async_std::fs in async contexts.

Match the extractor to the Content-Type. The router will handle the rest.

Choosing the right extractor

Use Form when your HTML form sends standard text inputs with application/x-www-form-urlencoded. Use Json when your frontend sends structured data via fetch or axios with Content-Type: application/json. Use Multipart when you need to accept file uploads alongside optional text fields. Use axum::body::Body when you need raw, unprocessed access to the request stream for custom protocols or binary formats.

The extractor you pick dictates how Axum reads the network socket. Pick the one that matches your client's Content-Type. The framework will parse, validate, and reject mismatches automatically. Your handler stays focused on business logic.

Where to go next