Skip to Content
Course content

60: Serialization with Serde

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've been working on this small CLI tool to manage a collection of vintage synthesizers, and I hit a wall today. I have a Synth struct that holds all the patch settings, and I realized that every time I restart the app, I lose everything. I need to save this state to a file and load it back. My first instinct was to just format!() the struct into a string, but that's a nightmare to parse back into a Rust object. That's why we're diving into Serde.

The manual struggle

I started by defining my data. Nothing fancy, just a few fields to keep track of the synth's state:

struct SynthPatch {
    name: String,
    oscillator_freq: f32,
    filter_cutoff: f32,
    is_active: bool,
}

I thought, "I'll just use the serde_json crate to turn this into a string." I added the dependency and tried this:

let patch = SynthPatch {
    name: String::from("Deep Space Bass"),
    oscillator_freq: 440.0,
    filter_cutoff: 1200.0,
    is_active: true,
};

let json = serde_json::to_string(&patch).unwrap();
println!("{}", json);

The compiler immediately yelled at me. It told me that SynthPatch doesn't implement the Serialize trait. This is the core philosophy of Rust: the language doesn't magically know how to traverse your custom structs. It needs an explicit contract. I can't just tell Rust "make this JSON"; I have to tell the type how to be serializable.

Letting Serde handle the heavy lifting

Now, I could implement the Serialize trait by hand, but that's a tedious process involving a lot of boilerplate that I simply don't have the patience for. This is where the derive feature of Serde comes in. I noticed that if I add serde = { version = "1.0", features = ["derive"] } to my Cargo.toml, I can use a procedural macro to do the work for me.

I updated my struct like this:

use serde::Serialize;

#[derive(Serialize)]
struct SynthPatch {
    name: String,
    oscillator_freq: f32,
    filter_cutoff: f32,
    is_active: bool,
}

I ran the code again, and it worked. The Serialize derive macro essentially writes the logic to visit every field in my struct and map it to a JSON key. It's seamless. But saving is only half the battle. If I can't get the data back into a SynthPatch instance, the file is just a useless text document.

Bringing the data back to life

I tried to use serde_json::from_str to load a JSON string back into my struct, but I hit the same wall as before. The compiler complained that SynthPatch didn't implement Deserialize. It makes sense—serializing is just reading, but deserializing requires the ability to actually construct the type from a stream of data.

I just added Deserialize to my derive list, and suddenly the "loading" part of my app started working:

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct SynthPatch {
    name: String,
    oscillator_freq: f32,
    filter_cutoff: f32,
    is_active: bool,
}

fn main() {
    let data = r#"{"name": "Nebula Pad", "oscillator_freq": 220.0, "filter_cutoff": 800.0, "is_active": false}"#;
    let decoded: SynthPatch = serde_json::from_str(data).unwrap();
    println!("Loaded patch: {:?}", decoded);
}

I love how Rust's type system handles this. Because I told from_str that I expected a SynthPatch (via the type annotation : SynthPatch), Serde knows exactly which deserialization logic to trigger.

Matching JSON keys to Rust fields

Here is where things got annoying. I decided I wanted my JSON files to use snake_case for the keys to follow standard JSON conventions, but I realized some of the external API data I'm pulling in uses camelCase (like oscillatorFreq instead of oscillator_freq).

I didn't want to rename my Rust fields because that would break my naming conventions. I found out that Serde has "field attributes" that let you map names without changing the code. I added a rename_all attribute to the top of the struct:

#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct SynthPatch {
    name: String,
    oscillator_freq: f32, // This will be "oscillatorFreq" in JSON
    filter_cutoff: f32,   // This will be "filterCutoff" in JSON
    is_active: bool,      // This will be "isActive" in JSON
}

This is a lifesaver. If I have one specific field that is totally weird—like a legacy key called p_name_val—I can just use #[serde(rename = "p_name_val")] directly on that specific field. Now my Rust code stays clean, and my JSON stays compatible with whatever external system I'm talking to.




📋 Practical Task

Build a Game Character Save-State System

You are building a save-game system for a space exploration RPG. You need to create a system that can save a player's progress to a JSON string and load it back without losing data.

  • Create a struct named PlayerSave with the following fields:
    • player_name (String)
    • level (u32)
    • coordinates (a tuple of two f64s: x and y)
    • inventory (a Vector of Strings)
  • Add the necessary Serde derives so the struct can be both serialized and deserialized.
  • Use #[serde(rename_all = "PascalCase")] on the struct so that the resulting JSON keys start with capital letters (e.g., "PlayerName" instead of "player_name").
  • In your main function:
    1. Instantiate a PlayerSave object with some sample data.
    2. Serialize it to a JSON string using serde_json::to_string and print the string to the console.
    3. Take that printed JSON string, pass it into serde_json::from_str, and deserialize it back into a new PlayerSave instance.
    4. Print the final deserialized object using {:?} to verify the data survived the round trip.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.