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:
[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.
The struct, and to_string
Section titled “The struct, and to_string”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.
Pretty, for humans
Section titled “Pretty, for humans”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.
Skipping None
Section titled “Skipping None”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.
- How to deserialize JSON into a struct — the mirror direction, and the
renameattribute that works both ways - How to read a config file for a CLI — the same derive, with TOML on disk instead of JSON on the wire
- Back to Working with JSON, TOML and CSV