Skip to content
Select theme

How to serialize a struct to JSON

Short answer: Derive serde::Serialize on the struct, then call serde_json::to_string for compact output or serde_json::to_string_pretty when a human will read it. An Option field serialises as null when it is None; add #[serde(skip_serializing_if = "Option::is_none")] and the key disappears instead.

The starting point is the json-demo project from the previous article — cargo new json-demo, with the same two crates in Cargo.toml:

Cargo.toml
[dependencies]
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"

The versions are whatever cargo add resolved on the day; the derive feature is the part that matters.

src/main.rs
use serde::Serialize;
#[derive(Debug, Serialize)]
struct User {
id: u64,
name: String,
active: bool,
}
fn main() {
let user = User {
id: 7,
name: "Grace".to_string(),
active: true,
};
let json = serde_json::to_string(&user).unwrap();
println!("{json}");
}
{"id":7,"name":"Grace","active":true}

Serialize is the mirror of Deserialize: the derive writes the code that turns each field into a key, in the order the fields are declared. to_string returns a Result<String, serde_json::Error>, and unwrap is fine here because the value is built three lines above — JSON heading out over a socket deserves a match instead. Look at what the output does not have: no space after a colon, no newline between fields. serde_json writes the smallest valid JSON it can, which is what a machine on the other end wants and what makes a diff unreadable.

src/main.rs
use serde::Serialize;
#[derive(Debug, Serialize)]
struct User {
id: u64,
name: String,
active: bool,
}
fn main() {
let user = User {
id: 7,
name: "Grace".to_string(),
active: true,
};
println!("{}", serde_json::to_string_pretty(&user).unwrap());
}
{
"id": 7,
"name": "Grace",
"active": true
}

to_string_pretty writes the same value with two-space indentation. A config file a user will edit, or a fixture committed next to the tests — reach for this one. The compact form is for anything a machine parses; the pretty form is for anything a person reads. Nothing else changes: the same keys, in the same order, with the same escaping.

src/main.rs
use serde::Serialize;
#[derive(Debug, Serialize)]
struct WithNull {
id: u64,
nickname: Option<String>,
}
#[derive(Debug, Serialize)]
struct Skipped {
id: u64,
#[serde(skip_serializing_if = "Option::is_none")]
nickname: Option<String>,
}
fn main() {
let null = serde_json::to_string(&WithNull {
id: 7,
nickname: None,
})
.unwrap();
println!("{null}");
let absent = serde_json::to_string(&Skipped {
id: 7,
nickname: None,
})
.unwrap();
println!("{absent}");
let present = serde_json::to_string(&Skipped {
id: 8,
nickname: Some("Amazing Grace".to_string()),
})
.unwrap();
println!("{present}");
}
{"id":7,"nickname":null}
{"id":7}
{"id":8,"nickname":"Amazing Grace"}

By default an Option field becomes null when it is None: the key stays, and the value says “nothing”. skip_serializing_if takes a path to a function that borrows the field and returns a bool — when it returns true, the key is not written at all. Option::is_none is the usual choice, and String::is_empty works the same way for a field that should vanish when it is empty. An absent key and an explicit null are two different payloads, and plenty of clients treat them differently — JavaScript’s undefined versus null is the classic split. Which one to send is a decision, and serde’s default is null; that choice gets its own page later in this chapter.

The next article here renames the keys serde writes — rename, and rename_all for when every key in one payload follows the same convention.