JSON with serde
serde turns your structs into JSON (or TOML, CSV, and more) and back again. You describe the shape once with a derive, and the derive macro writes the code when you compile.
RememberDerive Serialize and Deserialize, then use serde_json::from_str and to_string.
people-json/ ├── examples/ │ ├── lenient.rs missing, null and unknown fields │ ├── missing.rs the gotcha: cargo run --example missing │ ├── optional.rs the fix │ ├── shapes.rs enums as JSON │ ├── strict.rs bad input and deny_unknown_fields │ └── untyped.rs serde_json::Value ├── src/ │ └── main.rs the program on this page ├── Cargo.lock created by Cargo └── Cargo.toml dependencies go here
[package]name = "people-json"version = "0.1.0"edition = "2024"
[dependencies]serde = { version = "1.0.229", features = ["derive"] }serde_json = "1.0.154"use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]#[serde(rename_all = "camelCase")] // full_name <-> "fullName"struct Person { full_name: String, age: u32, email: Option<String>, // may be missing or null #[serde(default)] tags: Vec<String>, // missing -> empty list}
fn main() -> Result<(), serde_json::Error> { // JSON text -> struct let text = r#"{ "fullName": "Ana Silva", "age": 31, "tags": ["admin"] }"#; let ana: Person = serde_json::from_str(text)?; println!("{ana:?}");
// struct -> JSON text let bo = Person { full_name: "Bo Chen".to_string(), age: 28, email: Some("bo@example.com".to_string()), tags: vec![], }; println!("{}", serde_json::to_string_pretty(&bo)?); Ok(())}Person { full_name: "Ana Silva", age: 31, email: None, tags: ["admin"] }
{
"fullName": "Bo Chen",
"age": 28,
"email": "bo@example.com",
"tags": []
}package main
import ( "encoding/json" "fmt")
type Person struct { FullName string `json:"fullName"` Age int `json:"age"` Email *string `json:"email"` // pointer, so it can be nil Tags []string `json:"tags"`}
func main() { text := `{ "fullName": "Ana Silva", "age": 31, "tags": ["admin"] }` var ana Person if err := json.Unmarshal([]byte(text), &ana); err != nil { panic(err) } fmt.Printf("%+v\n", ana)
email := "bo@example.com" bo := Person{FullName: "Bo Chen", Age: 28, Email: &email, Tags: []string{}} out, _ := json.MarshalIndent(bo, "", " ") fmt.Println(string(out))}{FullName:Ana Silva Age:31 Email:<nil> Tags:[admin]}
{
"fullName": "Bo Chen",
"age": 28,
"email": "bo@example.com",
"tags": []
}serde is the framework: the Serialize and Deserialize traits and the derives, which come with the derive feature in Cargo.toml above. serde_json is one format that plugs into it (the data model is the meeting point).
- Field names: Go puts a tag on each field. C# uses an attribute on each property (
[JsonPropertyName]) or a naming policy set once. In serde,#[serde(rename_all = "camelCase")]covers the whole struct, and#[serde(rename = "...")]one field. The first run above showsfullNamegoing in and coming out. - Missing or null values go in an
Option. The Go program above has the same idea as a pointer field (Emailprints as<nil>).#[serde(default)]fills inDefault::default()for the field’s type instead (an emptyVecfortags). A field with neither is required, which is the gotcha below. - Enums become JSON too, with a few styles to choose from, such as
#[serde(tag = "type")]for{"type": "circle", ...}. - Other formats use the same derives: they are separate crates, such as
tomlandcsv.
What goes in, what comes out
Section titled “What goes in, what comes out”from_str returns a Result, so bad input is an Err you handle, not a crash. First the forgiving side. Missing and null both become None, a missing tags becomes an empty list, and a field the struct does not have is ignored (the serde docs say unknown fields are ignored by default for JSON):
use serde::Deserialize;
#[derive(Debug, Deserialize)]#[serde(rename_all = "camelCase")]struct Person { full_name: String, email: Option<String>, // missing or null -> None #[serde(default)] tags: Vec<String>, // missing -> Vec::default(), an empty list}
fn show(p: &Person) { println!("{}: email={:?} tags={:?}", p.full_name, p.email, p.tags);}
fn main() -> Result<(), serde_json::Error> { // "email" and "tags" are missing let a: Person = serde_json::from_str(r#"{ "fullName": "Ana" }"#)?; show(&a); // "email" is null let b: Person = serde_json::from_str(r#"{ "fullName": "Bo", "email": null }"#)?; show(&b); // a field the struct does not have let c: Person = serde_json::from_str(r#"{ "fullName": "Cy", "nickname": "C" }"#)?; show(&c); Ok(())}Ana: email=None tags=[] Bo: email=None tags=[] Cy: email=None tags=[]
Then the strict side. #[serde(deny_unknown_fields)] turns that last case into an error, and the other lines are a string, a negative number, a fraction and JSON that is cut off. Each message names the problem and ends with a line and column:
use serde::Deserialize;
#[derive(Deserialize)]#[serde(rename_all = "camelCase", deny_unknown_fields)]struct Person { full_name: String, age: u32,}
fn main() { let inputs = [ r#"{ "fullName": "Ana", "age": 31, "nickname": "A" }"#, // unknown field r#"{ "fullName": "Bo", "age": "28" }"#, // a string, not a number r#"{ "fullName": "Cy", "age": -1 }"#, // negative for a u32 r#"{ "fullName": "Di", "age": 1.5 }"#, // fraction for a u32 r#"{ "fullName": "Ed", "age": 40"#, // cut off ]; for text in inputs { match serde_json::from_str::<Person>(text) { Ok(p) => println!("ok: {} is {}", p.full_name, p.age), Err(e) => println!("error: {e}"), } }}error: unknown field `nickname`, expected `fullName` or `age` at line 1 column 42 error: invalid type: string "28", expected u32 at line 1 column 31 error: invalid value: integer `-1`, expected u32 at line 1 column 29 error: invalid type: floating point `1.5`, expected u32 at line 1 column 30 error: EOF while parsing an object at line 1 column 29
age is a u32, so "28" (a string), -1 and 1.5 are refused instead of converted.
An enum becomes JSON in a shape you pick. By default the variant name wraps the data. With tag = "type" the name becomes a field inside the object, and a tag the enum does not know is an error. The serde docs list every representation.
use serde::{Deserialize, Serialize};
// the default: the variant name wraps the data#[derive(Debug, Serialize, Deserialize)]#[serde(rename_all = "lowercase")]enum Wrapped { Circle { radius: f64 }, Square { side: f64 },}
// tag = "type": the variant name becomes a field inside the object#[derive(Debug, Serialize, Deserialize)]#[serde(tag = "type", rename_all = "lowercase")]enum Tagged { Circle { radius: f64 }, Square { side: f64 },}
fn main() -> Result<(), serde_json::Error> { println!("{}", serde_json::to_string(&Wrapped::Circle { radius: 1.5 })?); println!("{}", serde_json::to_string(&Tagged::Circle { radius: 1.5 })?);
let shapes: Vec<Tagged> = serde_json::from_str(r#"[{"type":"circle","radius":2.0},{"type":"square","side":3.0}]"#)?; println!("{shapes:?}");
// an unknown tag is an error, not a guess let err = serde_json::from_str::<Tagged>(r#"{"type":"triangle","side":3.0}"#).unwrap_err(); println!("error: {err}"); Ok(())}{"circle":{"radius":1.5}}
{"type":"circle","radius":1.5}
[Circle { radius: 2.0 }, Square { side: 3.0 }]
error: unknown variant `triangle`, expected `circle` or `square` at line 1 column 18When you have no struct
Section titled “When you have no struct”Sometimes you only need one value out of a document. serde_json::Value holds any JSON, and json! builds one. This trades the checks above for flexibility: a missing key reads as null and as_u64() on a string gives None, so you decide what that means.
use serde_json::{Value, json};
fn main() -> Result<(), serde_json::Error> { // no struct: parse into serde_json::Value and look things up by key let v: Value = serde_json::from_str(r#"{ "fullName": "Ana", "tags": ["admin", "dev"] }"#)?; println!("{}", v["fullName"]); println!("{}", v["tags"][1]); println!("{}", v["nope"]); // a missing key is Value::Null, not an error println!("{:?}", v["fullName"].as_str()); println!("{:?}", v["fullName"].as_u64());
// build JSON with the json! macro let out = json!({ "ok": true, "count": 2, "names": ["Ana", "Bo"] }); println!("{out}"); Ok(())}"Ana"
"dev"
null
Some("Ana")
None
{"count":2,"names":["Ana","Bo"],"ok":true}Other formats
Section titled “Other formats”The same Person struct goes to TOML and CSV by changing the crate that does the writing. This is a second project with its own Cargo.toml. CSV reads through a Reader that deserializes each row, not through from_str.
[package]name = "formats"version = "0.1.0"edition = "2024"
[dependencies]csv = "1.4.0"serde = { version = "1.0.229", features = ["derive"] }serde_json = "1.0.154"toml = "1.1.8"use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]#[serde(rename_all = "camelCase")]struct Person { full_name: String, age: u32,}
fn main() -> Result<(), Box<dyn std::error::Error>> { let ana = Person { full_name: "Ana Silva".to_string(), age: 31 };
// the same struct, three formats out println!("{}", serde_json::to_string(&ana)?); println!("{}", toml::to_string(&ana)?); let mut writer = csv::Writer::from_writer(vec![]); writer.serialize(&ana)?; println!("{}", String::from_utf8(writer.into_inner()?)?);
// and back in, from TOML and from CSV let from_toml: Person = toml::from_str("fullName = \"Bo Chen\"\nage = 28\n")?; println!("{from_toml:?}"); let mut reader = csv::Reader::from_reader("fullName,age\nCy Diaz,40\n".as_bytes()); for row in reader.deserialize() { let p: Person = row?; println!("{p:?}"); } Ok(())}{"fullName":"Ana Silva","age":31}
fullName = "Ana Silva"
age = 31
fullName,age
Ana Silva,31
Person { full_name: "Bo Chen", age: 28 }
Person { full_name: "Cy Diaz", age: 40 }For YAML, the serde_yaml crate has a final release numbered 0.9.34+deprecated and its repository was archived by its owner on 25 March 2024. Check what is current before you pick a YAML crate.
The same job in your language
Section titled “The same job in your language”The last column is the gotcha’s JSON, { "fullName": "Cy" }, loaded into a type that has an age. Cells are from the runs in the gotcha below; the image has no Jackson, kotlinx.serialization, cJSON or nlohmann/json, so those rows were not run.
| Language | A common choice | A missing age, as run |
|---|---|---|
| Go | encoding/json with struct tags (standard library) |
Age is 0, err is nil |
| Python | json (standard library), plus pydantic (or a dataclass you fill in yourself) for shapes |
a dict without the key; KeyError when you read it |
| TypeScript | JSON.parse (built in), plus zod to check the shape |
the type says number, the value is undefined |
| Java | Jackson | not run |
| Kotlin | kotlinx.serialization | not run; its docs say an object can be deserialized only when all its properties are present, unless the property has a default |
| C# | System.Text.Json (in .NET) |
Age is 0; with required properties it throws |
| C | cJSON, jansson | not run |
| C++ | nlohmann/json | not run |
| Rust | serde with serde_json |
an error that names the field |
Try it yourself
Section titled “Try it yourself”I did all three in the image behind this page.
-
Rename wrongly. In
src/main.rs, changerename_all = "camelCase"to"snake_case"and run it. The JSON still saysfullName, so the program stops with an error namingfull_name. The attribute works in both directions, so the text and the struct have to agree.$ cargo runError: Error("missing field `full_name`", line: 1, column: 57) -
Make the gotcha pass. In
examples/missing.rs, changeage: u32toage: Option<u32>, which is the fix shown in the gotcha below. Run it and the compiler stops with E0277, because the second{}inprintln!printsperson.ageand anOption<u32>has noDisplay. Change that{}to{:?}and it printsCy is None:ageisNone, not an error.terminal$ cargo run --example missing # after changing only age: u32 to age: Option<u32> error[E0277]: `std::option::Option<u32>` doesn't implement `std::fmt::Display` --> examples/missing.rs:14:62 | 14 | Ok(person) => println!("{} is {}", person.full_name, person.age), | -- ^^^^^^^^^^ `std::option::Option<u32>` cannot be formatted with the default formatter | | | required by this formatting parameter | = help: the trait `std::fmt::Display` is not implemented for `std::option::Option<u32>` = note: in format strings you may be able to use `{:?}` (or `{:#?}` for pretty-print) instead For more information about this error, try `rustc --explain E0277`. error: could not compile `people-json` (example "missing") due to 1 previous error $ cargo run --example missing # after also changing the second {} to {:?} Cy is None
-
Loosen the strict example. In
examples/strict.rs, delete, deny_unknown_fieldsfrom the attribute. The first input, the one withnickname, is now accepted, and the other four still fail.terminal$ cargo run --example strict ok: Ana is 31 error: invalid type: string "28", expected u32 at line 1 column 31 error: invalid value: integer `-1`, expected u32 at line 1 column 29 error: invalid type: floating point `1.5`, expected u32 at line 1 column 30 error: EOF while parsing an object at line 1 column 29
A field is missing from the JSON
Looks fine, and it compiles in Rust too. But the two give different results.
Compiles in both. Different result.Not run in Kotlin on this page.Compiles in both. Different result.Compiles in both. Different result.Not run in Java on this page.Compiles in both. Different result.Not run in C on this page.Not run in C++ on this page.use serde::Deserialize;
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct Person { full_name: String, age: u32,}
fn main() { let text = r#"{ "fullName": "Cy" }"#; // no "age" let result: Result<Person, serde_json::Error> = serde_json::from_str(text); match result { Ok(person) => println!("{} is {}", person.full_name, person.age), Err(e) => println!("error: {e}"), }}package main
import ( "encoding/json" "fmt")
type Person struct { FullName string `json:"fullName"` Age int `json:"age"`}
func main() { var cy Person err := json.Unmarshal([]byte(`{ "fullName": "Cy" }`), &cy) // no "age" fmt.Printf("%+v err=%v\n", cy, err)}{FullName:Cy Age:0} err=<nil>Not run here: the image has no kotlinx.serialization runtime. Its documentation says an object can be deserialized only when all its properties are present in the input, and that a property with a default value is optional.
import json
cy = json.loads('{ "fullName": "Cy" }') # no "age"print(cy)try: print(cy["age"])except KeyError as e: print("KeyError:", e){'fullName': 'Cy'}
KeyError: 'age'interface Person { fullName: string; age: number;}
const cy = JSON.parse('{ "fullName": "Cy" }') as Person; // no "age"console.log(cy);console.log(typeof cy.age, cy.age + 1);{ fullName: 'Cy' }
undefined NaNNot run here: the image has no JSON library for Java, and the standard library has none.
using System.Text.Json;
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };var text = """{ "fullName": "Cy" }"""; // no "age"
var cy = JsonSerializer.Deserialize<Person>(text, options)!;Console.WriteLine($"{cy.FullName} is {cy.Age}");
try{ JsonSerializer.Deserialize<RequiredPerson>(text, options);}catch (JsonException e){ Console.WriteLine("JsonException: " + e.Message);}
class Person{ public string FullName { get; set; } = ""; public int Age { get; set; }}
class RequiredPerson{ public required string FullName { get; set; } public required int Age { get; set; }}Cy is 0 JsonException: JSON deserialization for type 'RequiredPerson' was missing required properties including: 'age'.
Not run here: the image has no JSON library for C, and the standard library has none.
Not run here: the image has no JSON library for C++, and the standard library has none.
error: missing field `age` at line 1 column 20
Go fills in the zero value, so Age is 0 and err is nil. Nothing in the result says the field was missing, and a real 0 would look the same.
Not run here: the image has no kotlinx.serialization runtime. Its documentation says an object can be deserialized only when all its properties are present in the input, and that a property with a default value is optional.
json.loads does not know about a shape: you get a dict without an "age" key and no error at load. The dict is faithful to the text, but code that expects an age finds out only when it reads cy["age"], and then it is a KeyError.
JSON.parse returns whatever is in the text, and as Person only tells the compiler to believe you. The type says number, the value is undefined, and undefined + 1 is NaN.
Not run here: the image has no JSON library for Java, and the standard library has none.
System.Text.Json leaves Age at its default, 0, with no error. Marking the properties required makes it throw, as the second line of the output shows.
Not run here: the image has no JSON library for C, and the standard library has none.
Not run here: the image has no JSON library for C++, and the standard library has none.
The struct says age: u32: always a number. The JSON has no age, so serde returns an error that names the field instead of making a value up.
A plain int field cannot tell a real 0 from a missing value afterwards. In Rust you say what you mean: Option<u32> if it may be missing, or #[serde(default)] if 0 is a fine answer. Other languages can ask for the same strictness (the C# tab shows required); in serde it is what you get when you write nothing.
The fix
Compiles and runs.
use serde::Deserialize;
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct Person { full_name: String, age: Option<u32>, // say it out loud: age may be missing}
fn main() { let cy: Person = serde_json::from_str(r#"{ "fullName": "Cy" }"#).unwrap(); match cy.age { Some(age) => println!("{} is {age}", cy.full_name), None => println!("{} didn't tell us their age", cy.full_name), }}Cy didn't tell us their age
Read more
Section titled “Read more”- The serde site: overview, derive setup, container attributes (
rename_all,deny_unknown_fields,tag), field attributes (default) and enum representations. serde_jsonon docs.rs: crate page,Valueand thejson!macro.- Other formats:
tomland thecsvtutorial. - Other languages: Go
encoding/json, Pythonjson, C#requiredproperties and customizing property names, Kotlin optional properties.
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.