JSON in Actix-web: web::Json Extractor, HttpResponse::json, serde & Error Handling

Last updated:

Actix-web handles JSON through serde — web::Json<T> as a handler parameter deserializes the request body into a typed Rust struct at compile time using #[derive(Deserialize)], returning HTTP 400 automatically if the JSON is malformed or fails validation. HttpResponse::Ok().json(data) serializes any #[derive(Serialize)] struct to JSON and sets Content-Type: application/json in one call, with serde_json doing the work at compile time through zero-cost abstraction. Actix-web leads the TechEmpower benchmarks at 600,000+ requests/second for JSON workloads.

#[serde(rename_all = "camelCase")] converts all snake_case Rust fields to camelCase JSON automatically; #[serde(skip_serializing_if = "Option::is_none")] omits None fields from output. serde_json::Value accepts arbitrary JSON without a typed struct. This guide covers web::Json<T>, HttpResponse::json(), serde field attributes, custom error responses as JSON, serde_json::Value for dynamic JSON, and structuring JSON APIs with Actix-web App.

web::Json<T>: Deserializing JSON Request Bodies

The web::Json<T> extractor is the idiomatic way to receive JSON in an Actix-web handler. Declare it as a handler parameter — Actix-web reads the request body, checks the Content-Type header, and calls serde_json::from_slice to populate your struct. If deserialization fails for any reason, Actix-web returns HTTP 400 before your handler code executes. The inner value is accessed via .into_inner() or by dereferencing the extractor.

// Cargo.toml dependencies:
// actix-web = "4"
// serde = { version = "1", features = ["derive"] }
// serde_json = "1"

use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use serde::{Deserialize, Serialize};

// ── Define the request body struct ────────────────────────────
#[derive(Deserialize)]
struct CreateUserRequest {
    name:  String,
    email: String,
    age:   u8,
}

// ── Define the response struct ─────────────────────────────────
#[derive(Serialize)]
struct UserResponse {
    id:    u64,
    name:  String,
    email: String,
}

// ── Handler: web::Json<T> extracts the JSON body ───────────────
async fn create_user(body: web::Json<CreateUserRequest>) -> impl Responder {
    // body is web::Json<CreateUserRequest>
    // Access the inner value:
    let user = body.into_inner(); // consumes the extractor, returns CreateUserRequest
    // or: let name = &body.name; // borrow without consuming

    // Actix-web already validated: name/email/age are present and typed
    let response = UserResponse {
        id:    1,
        name:  user.name,
        email: user.email,
    };

    // Returns HTTP 200 with Content-Type: application/json
    HttpResponse::Ok().json(response)
}

// ── Register the route ─────────────────────────────────────────
#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .route("/users", web::post().to(create_user))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

// ── Example request ────────────────────────────────────────────
// POST /users
// Content-Type: application/json
// { "name": "Alice", "email": "alice@example.com", "age": 30 }
//
// → 200 OK
// { "id": 1, "name": "Alice", "email": "alice@example.com" }
//
// POST /users with { "name": "Alice" }   (missing required fields)
// → 400 Bad Request  (automatic — handler never runs)

The default payload size limit for web::Json is 256 KB. Increase it by configuring web::JsonConfig and registering it with App::app_data(). Multiple extractors can be combined in a single handler — for example, web::Json<Body>, web::Path<Params>, and web::Query<Query> can all appear as parameters in the same handler function and are resolved independently.

use actix_web::{web, App, HttpServer};

// ── Configure JSON payload limit and custom error handler ──────
let json_cfg = web::JsonConfig::default()
    .limit(1_048_576) // 1 MB (default is 256 KB)
    .error_handler(|err, _req| {
        // Return a JSON error instead of plain text
        let response = actix_web::HttpResponse::BadRequest()
            .json(serde_json::json!({
                "error": "invalid_json",
                "detail": err.to_string(),
            }));
        actix_web::error::InternalError::from_response(err, response).into()
    });

