Updated Aug 27, 2026

03. JSON and Serde

📋 Jump to Takeaways

🎁 Serde turns a struct into JSON and back with two derive macros. No schema files, no code generation step, no runtime reflection.

Serde Basics

Add #[derive(Serialize, Deserialize)] to any struct:

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct Link {
    code: String,
    url: String,
    clicks: u64,
}

That's it. Serde generates the serialization code at compile time — zero runtime overhead.

Json Extractor and Response

Axum's Json<T> works in both directions:

  • As a response: wraps a value and sets Content-Type: application/json
  • As an extractor: deserializes the request body into T
use axum::{Json, extract::Path, http::StatusCode};
use serde::{Serialize, Deserialize};

#[derive(Serialize)]
struct Link {
    code: String,
    url: String,
    clicks: u64,
}

#[derive(Deserialize)]
struct CreateLink {
    url: String,
}

async fn get_link(Path(code): Path<String>) -> Json<Link> {
    Json(Link {
        code,
        url: "https://example.com".to_string(),
        clicks: 42,
    })
}

async fn create_link(Json(body): Json<CreateLink>) -> (StatusCode, Json<Link>) {
    let link = Link {
        code: "abc123".to_string(),
        url: body.url,
        clicks: 0,
    };
    (StatusCode::CREATED, Json(link))
}
curl http://localhost:3000/links/abc123
# {"code":"abc123","url":"https://example.com","clicks":42}

curl -X POST http://localhost:3000/links \
  -H "Content-Type: application/json" \
  -d '{"url":"https://rust-lang.org"}'
# {"code":"abc123","url":"https://rust-lang.org","clicks":0}

Renaming Fields

By default Serde uses the struct field names as JSON keys. Use #[serde(rename_all)] to change the convention:

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Link {
    short_code: String,   // serializes as "shortCode"
    long_url: String,     // serializes as "longUrl"
    click_count: u64,     // serializes as "clickCount"
}

Or rename a single field:

#[serde(rename = "url")]
long_url: String,

Skipping Fields

#[derive(Serialize)]
struct Link {
    code: String,
    url: String,
    #[serde(skip)]
    internal_id: u64,    // never appears in JSON output
}

Optional Fields

#[derive(Serialize, Deserialize)]
struct UpdateLink {
    url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    label: Option<String>, // omitted from JSON if None
}

skip_serializing_if is how you avoid "label": null in your responses when the field has no value.

Nested Structs

Serde handles nesting automatically:

#[derive(Serialize)]
struct LinkResponse {
    data: Link,
    meta: Meta,
}

#[derive(Serialize)]
struct Meta {
    total: u64,
}

The entire tree serializes recursively — no extra work needed.

Key Takeaways

  • #[derive(Serialize, Deserialize)] on a struct gives you JSON for free
  • Json<T> as a return type sets the response body and Content-Type header
  • Json<T> as a function parameter deserializes the request body
  • #[serde(rename_all = "camelCase")] changes all field names at once
  • #[serde(skip)] excludes a field from serialization
  • #[serde(skip_serializing_if = "Option::is_none")] omits null fields from output

🎁 What happens when the client sends invalid JSON, or the URL they submit is malformed? Right now your handler panics or returns garbage. Next up: building an error handling layer that returns consistent, meaningful error responses.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy