ZetCode

Rust Serde JSON

last modified November 13, 2026

In this article we work with JSON data in Rust using the serde and serde_json crates.

JSON (JavaScript Object Notation) is a lightweight text format for exchanging data. It has objects, arrays, strings, numbers, booleans, and null. The Rust standard library has no JSON support; the de facto standard is Serde, a framework for serializing and deserializing Rust data structures.

Serde itself is format-agnostic. The serde crate defines the Serialize and Deserialize traits and derive macros that implement them; format crates such as serde_json, toml, or serde_yaml read and write the actual text. The examples were tested with Rust 1.97, the 2024 edition, serde 1.0.229, and serde_json 1.0.154. They are run as Cargo projects with cargo run -q.

Setting up the project

Both crates are added to the project's dependencies. The derive feature of serde enables the #[derive(Serialize, Deserialize)] macros.

Cargo.toml
[package]
name = "json_example"
version = "0.1.0"
edition = "2024"

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

The serde_json crate brings serde in on its own, but the derive macros are only available when we depend on serde directly with the feature enabled. The dependencies are added with cargo add, which also lists the available features of each crate.

$ cargo add serde --features derive
    Updating crates.io index
      Adding serde v1.0.229 to dependencies
             Features:
             + derive
             + serde_derive
             + std
             - alloc
             - rc
             - unstable
    Updating crates.io index
     Locking 7 packages to latest Rust 1.97.1 compatible versions
$ cargo add serde_json
    Updating crates.io index
      Adding serde_json v1.0.154 to dependencies
             Features:
             + std
             - alloc
             - arbitrary_precision
             - float_roundtrip
             - indexmap
             - preserve_order
             - raw_value
             - unbounded_depth
    Updating crates.io index
     Locking 4 packages to latest Rust 1.97.1 compatible versions
      Adding itoa v1.0.19
      Adding memchr v2.8.3
      Adding serde_json v1.0.154
      Adding zmij v1.0.23

Serializing a struct

Deriving Serialize lets a struct be converted to JSON.

main.rs
use serde::Serialize;

#[derive(Serialize)]
struct User {
    name: String,
    age: u32,
    email: String,
    active: bool,
    roles: Vec<String>,
}

fn main() -> Result<(), serde_json::Error> {
    let user = User {
        name: "John Doe".to_string(),
        age: 34,
        email: "john@example.com".to_string(),
        active: true,
        roles: vec!["admin".to_string(), "editor".to_string()],
    };

    let compact = serde_json::to_string(&user)?;
    println!("{compact}");

    let pretty = serde_json::to_string_pretty(&user)?;
    println!("{pretty}");

    Ok(())
}

The example turns a user record into a JSON string.

#[derive(Serialize)]
struct User {

The derive macro generates the Serialize implementation. Every field type must be serializable too; all standard types such as numbers, strings, vectors, and options are.

let compact = serde_json::to_string(&user)?;

The to_string function produces compact JSON on a single line. Struct fields become object keys, in declaration order.

let pretty = serde_json::to_string_pretty(&user)?;

The to_string_pretty function indents the output with two spaces. Serialization of a struct like this cannot actually fail, but the functions return Result because some types can, for instance a map with non-string keys.

$ cargo run -q
{"name":"John Doe","age":34,"email":"john@example.com","active":true,"roles":["admin","editor"]}
{
  "name": "John Doe",
  "age": 34,
  "email": "john@example.com",
  "active": true,
  "roles": [
    "admin",
    "editor"
  ]
}

Deserializing into a struct

Deriving Deserialize lets a struct be built from JSON.

main.rs
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct User {
    name: String,
    age: u32,
    roles: Vec<String>,
}

fn main() -> Result<(), serde_json::Error> {
    let data = r#"
        {
            "name": "Roger Roe",
            "age": 41,
            "roles": ["viewer"],
            "country": "SK"
        }"#;

    let user: User = serde_json::from_str(data)?;
    println!("{user:?}");
    println!("{} is {} years old, roles: {:?}", user.name, user.age, user.roles);

    let ages: Vec<u32> = serde_json::from_str("[31, 45, 27]")?;
    let total: u32 = ages.iter().sum();
    println!("total age: {total}");

    Ok(())
}

The JSON text is parsed into a typed struct.

let user: User = serde_json::from_str(data)?;

The from_str function needs to know the target type, given here by the type annotation. Whitespace in the input does not matter.

"country": "SK"

The JSON has a country key that the struct does not have. By default, Serde silently ignores unknown fields.

let ages: Vec<u32> = serde_json::from_str("[31, 45, 27]")?;

Any deserializable type can be the target, not only structs. A JSON array becomes a vector.

$ cargo run -q
User { name: "Roger Roe", age: 41, roles: ["viewer"] }
Roger Roe is 41 years old, roles: ["viewer"]
total age: 103

A round trip

Types that derive both traits can go to JSON and back without losing information.

main.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Point {
    x: f64,
    y: f64,
}

#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Polygon {
    name: String,
    points: Vec<Point>,
    closed: bool,
}

fn main() -> Result<(), serde_json::Error> {
    let poly = Polygon {
        name: "triangle".into(),
        points: vec![Point { x: 0.0, y: 0.0 }, Point { x: 4.0, y: 0.0 }, Point { x: 0.0, y: 3.0 }],
        closed: true,
    };

    let json = serde_json::to_string(&poly)?;
    println!("{json}");

    let back: Polygon = serde_json::from_str(&json)?;
    println!("{back:?}");
    println!("equal: {}", poly == back);

    Ok(())
}

A polygon contains a vector of nested point structs.

#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Polygon {
    name: String,
    points: Vec<Point>,
    closed: bool,
}

Nested structs become nested JSON objects, and vectors become arrays. Floats are always written with a decimal point, so 0.0 stays a float.

println!("equal: {}", poly == back);

The value read back is equal to the original.

$ cargo run -q
{"name":"triangle","points":[{"x":0.0,"y":0.0},{"x":4.0,"y":0.0},{"x":0.0,"y":3.0}],"closed":true}
Polygon { name: "triangle", points: [Point { x: 0.0, y: 0.0 }, Point { x: 4.0, y: 0.0 }, Point { x: 0.0, y: 3.0 }], closed: true }
equal: true

Untyped JSON with Value

When the structure of the data is not known in advance, JSON can be parsed into serde_json::Value, an enum that represents any JSON value.

main.rs
use serde_json::{Value, json};

fn main() -> Result<(), serde_json::Error> {
    let data = r#"{
        "city": "Bratislava",
        "population": 475000,
        "coords": {"lat": 48.1486, "lon": 17.1077},
        "districts": ["Old Town", "Petrzalka", "Ruzinov"]
    }"#;

    let v: Value = serde_json::from_str(data)?;

    println!("{}", v["city"]);
    println!("{:?}", v["city"].as_str());
    println!("{}", v["coords"]["lat"]);
    println!("{}", v["districts"][1]);
    println!("{}", v["districts"].as_array().map_or(0, |a| a.len()));
    println!("{}", v["mayor"]);
    println!("{:?}", v["population"].as_u64());
    println!("{:?}", v["population"].as_str());

    let name = "Anna";
    let tags = vec!["rust", "json"];
    let doc = json!({
        "user": name,
        "score": 9.5,
        "tags": tags,
        "address": null,
        "verified": true
    });
    println!("{doc}");

    Ok(())
}

The Value enum has the variants Null, Bool, Number, String, Array, and Object.

println!("{}", v["city"]);
println!("{:?}", v["city"].as_str());

Indexing with a string looks up an object key, and indexing with a number looks up an array element. Printing a Value outputs JSON, so a string is shown with quotes. The as_str method extracts the Rust string.

println!("{}", v["mayor"]);

Indexing never panics on missing data; a missing key yields Null. The get method returns an Option instead when the difference matters.

println!("{:?}", v["population"].as_str());

The as_* methods return None when the value has a different type.

let doc = json!({
    "user": name,
    "score": 9.5,
    "tags": tags,
    ...
});

The json! macro builds a Value with JSON syntax. Rust variables and expressions can be interpolated. By default, objects are stored in a BTreeMap, so the keys are printed in sorted order; the preserve_order feature keeps the insertion order.

$ cargo run -q
"Bratislava"
Some("Bratislava")
48.1486
"Petrzalka"
3
null
Some(475000)
None
{"address":null,"score":9.5,"tags":["rust","json"],"user":"Anna","verified":true}

Modifying a Value

A Value can be changed in place.

main.rs
use serde_json::{Value, json};

fn main() -> Result<(), serde_json::Error> {
    let mut v: Value = serde_json::from_str(
        r#"{"order": 1001, "items": [{"sku": "A1", "qty": 2}, {"sku": "B7", "qty": 1}]}"#,
    )?;

    v["status"] = json!("shipped");
    v["items"][0]["qty"] = json!(3);

    if let Some(items) = v["items"].as_array_mut() {
        items.push(json!({"sku": "C3", "qty": 5}));
    }

    if let Some(obj) = v.as_object_mut() {
        obj.remove("order");
    }

    println!("{}", v.pointer("/items/2/sku").unwrap_or(&Value::Null));

    let total: u64 = v["items"]
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|item| item["qty"].as_u64())
        .sum();
    println!("total qty: {total}");

    println!("{}", serde_json::to_string_pretty(&v)?);
    Ok(())
}

We update an order document.

v["status"] = json!("shipped");
v["items"][0]["qty"] = json!(3);

Assigning through a string index inserts or replaces an object key. Note that indexing a missing array element in assignment panics, unlike reading.

if let Some(items) = v["items"].as_array_mut() {
    items.push(json!({"sku": "C3", "qty": 5}));
}

The as_array_mut and as_object_mut methods give mutable access to the underlying Vec and map.

println!("{}", v.pointer("/items/2/sku").unwrap_or(&Value::Null));

The pointer method looks up a value by a JSON Pointer path (RFC 6901), with keys and indexes separated by slashes.

$ cargo run -q
"C3"
total qty: 9
{
  "items": [
    {
      "qty": 3,
      "sku": "A1"
    },
    {
      "qty": 1,
      "sku": "B7"
    },
    {
      "qty": 5,
      "sku": "C3"
    }
  ],
  "status": "shipped"
}

Collections, tuples, and options

Standard Rust types map to JSON in a predictable way.

main.rs
use std::collections::{BTreeMap, HashMap};

fn main() -> Result<(), serde_json::Error> {
    let mut prices = BTreeMap::new();
    prices.insert("pear", 1.2);
    prices.insert("apple", 0.8);
    prices.insert("plum", 2.5);
    println!("{}", serde_json::to_string(&prices)?);

    let mut by_id = BTreeMap::new();
    by_id.insert(3, "Lucia");
    by_id.insert(1, "Anna");
    println!("{}", serde_json::to_string(&by_id)?);

    let tuple = ("point", 3, -4);
    println!("{}", serde_json::to_string(&tuple)?);

    let nested: Vec<Option<i32>> = vec![Some(1), None, Some(3)];
    println!("{}", serde_json::to_string(&nested)?);

    let parsed: HashMap<String, Vec<u32>> = serde_json::from_str(r#"{"odd": [1, 3], "even": [2, 4]}"#)?;
    println!("{:?}", parsed["even"]);

    let back: BTreeMap<u32, String> = serde_json::from_str(r#"{"10": "ten", "2": "two"}"#)?;
    println!("{back:?}");

    Ok(())
}

We serialize maps, a tuple, and options, and parse two maps.

let mut by_id = BTreeMap::new();
by_id.insert(3, "Lucia");

JSON object keys are always strings. Integer keys are converted to strings when serializing and parsed back when deserializing.

let tuple = ("point", 3, -4);

Tuples become arrays.

let nested: Vec<Option<i32>> = vec![Some(1), None, Some(3)];

Some(x) is serialized as the plain value and None as null.

$ cargo run -q
{"apple":0.8,"pear":1.2,"plum":2.5}
{"1":"Anna","3":"Lucia"}
["point",3,-4]
[1,null,3]
[2, 4]
{2: "two", 10: "ten"}

Optional and default fields

Field attributes control how missing and empty values are handled.

main.rs
use serde::{Deserialize, Serialize};

fn default_lang() -> String {
    "en".to_string()
}

#[derive(Debug, Serialize, Deserialize)]
struct Profile {
    username: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    nickname: Option<String>,
    #[serde(default)]
    followers: u32,
    #[serde(default = "default_lang")]
    language: String,
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    tags: Vec<String>,
    #[serde(skip)]
    session_token: String,
}

fn main() -> Result<(), serde_json::Error> {
    let p: Profile = serde_json::from_str(r#"{"username": "jdoe", "nickname": null}"#)?;
    println!("{p:?}");
    println!("{}", serde_json::to_string(&p)?);

    let full = r#"{"username": "anna", "nickname": "Annie", "followers": 120,
                   "language": "sk", "tags": ["rust"], "session_token": "secret"}"#;
    let p: Profile = serde_json::from_str(full)?;
    println!("{p:?}");
    println!("{}", serde_json::to_string(&p)?);
    println!("token read: {}", !p.session_token.is_empty());

    Ok(())
}

A user profile has several optional parts.

#[serde(skip_serializing_if = "Option::is_none")]
nickname: Option<String>,

An Option field accepts a missing key or null when deserializing. The attribute omits the key from the output when the value is None, instead of writing null.

#[serde(default)]
followers: u32,
#[serde(default = "default_lang")]
language: String,

Without these attributes, a missing followers key would be an error. The default attribute uses Default::default(); the second form calls the named function.

#[serde(skip)]
session_token: String,

A skipped field is never written or read; it is always set to its default value. The token in the input JSON was ignored.

$ cargo run -q
Profile { username: "jdoe", nickname: None, followers: 0, language: "en", tags: [], session_token: "" }
{"username":"jdoe","followers":0,"language":"en"}
Profile { username: "anna", nickname: Some("Annie"), followers: 120, language: "sk", tags: ["rust"], session_token: "" }
{"username":"anna","nickname":"Annie","followers":120,"language":"sk","tags":["rust"]}
token read: false

Renaming fields

JSON APIs often use naming conventions that differ from Rust's snake_case.

main.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Book {
    title: String,
    author_name: String,
    page_count: u32,
    #[serde(rename = "ISBN")]
    isbn: String,
    #[serde(alias = "published", alias = "year")]
    published_year: u16,
}

fn main() -> Result<(), serde_json::Error> {
    let data = r#"{
        "title": "The Book of Rust",
        "authorName": "Jane Doe",
        "pageCount": 512,
        "ISBN": "978-0-00-000000-0",
        "year": 2026
    }"#;

    let book: Book = serde_json::from_str(data)?;
    println!("{book:?}");
    println!("{}", serde_json::to_string_pretty(&book)?);

    Ok(())
}

The struct maps between Rust names and camelCase JSON keys.

#[serde(rename_all = "camelCase")]
struct Book {

The container attribute renames all fields; author_name becomes authorName. Other options include "kebab-case", "PascalCase", and "SCREAMING_SNAKE_CASE".

#[serde(rename = "ISBN")]
isbn: String,
#[serde(alias = "published", alias = "year")]
published_year: u16,

The rename attribute sets the name of one field. An alias is an additional name accepted when deserializing; the field is still written under its main name.

$ cargo run -q
Book { title: "The Book of Rust", author_name: "Jane Doe", page_count: 512, isbn: "978-0-00-000000-0", published_year: 2026 }
{
  "title": "The Book of Rust",
  "authorName": "Jane Doe",
  "pageCount": 512,
  "ISBN": "978-0-00-000000-0",
  "publishedYear": 2026
}

Enum representations

JSON has no enums, so Serde offers several ways to represent them.

main.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
enum Shape {
    Circle { radius: f64 },
    Rect { w: f64, h: f64 },
    Unit,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
enum Event {
    Login { user: String },
    Purchase { user: String, amount: f64 },
    Logout { user: String },
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum Level {
    Info,
    Warning,
    Error,
}

fn main() -> Result<(), serde_json::Error> {
    let shapes = vec![Shape::Circle { radius: 1.5 }, Shape::Rect { w: 2.0, h: 3.0 }, Shape::Unit];
    println!("{}", serde_json::to_string(&shapes)?);

    let events: Vec<Event> = serde_json::from_str(
        r#"[{"type": "login", "user": "anna"},
            {"type": "purchase", "user": "anna", "amount": 19.9},
            {"type": "logout", "user": "anna"}]"#,
    )?;
    for e in &events {
        match e {
            Event::Purchase { user, amount } => println!("{user} spent {amount}"),
            other => println!("{other:?}"),
        }
    }
    println!("{}", serde_json::to_string(&events[1])?);

    let levels: Vec<Level> = serde_json::from_str(r#"["INFO", "ERROR"]"#)?;
    println!("{levels:?} {}", serde_json::to_string(&Level::Warning)?);

    Ok(())
}

We use the default representation, an internally tagged one, and unit-only variants.

enum Shape {
    Circle { radius: f64 },
    Rect { w: f64, h: f64 },
    Unit,
}

By default, enums are externally tagged: a variant with data becomes an object with a single key, the variant name. A unit variant is just its name as a string.

#[serde(tag = "type", rename_all = "lowercase")]
enum Event {

The internally tagged form puts the variant name in a field next to the data. This is the common format of event streams and web APIs.

#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum Level {

On an enum, rename_all renames the variants. Enums with only unit variants map nicely to JSON string constants.

$ cargo run -q
[{"Circle":{"radius":1.5}},{"Rect":{"w":2.0,"h":3.0}},"Unit"]
Login { user: "anna" }
anna spent 19.9
Logout { user: "anna" }
{"type":"purchase","user":"anna","amount":19.9}
[Info, Error] "WARNING"

Untagged enums

An untagged enum has no marker in the data; Serde tries each variant in order and picks the first that matches.

main.rs
use serde::Deserialize;

#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Id {
    Number(u64),
    Text(String),
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Tags {
    One(String),
    Many(Vec<String>),
}

#[derive(Debug, Deserialize)]
struct Record {
    id: Id,
    tags: Tags,
}

fn main() -> Result<(), serde_json::Error> {
    let data = r#"[
        {"id": 17, "tags": "new"},
        {"id": "A-200", "tags": ["sale", "summer"]}
    ]"#;

    let records: Vec<Record> = serde_json::from_str(data)?;
    for r in &records {
        println!("{r:?}");
        let id = match &r.id {
            Id::Number(n) => format!("#{n}"),
            Id::Text(s) => s.clone(),
        };
        let tags = match &r.tags {
            Tags::One(t) => vec![t.as_str()],
            Tags::Many(v) => v.iter().map(String::as_str).collect(),
        };
        println!("{id}: {}", tags.join(", "));
    }

    let bad = serde_json::from_str::<Record>(r#"{"id": true, "tags": "x"}"#);
    println!("{}", bad.unwrap_err());

    Ok(())
}

The data uses either a number or a string as an identifier, and either a single tag or a list of tags.

#[serde(untagged)]
enum Tags {
    One(String),
    Many(Vec<String>),
}

A string matches the One variant, an array the Many variant. Untagged enums are convenient for loosely specified formats.

let bad = serde_json::from_str::<Record>(r#"{"id": true, "tags": "x"}"#);

The drawback is the error message: when no variant fits, Serde can only say that nothing matched, not why.

$ cargo run -q
Record { id: Number(17), tags: One("new") }
#17: new
Record { id: Text("A-200"), tags: Many(["sale", "summer"]) }
A-200: sale, summer
data did not match any variant of untagged enum Id at line 1 column 11

Handling errors

Deserialization fails on invalid JSON and on JSON that does not fit the target type. The serde_json::Error type reports what went wrong and where.

main.rs
use serde::Deserialize;

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Config {
    host: String,
    port: u16,
}

fn main() {
    let inputs = [
        r#"{"host": "localhost", "port": 8080}"#,
        r#"{"host": "localhost"}"#,
        r#"{"host": "localhost", "port": 70000}"#,
        r#"{"host": "localhost", "port": "80"}"#,
        r#"{"host": "localhost", "port": 80, "debug": true}"#,
        "{\"host\": \"localhost\",\n \"port\": 80,\n}",
        r#"{"host": "localhost", "port": 80"#,
    ];

    for input in inputs {
        match serde_json::from_str::<Config>(input) {
            Ok(cfg) => println!("ok: {}:{}", cfg.host, cfg.port),
            Err(e) => println!("{:?} error: {e}", e.classify()),
        }
    }
}

We parse several broken configurations.

#[serde(deny_unknown_fields)]
struct Config {

This attribute turns unknown keys into an error, which catches typos in configuration files.

Err(e) => println!("{:?} error: {e}", e.classify()),

The classify method returns the error category: Syntax for malformed JSON, Data for valid JSON with the wrong shape, Eof for truncated input, and Io for read errors. The message includes the line and column. Note that 70000 does not fit into a u16, and the string "80" is not accepted as a number. JSON does not allow trailing commas.

$ cargo run -q
ok: localhost:8080
Data error: missing field `port` at line 1 column 21
Data error: invalid value: integer `70000`, expected u16 at line 1 column 35
Data error: invalid type: string "80", expected u16 at line 1 column 34
Data error: unknown field `debug`, expected `host` or `port` at line 1 column 41
Syntax error: trailing comma at line 3 column 1
Eof error: EOF while parsing an object at line 1 column 32

Writing JSON to a file

The to_writer and to_writer_pretty functions write JSON to anything that implements io::Write.

main.rs
use serde::Serialize;
use std::fs::File;
use std::io::BufWriter;

#[derive(Serialize)]
struct User {
    id: u32,
    name: String,
    email: String,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let users = vec![
        User { id: 1, name: "Anna".into(), email: "anna@example.com".into() },
        User { id: 2, name: "Peter".into(), email: "peter@example.com".into() },
        User { id: 3, name: "Lucia".into(), email: "lucia@example.com".into() },
    ];

    let file = File::create("users.json")?;
    let mut writer = BufWriter::new(file);
    serde_json::to_writer_pretty(&mut writer, &users)?;

    println!("wrote {} users", users.len());
    Ok(())
}

We write a list of users to a file.

let file = File::create("users.json")?;
let mut writer = BufWriter::new(file);
serde_json::to_writer_pretty(&mut writer, &users)?;

Serde writes many small pieces, so the file is wrapped in a BufWriter. The main function returns Box<dyn Error> because two error types are involved: io::Error and serde_json::Error. The buffer is flushed when the writer is dropped; a call to flush would report write errors that dropping silently ignores.

$ cargo run -q
wrote 3 users
$ cat users.json
[
  {
    "id": 1,
    "name": "Anna",
    "email": "anna@example.com"
  },
  {
    "id": 2,
    "name": "Peter",
    "email": "peter@example.com"
  },
  {
    "id": 3,
    "name": "Lucia",
    "email": "lucia@example.com"
  }
]

Reading JSON from a file

The from_reader function reads JSON from anything that implements io::Read.

main.rs
use serde::Deserialize;
use std::fs::File;
use std::io::BufReader;

#[derive(Debug, Deserialize)]
struct User {
    id: u32,
    name: String,
    email: String,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let file = File::open("users.json")?;
    let reader = BufReader::new(file);
    let users: Vec<User> = serde_json::from_reader(reader)?;

    for u in &users {
        println!("{:>2} {:<6} {}", u.id, u.name, u.email);
    }

    let text = std::fs::read_to_string("users.json")?;
    let users2: Vec<User> = serde_json::from_str(&text)?;
    println!("{} users, last: {:?}", users2.len(), users2.last());

    Ok(())
}

We read the users written by the previous example.

let reader = BufReader::new(file);
let users: Vec<User> = serde_json::from_reader(reader)?;

Reading from an unbuffered file would be slow, so we wrap it in a BufReader.

let text = std::fs::read_to_string("users.json")?;
let users2: Vec<User> = serde_json::from_str(&text)?;

Reading the whole file into a string and calling from_str is usually faster than from_reader, at the cost of holding the text in memory.

$ cargo run -q
 1 Anna   anna@example.com
 2 Peter  peter@example.com
 3 Lucia  lucia@example.com
3 users, last: Some(User { id: 3, name: "Lucia", email: "lucia@example.com" })

Zero-copy deserialization

A struct can borrow string data directly from the input instead of allocating new strings.

main.rs
use serde::Deserialize;
use std::borrow::Cow;

#[derive(Debug, Deserialize)]
struct LogLine<'a> {
    level: &'a str,
    #[serde(borrow)]
    message: Cow<'a, str>,
}

fn main() -> Result<(), serde_json::Error> {
    let input = r#"[
        {"level": "info", "message": "server started"},
        {"level": "warn", "message": "disk \"/data\" almost full"}
    ]"#;

    let lines: Vec<LogLine> = serde_json::from_str(input)?;
    for line in &lines {
        let kind = match line.message {
            Cow::Borrowed(_) => "borrowed",
            Cow::Owned(_) => "owned",
        };
        println!("[{}] {} ({kind})", line.level, line.message);
    }

    let bad: Result<LogLine, _> = serde_json::from_str(r#"{"level": "in\"fo", "message": "x"}"#);
    println!("{}", bad.unwrap_err());

    Ok(())
}

The struct has a lifetime parameter tied to the input text.

struct LogLine<'a> {
    level: &'a str,
    #[serde(borrow)]
    message: Cow<'a, str>,
}

A &str field points into the JSON text. This only works when the string in the input has no escape sequences, because an escaped string must be decoded into new memory. A Cow<str> field borrows when it can and allocates when it must; the borrow attribute tells Serde to try borrowing.

serde_json::from_str(r#"{"level": "in\"fo", "message": "x"}"#);

The level contains an escaped quote, so it cannot be borrowed, and the &str field fails.

$ cargo run -q
[info] server started (borrowed)
[warn] disk "/data" almost full (owned)
invalid type: string "in\"fo", expected a borrowed string at line 1 column 18

Flattening

The flatten attribute merges the fields of a nested struct or map into the parent object.

main.rs
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;

#[derive(Debug, Serialize, Deserialize)]
struct Pagination {
    page: u32,
    per_page: u32,
}

#[derive(Debug, Serialize, Deserialize)]
struct Request {
    query: String,
    #[serde(flatten)]
    pagination: Pagination,
    #[serde(flatten)]
    extra: BTreeMap<String, Value>,
}

fn main() -> Result<(), serde_json::Error> {
    let data = r#"{"query": "rust", "page": 2, "per_page": 20, "sort": "date", "safe": true}"#;

    let req: Request = serde_json::from_str(data)?;
    println!("{req:?}");
    println!("page {} of results for {:?}", req.pagination.page, req.query);
    println!("extra keys: {:?}", req.extra.keys().collect::<Vec<_>>());

    println!("{}", serde_json::to_string(&req)?);
    Ok(())
}

A search request mixes known and unknown parameters at one level.

#[serde(flatten)]
pagination: Pagination,

The page and per_page keys are read into the nested struct, although they are not nested in the JSON. This lets us reuse groups of fields in several types.

#[serde(flatten)]
extra: BTreeMap<String, Value>,

A flattened map collects all remaining keys. This preserves unknown data when a document is read and written back.

$ cargo run -q
Request { query: "rust", pagination: Pagination { page: 2, per_page: 20 }, extra: {"safe": Bool(true), "sort": String("date")} }
page 2 of results for "rust"
extra keys: ["safe", "sort"]
{"query":"rust","page":2,"per_page":20,"safe":true,"sort":"date"}

Streaming JSON values

Log files and data exports often use newline-delimited JSON (NDJSON), with one JSON value per line. A StreamDeserializer reads such input value by value.

main.rs
use serde::Deserialize;
use serde_json::Deserializer;

#[derive(Debug, Deserialize)]
struct Reading {
    sensor: String,
    temp: f64,
}

fn main() {
    let input = r#"{"sensor": "a", "temp": 21.5}
{"sensor": "b", "temp": 19.0}
{"sensor": "a", "temp": 22.1}
{"sensor": "c", "temp": "hot"}
{"sensor": "b", "temp": 18.4}"#;

    let stream = Deserializer::from_str(input).into_iter::<Reading>();

    let mut sum = 0.0;
    let mut count = 0;
    for item in stream {
        match item {
            Ok(r) => {
                println!("{} {}", r.sensor, r.temp);
                sum += r.temp;
                count += 1;
            }
            Err(e) => {
                println!("stopped: {e}");
                break;
            }
        }
    }
    println!("average of {count}: {:.2}", sum / count as f64);
}

We compute the average temperature of sensor readings.

let stream = Deserializer::from_str(input).into_iter::<Reading>();

The iterator yields a Result for each value. The values can be separated by any whitespace, so the input does not even need newlines. There is also Deserializer::from_reader for files and sockets.

Err(e) => {
    println!("stopped: {e}");
    break;
}

After an error the stream cannot reliably continue, so we stop. To skip bad lines, read the input line by line and parse each line separately.

$ cargo run -q
a 21.5
b 19
a 22.1
stopped: invalid type: string "hot", expected f64 at line 4 column 29
average of 3: 20.87

Numbers

JSON has a single number type, while Rust has many. serde_json keeps integers and floats apart and checks the ranges.

main.rs
use serde_json::{Value, json};

fn main() -> Result<(), serde_json::Error> {
    let v: Value = serde_json::from_str(r#"{"big": 18446744073709551615, "neg": -5, "pi": 3.14, "exp": 1e3}"#)?;

    for key in ["big", "neg", "pi", "exp"] {
        let n = &v[key];
        println!("{key}: u64={:?} i64={:?} f64={:?}", n.as_u64(), n.as_i64(), n.as_f64());
    }

    println!("{}", json!(f64::NAN));
    println!("{}", json!([1.0, 0.1 + 0.2, 1e21]));

    let r: Result<u8, _> = serde_json::from_str("300");
    println!("{}", r.unwrap_err());

    let r: Result<i32, _> = serde_json::from_str("2.5");
    println!("{}", r.unwrap_err());

    Ok(())
}

We inspect numbers in a Value and parse into narrow types.

println!("{key}: u64={:?} i64={:?} f64={:?}", n.as_u64(), n.as_i64(), n.as_f64());

A number without a decimal point or exponent is stored as an integer, so the largest u64 is read exactly. Converting it to f64 loses precision. Numbers written with a decimal point or an exponent, such as 1e3, are floats.

println!("{}", json!(f64::NAN));

JSON cannot represent NaN or infinity. serde_json converts them to null.

let r: Result<u8, _> = serde_json::from_str("300");

A number that does not fit the target type is an error, not a silent truncation as with the as operator.

$ cargo run -q
big: u64=Some(18446744073709551615) i64=None f64=Some(1.8446744073709552e19)
neg: u64=None i64=Some(-5) f64=Some(-5.0)
pi: u64=None i64=None f64=Some(3.14)
exp: u64=None i64=None f64=Some(1000.0)
null
[1.0,0.30000000000000004,1e+21]
invalid value: integer `300`, expected u8 at line 1 column 3
invalid type: floating point `2.5`, expected i32 at line 1 column 3

Source

Serde documentation

serde_json crate documentation

Serde attributes

In this article we have worked with JSON in Rust using serde and serde_json. Reading and writing files is covered in Rust read file and Rust write file.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Rust tutorials.