HttpServer::new(move || {
    App::new()
        .app_data(json_cfg.clone()) // register the config
        // routes ...
})
.bind("127.0.0.1:8080")?
.run()
.await

HttpResponse::Ok().json(): Returning JSON Responses

HttpResponse::Ok().json(data) is the standard way to return JSON from an Actix-web handler. It calls serde_json::to_vec on data, sets the body, and adds Content-Type: application/json; charset=utf-8. The method is available on the builder returned by any HttpResponse::<StatusCode>() variant, so you can return any HTTP status with a JSON body in one expression.

use actix_web::{web, HttpResponse, Responder};
use serde::Serialize;

#[derive(Serialize)]
struct Product {
    id:    u64,
    name:  String,
    price: f64,
}

#[derive(Serialize)]
struct ApiError {
    code:    &'static str,
    message: String,
}

// ── Return 200 OK with JSON body ───────────────────────────────
async fn get_product(path: web::Path<u64>) -> impl Responder {
    let id = path.into_inner();

    if id == 0 {
        // 404 Not Found with JSON body
        return HttpResponse::NotFound().json(ApiError {
            code:    "not_found",
            message: format!("Product {} does not exist", id),
        });
    }

    HttpResponse::Ok().json(Product {
        id,
        name:  "Actix Widget".to_string(),
        price: 19.99,
    })
}

// ── 201 Created ────────────────────────────────────────────────
async fn create_product(body: web::Json<Product>) -> impl Responder {
    let product = body.into_inner();
    // ... save to database ...
    HttpResponse::Created()
        .insert_header(("Location", format!("/products/{}", product.id)))
        .json(product)
}

// ── Return typed web::Json<T> directly ────────────────────────
// Actix-web derives Responder for web::Json<T>
async fn list_products() -> web::Json<Vec<Product>> {
    web::Json(vec![
        Product { id: 1, name: "Widget A".to_string(), price: 9.99 },
        Product { id: 2, name: "Widget B".to_string(), price: 14.99 },
    ])
    // Always returns 200 OK — use HttpResponse builder for other codes
}

// ── Use serde_json::json! macro for ad-hoc responses ──────────
async fn health_check() -> impl Responder {
    HttpResponse::Ok().json(serde_json::json!({
        "status": "ok",
        "version": env!("CARGO_PKG_VERSION"),
    }))
}

For handlers that may fail, use Result<HttpResponse, MyError> as the return type. Actix-web calls ResponseError::error_response() on the Err variant automatically, so your error type controls the JSON shape of every error in the API. This pattern is covered in detail in the Custom JSON Error Responses section below.

serde Attributes: rename_all, skip_serializing_if, default

Serde attributes control how Rust field names map to JSON keys and how Option, Vec, and other types serialize. Place them above the struct or above individual fields using the #[serde(...)] syntax. They are processed entirely at compile time — the serde_derive macro generates custom Serialize and Deserialize implementations that embed the name mappings directly in code, with no runtime lookup tables.

use serde::{Deserialize, Serialize};

// ── rename_all: convert all field names ────────────────────────
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Order {
    order_id:   u64,    // serializes as "orderId"
    created_at: String, // serializes as "createdAt"
    total_usd:  f64,    // serializes as "totalUsd"
}

// ── skip_serializing_if: omit None/empty from output ──────────
#[derive(Serialize, Deserialize)]
struct UserProfile {
    id:   u64,
    name: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    bio: Option<String>, // omitted from JSON if None

    #[serde(skip_serializing_if = "Vec::is_empty")]
    tags: Vec<String>,   // omitted from JSON if empty

    #[serde(skip_serializing_if = "Option::is_none")]
    avatar_url: Option<String>,
}

// ── default: use Default::default() if field is missing ───────
#[derive(Serialize, Deserialize)]
struct SearchParams {
    query: String,

    #[serde(default)]              // uses u32::default() = 0 if missing
    page: u32,

