Skip to content
Learn Rust
Theme

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.

Project layout
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
RustCargo.toml
[package]
name = "people-json"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
Rustsrc/main.rs
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("[email protected]".to_string()),
tags: vec![],
};
println!("{}", serde_json::to_string_pretty(&bo)?);
Ok(())
}
$ cargo run
Person { full_name: "Ana Silva", age: 31, email: None, tags: ["admin"] }
{
  "fullName": "Bo Chen",
  "age": 28,
  "email": "[email protected]",
  "tags": []
}
Gomain.go
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 := "[email protected]"
bo := Person{FullName: "Bo Chen", Age: 28, Email: &email, Tags: []string{}}
out, _ := json.MarshalIndent(bo, "", " ")
fmt.Println(string(out))
}
$ go run .
{FullName:Ana Silva Age:31 Email:<nil> Tags:[admin]}
{
  "fullName": "Bo Chen",
  "age": 28,
  "email": "[email protected]",
  "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 shows fullName going in and coming out.
  • Missing or null values go in an Option. The Go program above has the same idea as a pointer field (Email prints as <nil>). #[serde(default)] fills in Default::default() for the field’s type instead (an empty Vec for tags). 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 toml and csv.

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):

Rustexamples/lenient.rs
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(())
}
$ cargo run --example lenient
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:

Rustexamples/strict.rs
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}"),
}
}
}
$ cargo run --example strict
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.

Rustexamples/shapes.rs
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(())
}
$ cargo run --example shapes
{"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 18

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.

Rustexamples/untyped.rs
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(())
}
$ cargo run --example untyped
"Ana"
"dev"
null
Some("Ana")
None
{"count":2,"names":["Ana","Bo"],"ok":true}

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.

Rust
[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"
$ cargo run
{"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 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

I did all three in the image behind this page.

  1. Rename wrongly. In src/main.rs, change rename_all = "camelCase" to "snake_case" and run it. The JSON still says fullName, so the program stops with an error naming full_name. The attribute works in both directions, so the text and the struct have to agree.

    $ cargo run
    Error: Error("missing field `full_name`", line: 1, column: 57)
  2. Make the gotcha pass. In examples/missing.rs, change age: u32 to age: Option<u32>, which is the fix shown in the gotcha below. Run it and the compiler stops with E0277, because the second {} in println! prints person.age and an Option<u32> has no Display. Change that {} to {:?} and it prints Cy is None: age is None, 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
    
  3. Loosen the strict example. In examples/strict.rs, delete , deny_unknown_fields from the attribute. The first input, the one with nickname, 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
    
Gotcha

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.
Rustexamples/missing.rs✓ compiles · different result
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)
}
$ go run main.go! runs · wrong answer
{FullName:Cy Age:0} err=<nil>
Kotlin

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)
$ python3 main.py! runs · wrong answer
{'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);
$ tsc --strict --module nodenext --types node main.ts && node main.js! runs · wrong answer
{ fullName: 'Cy' }
undefined NaN
Java

Not 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; }
}
$ dotnet run! runs · wrong answer
Cy is 0
JsonException: JSON deserialization for type 'RequiredPerson' was missing required properties including: 'age'.
C

Not run here: the image has no JSON library for C, and the standard library has none.

C++

Not run here: the image has no JSON library for C++, and the standard library has none.

$ cargo run --example missing
error: missing field `age` at line 1 column 20
What happens in Go

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.

What happens in Kotlin

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.

What happens in Python

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.

What happens in TypeScript

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.

What happens in Java

Not run here: the image has no JSON library for Java, and the standard library has none.

What happens in C#

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.

What happens in C

Not run here: the image has no JSON library for C, and the standard library has none.

What happens in C++

Not run here: the image has no JSON library for C++, and the standard library has none.

What Rust sees

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.

Why Rust says no

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.

Rust, fixedexamples/optional.rs
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),
}
}
$ cargo run --example optional
Cy didn't tell us their age