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 freeJson<T>as a return type sets the response body andContent-TypeheaderJson<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.