    #[serde(default = "default_limit")]
    limit: u32,                    // uses default_limit() if missing
}

fn default_limit() -> u32 { 20 }

// ── rename: override a single field name ──────────────────────
#[derive(Serialize, Deserialize)]
struct ApiResponse {
    #[serde(rename = "userId")]
    user_id: u64,

    #[serde(rename(serialize = "createdAt", deserialize = "created_at"))]
    created_at: String, // different names for in vs out
}

// ── flatten: inline nested struct fields ──────────────────────
#[derive(Serialize, Deserialize)]
struct Timestamps {
    created_at: String,
    updated_at: String,
}

#[derive(Serialize, Deserialize)]
struct Article {
    id:    u64,
    title: String,

    #[serde(flatten)]
    timestamps: Timestamps, // inlined: { "created_at": ..., "updated_at": ... }
}

// ── deny_unknown_fields: reject extra JSON keys ────────────────
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct StrictInput {
    name:  String,
    email: String,
    // Any JSON key not listed here causes a 400 deserialization error
}

A critical combination for REST APIs: #[serde(rename_all = "camelCase")] on all request and response structs ensures your Rust code uses idiomatic snake_case while your JSON API surface uses camelCase — the JavaScript convention. Combine with #[serde(skip_serializing_if = "Option::is_none")] to produce clean JSON output without null fields, reducing payload size and making responses easier to consume.

Custom JSON Error Responses with ResponseError

Implementing actix_web::ResponseError for your error types is the idiomatic way to return structured JSON errors. Define a custom error enum, implement std::fmt::Display and ResponseError, and return Result<HttpResponse, MyError> from handlers. Actix-web calls error_response() on Err values automatically and uses status_code() to set the HTTP status.

use actix_web::{HttpResponse, ResponseError};
use serde::Serialize;
use std::fmt;

// ── Error type ─────────────────────────────────────────────────
#[derive(Debug)]
pub enum AppError {
    NotFound(String),
    Validation(String),
    Internal(String),
}

// ── JSON body for errors ───────────────────────────────────────
#[derive(Serialize)]
struct ErrorBody {
    code:    &'static str,
    message: String,
}

// ── Display — required by ResponseError ───────────────────────
impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::NotFound(msg)    => write!(f, "Not found: {}", msg),
            AppError::Validation(msg)  => write!(f, "Validation error: {}", msg),
            AppError::Internal(msg)    => write!(f, "Internal error: {}", msg),
        }
    }
}

// ── ResponseError — maps errors to HTTP responses ─────────────
impl ResponseError for AppError {
    fn status_code(&self) -> actix_web::http::StatusCode {
        match self {
            AppError::NotFound(_)   => actix_web::http::StatusCode::NOT_FOUND,
            AppError::Validation(_) => actix_web::http::StatusCode::UNPROCESSABLE_ENTITY,
            AppError::Internal(_)   => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
        }
    }

    fn error_response(&self) -> HttpResponse {
        let (code, message) = match self {
            AppError::NotFound(msg)   => ("not_found",          msg.clone()),
            AppError::Validation(msg) => ("validation_error",   msg.clone()),
            AppError::Internal(_)     => ("internal_error",     "An internal error occurred".to_string()),
        };
        HttpResponse::build(self.status_code())
            .json(ErrorBody { code, message })
    }
}

// ── Handler using the error type ──────────────────────────────
use actix_web::{web, HttpResponse as Response};

async fn get_user(path: web::Path<u64>) -> Result<Response, AppError> {
    let id = path.into_inner();

    if id == 0 {
        return Err(AppError::NotFound(format!("User {} not found", id)));
    }

    // Simulate a DB call that may fail
    let user = fetch_user(id)
        .ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;

    Ok(Response::Ok().json(user))
}

// Placeholder — in real code this calls a database
fn fetch_user(id: u64) -> Option<serde_json::Value> {
    if id == 1 { Some(serde_json::json!({ "id": 1, "name": "Alice" })) }
    else { None }
}

// ── Error response JSON shapes ─────────────────────────────────
// 404: { "code": "not_found", "message": "User 99 not found" }
// 422: { "code": "validation_error", "message": "age must be positive" }
// 500: { "code": "internal_error", "message": "An internal error occurred" }

The ? operator works with Result<T, AppError> handlers just as in regular Rust code — any Err propagates to Actix-web's error handling layer, which calls error_response(). For converting third-party errors (database errors, HTTP client errors), implement From<ThirdPartyError> for AppError so the ? operator converts them automatically.

Dynamic JSON with serde_json::Value and json! macro

serde_json::Value is the escape hatch for JSON that cannot be typed at compile time. Use it for proxy endpoints, webhook relay, configuration parsing, or any case where the JSON structure is not known in advance. The json! macro builds Value objects inline with a JSON-like syntax, eliminating verbose builder calls.

use actix_web::{web, HttpResponse, Responder};
use serde_json::{json, Value};

// ── Accept arbitrary JSON ──────────────────────────────────────
async fn echo_json(body: web::Json<Value>) -> impl Responder {
    let mut value = body.into_inner();

    // Access fields by key — returns Value::Null if missing
    let name = value["name"].as_str().unwrap_or("unknown");

    // Mutate the JSON value
    value["echo"] = json!(true);
    value["processed_by"] = json!("actix-web");

    HttpResponse::Ok().json(value)
}

// ── Build JSON with the json! macro ───────────────────────────
async fn build_response() -> impl Responder {
    let items = vec!["alpha", "beta", "gamma"];
    let count = items.len();

    let response = json!({
        "status": "ok",
        "data": {
            "items": items,      // Vec<&str> serializes as JSON array
            "count": count,      // usize serializes as JSON number
            "meta": {
                "page": 1,
                "per_page": 20,
            }
        }
    });

    HttpResponse::Ok().json(response)
}

// ── Merge JSON values ──────────────────────────────────────────
fn merge_json(base: &mut Value, overlay: Value) {
    if let (Value::Object(base_map), Value::Object(overlay_map)) = (base, overlay) {
        for (key, value) in overlay_map {
            base_map.insert(key, value);
        }
    }
}

// ── Navigate nested JSON safely ────────────────────────────────
async fn navigate_json(body: web::Json<Value>) -> impl Responder {
    let payload = body.into_inner();

    // Chained indexing — returns Value::Null for missing keys (no panic)
    let city = payload["address"]["city"]
        .as_str()
        .unwrap_or("Unknown");

    // Array access — index with usize
    let first_tag = payload["tags"][0]
        .as_str()
        .unwrap_or("none");

    HttpResponse::Ok().json(json!({
        "city":      city,
        "first_tag": first_tag,
    }))
}

// ── Convert between Value and typed structs ────────────────────
use serde::Deserialize;

#[derive(Deserialize)]
struct User { id: u64, name: String }

async fn process_dynamic(body: web::Json<Value>) -> impl Responder {
    let value = body.into_inner();

    // Try to deserialize into a typed struct
    match serde_json::from_value::<User>(value.clone()) {
        Ok(user) => HttpResponse::Ok().json(json!({
            "typed": true, "id": user.id, "name": user.name
        })),
        Err(_) => HttpResponse::Ok().json(json!({
            "typed": false, "raw": value
        })),
    }
}

serde_json::from_value<T>(value) converts a Value into a typed struct without touching the wire format — useful when you receive dynamic JSON but want to switch to typed handling for a known subset of fields. The reverse, serde_json::to_value(typed), converts a typed struct to a Value for further manipulation before sending.

Validating JSON Input with the validator crate

The validator crate adds field-level constraint validation on top of serde deserialization. Where serde checks types and structure (returning 400 on failure), validator checks business rules like length, range, format, and custom predicates (returning 422 on failure). The two layers are complementary: serde rejects structurally invalid JSON, validator rejects semantically invalid values.

// Cargo.toml:
// validator = { version = "0.18", features = ["derive"] }

use actix_web::{web, HttpResponse, Responder};
use serde::Deserialize;
use validator::Validate;

// ── Struct with validation constraints ─────────────────────────
#[derive(Deserialize, Validate)]
struct CreateProductRequest {
    #[validate(length(min = 1, max = 200))]
    name: String,

    #[validate(range(min = 0.01, max = 999_999.99))]
    price: f64,

    #[validate(length(min = 0, max = 1000))]
    description: Option<String>,

    #[validate(url)]
    image_url: Option<String>,

    #[validate(length(min = 1, max = 10), custom(function = "validate_tags"))]
    tags: Vec<String>,
}

fn validate_tags(tags: &Vec<String>) -> Result<(), validator::ValidationError> {
    for tag in tags {
        if tag.len() > 50 {
            return Err(validator::ValidationError::new("tag_too_long"));
        }
    }
    Ok(())
}

// ── Handler with validation ────────────────────────────────────
async fn create_product(
    body: web::Json<CreateProductRequest>,
) -> impl Responder {
    let request = body.into_inner();

    // Run validation — returns ValidationErrors on failure
    if let Err(errors) = request.validate() {
        // Convert ValidationErrors to a JSON-friendly map
        let field_errors: std::collections::HashMap<_, _> = errors
            .field_errors()
            .iter()
            .map(|(field, errs)| {
                let messages: Vec<String> = errs
                    .iter()
                    .map(|e| e.message.as_deref().unwrap_or("invalid").to_string())
                    .collect();
                (*field, messages)
            })
            .collect();

        return HttpResponse::UnprocessableEntity().json(serde_json::json!({
            "error": "validation_failed",
            "fields": field_errors,
        }));
    }

    // All constraints passed — proceed with business logic
    HttpResponse::Created().json(serde_json::json!({
        "id":    42,
        "name":  request.name,
        "price": request.price,
    }))
}

// ── Example error response for invalid input ───────────────────
// POST /products { "name": "", "price": -5.0, "tags": [] }
// →  422 Unprocessable Entity
// {
//   "error": "validation_failed",
//   "fields": {
//     "name":  ["length"],
//     "price": ["range"],
//     "tags":  ["length"]
//   }
// }

For cross-field validation — for example, ensuring that end_date is after start_date — implement the Validate trait manually rather than using the derive macro. The manual implementation calls self.validate_fields() for all field-level constraints first, then adds custom checks. This keeps field errors and cross-field errors in the same ValidationErrors structure.

Structuring JSON APIs with App, scope, and web::Data

Production Actix-web JSON APIs organize routes with web::scope() for versioned prefixes, share application state (database pools, configuration) with web::Data<T>, and split handlers into modules. The App::new() builder wires everything together when the server starts. Each worker thread gets its own clone of App, so shared state must implement Send + Sync — wrap non-thread-safe resources in Arc<Mutex<T>>.

use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;

// ── Application state ──────────────────────────────────────────
pub struct AppState {
    // In a real app: database pool, config, HTTP client, etc.
    pub db_url: String,
    pub items:  RwLock<Vec<Item>>,  // RwLock for async-safe reads/writes
}

#[derive(Serialize, Deserialize, Clone)]
pub struct Item {
    pub id:   u64,
    pub name: String,
}

// ── Handlers ───────────────────────────────────────────────────
async fn list_items(data: web::Data<Arc<AppState>>) -> impl Responder {
    let items = data.items.read().await;
    HttpResponse::Ok().json(&*items)
}

async fn create_item(
    data: web::Data<Arc<AppState>>,
    body: web::Json<Item>,
) -> impl Responder {
    let item = body.into_inner();
    let mut items = data.items.write().await;
    items.push(item.clone());
    HttpResponse::Created().json(item)
}

async fn get_item(
    data: web::Data<Arc<AppState>>,
    path: web::Path<u64>,
) -> impl Responder {
    let id = path.into_inner();
    let items = data.items.read().await;
    match items.iter().find(|i| i.id == id) {
        Some(item) => HttpResponse::Ok().json(item),
        None => HttpResponse::NotFound().json(serde_json::json!({
            "error": "not_found",
            "id":    id,
        })),
    }
}

// ── Wire everything together ───────────────────────────────────
#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let state = Arc::new(AppState {
        db_url: "postgres://localhost/myapp".to_string(),
        items:  RwLock::new(vec![
            Item { id: 1, name: "Widget Alpha".to_string() },
        ]),
    });

    HttpServer::new(move || {
        // Configure JSON extractor globally
        let json_cfg = web::JsonConfig::default()
            .limit(524_288) // 512 KB
            .error_handler(|err, _req| {
                let resp = HttpResponse::BadRequest().json(serde_json::json!({
                    "error": "invalid_json",
                    "detail": err.to_string(),
                }));
                actix_web::error::InternalError::from_response(err, resp).into()
            });

        App::new()
            .app_data(web::Data::new(state.clone()))
            .app_data(json_cfg)
            // v1 API scope — all routes prefixed with /api/v1
            .service(
                web::scope("/api/v1")
                    .service(
                        web::scope("/items")
                            .route("",     web::get().to(list_items))
                            .route("",     web::post().to(create_item))
                            .route("/{id}", web::get().to(get_item))
                    )
            )
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

// Route map:
// GET  /api/v1/items       → list_items
// POST /api/v1/items       → create_item
// GET  /api/v1/items/{id}  → get_item

For larger applications, use Actix-web's ServiceConfig pattern to split route registration into modules. Each module defines a pub fn configure(cfg: &mut web::ServiceConfig) function, and App::configure() calls them during startup. This keeps the main App builder readable and allows each module to own its routes independently.

FAQ

How do I deserialize a JSON request body in Actix-web?

Declare web::Json<T> as a handler parameter, where T is a struct that derives serde::Deserialize. Actix-web reads the request body, checks the Content-Type header for application/json, and calls serde_json to parse the bytes into T. If the body is missing, malformed, or fails to deserialize, Actix-web automatically returns HTTP 400 before your handler code runs. The limit for the JSON payload is configurable — the default is 256 KB via JsonConfig::default().limit(bytes). To change it globally, call web::JsonConfig::default().limit(1_048_576) (1 MB) and register it with App::app_data(). Access the inner value by calling .into_inner() on the extractor or by dereferencing it. No manual serde_json::from_str calls are needed.

How do I return a JSON response in Actix-web?

Call HttpResponse::Ok().json(data) where data is any type that derives serde::Serialize. Actix-web serializes the value with serde_json, sets the response body to the resulting bytes, and sets Content-Type: application/json automatically. For other status codes use the matching builder: HttpResponse::Created().json(body) returns 201, HttpResponse::NotFound().json(error) returns 404. You can also return impl Responder from a handler function — Actix-web's impl Responder for HttpResponse handles the conversion. For typed responses, return web::Json<T> directly from the handler: Actix-web serializes it the same way. All serialization code is generated at compile time by serde_derive — there is zero runtime reflection.

How do I rename JSON fields in Actix-web with serde?

Use the #[serde(rename_all = "camelCase")] attribute on the struct to convert all snake_case Rust field names to camelCase JSON keys for both serialization and deserialization in one line. For example, a field named created_at becomes "createdAt" in JSON. To rename a single field, use #[serde(rename = "firstName")] above that field. To use different names for serialization and deserialization, use #[serde(rename(serialize = "createdAt", deserialize = "created_at"))]. Valid rename_all values include "camelCase", "PascalCase", "SCREAMING_SNAKE_CASE", "kebab-case", "lowercase", and "UPPERCASE". These attributes are processed at compile time by the serde_derive macro — they add zero runtime overhead.

How do I handle JSON parse errors in Actix-web?

By default, Actix-web returns a plain-text HTTP 400 response when web::Json<T> fails to parse the request body. To customize the error response — returning JSON with a structured error shape instead — configure a JsonConfig error handler with web::JsonConfig::default().error_handler(|err, req| {'{ ... }'}). The closure receives a JsonPayloadError and the HttpRequest, and must return an actix_web::Error. Construct the error response with HttpResponse::BadRequest().json(your_error_struct).into() and wrap it with actix_web::error::InternalError::from_response(err, response). Register the configured JsonConfig with App::app_data(). The error types exposed by JsonPayloadError include ContentType, Deserialize, Serialize, and Payload.

What is serde_json::Value and when should I use it?

serde_json::Value is an enum that represents any valid JSON value: Null, Bool(bool), Number(Number), String(String), Array(Vec<Value>), or Object(Map<String, Value>). Use it when the JSON schema is dynamic, unknown at compile time, or highly variable — for example, when proxying requests, processing user-supplied JSON, or reading configuration files where keys are not fixed. Access nested values with indexing: value["key"]["nested"] returns a &Value (Null for missing keys). Convert to Rust types with .as_str(), .as_f64(), .as_bool(), etc. — each returns an Option. In Actix-web, accept web::Json<serde_json::Value> to receive arbitrary JSON. Build JSON dynamically with the json! macro: json!({'{"status": "ok", "count": 42}'}) returns a Value without defining a struct. For production APIs, prefer typed structs — they catch shape mismatches at compile time rather than runtime.

How do I validate JSON input in Actix-web?

The most common approach is to add the validator crate alongside serde. Derive #[derive(Deserialize, Validate)] on the request struct, then annotate fields with constraints: #[validate(length(min = 1, max = 100))] on a String, #[validate(range(min = 0.0, max = 999.99))] on a number, #[validate(email)] on an email field. In the handler, call input.validate()? to run all validations — the ? returns an Err(ValidationErrors) that you convert to an HTTP 422 response. As of validator 0.18, ValidationErrors implements std::error::Error, making it compatible with Actix-web's error handling. For cross-field validation, implement the Validate trait manually with custom predicates. Alternatively, encode simple constraints directly in serde using newtype structs or custom deserializers — invalid input is rejected at the serde layer (HTTP 400) before reaching handler logic.

How do I return a JSON error with a custom status code in Actix-web?

Implement actix_web::ResponseError for your custom error type. Define a struct or enum for your errors, implement std::fmt::Display and std::error::Error, then implement ResponseError with two methods: error_response() returns the HttpResponse and status_code() returns the HTTP status. In error_response(), call HttpResponse::build(self.status_code()).json(ErrorBody {'{ code: ..., message: ... }'}) to serialize a structured error body. Return your error type from handlers using Result<HttpResponse, MyError> — Actix-web calls ResponseError::error_response() automatically. This pattern gives you one place to control the JSON shape of every error in your API. For 422 Unprocessable Entity on validation failures, return status_code() = StatusCode::UNPROCESSABLE_ENTITY with a body containing the field-level error details.

How does Actix-web JSON performance compare to other frameworks?

Actix-web consistently ranks at the top of the TechEmpower Framework Benchmarks, handling over 600,000 JSON requests per second for the JSON serialization benchmark on standard hardware. This places it ahead of most Go, Java, and Node.js frameworks. The performance comes from several factors: Rust's zero-cost abstractions eliminate runtime overhead, serde generates JSON serialization code at compile time with no reflection, Actix-web uses tokio's async I/O with a work-stealing thread pool, and the actix-http layer uses highly optimized byte buffers. In the TechEmpower “Single Query” database benchmark, Actix-web with sqlx and PostgreSQL achieves 200,000–400,000 requests per second, also near the top. For comparison, Express.js (Node.js) handles roughly 30,000–60,000 JSON requests per second on the same benchmark hardware — Actix-web is approximately 10× faster for JSON-heavy workloads.

Further reading and primary sources