How to serialize to TOML with serde

Serialize Rust structs to TOML strings using the toml crate and serde Serialize derive macro.

When you need to save state

You are building a CLI tool that remembers the user's preferences. Or a server that needs to reload its configuration without restarting. You have a Rust struct holding the data. Now you need to write that struct to a file so it survives the next run. You pick TOML because it is readable and unambiguous. The question is how to get your Rust struct into a TOML string without fighting the syntax.

Serialization is the bridge between memory and storage. Rust keeps your data in structs. Files hold text. You need a reliable way to translate back and forth. The serde crate provides the translation framework. The toml crate provides the TOML dialect. Together they turn structs into text and text into structs with minimal effort.

Serialization is a walk, not a copy

Think of serialization as a guided tour. You have a building full of rooms (your struct fields). A guide (the serializer) walks through the building, visits each room in order, and writes down what they see in a specific format (TOML). The guide knows the rules of the format. They quote strings. They write integers as numbers. They nest tables for nested structs.

You do not write the guide by hand. You mark your struct with #[derive(Serialize)]. This tells the compiler to generate the tour guide automatically. The generated code knows exactly which fields exist, their types, and their order. When you call toml::to_string, the toml crate provides the guide that speaks TOML. The guide walks your struct and emits the correct syntax.

This approach is fast and safe. The code is generated at compile time. There is no reflection overhead at runtime. The compiler checks that every field can be serialized. If a field has a type that cannot be serialized, the build fails immediately.

Minimal example

Start with a simple struct. Derive Serialize. Call toml::to_string.

use serde::Serialize;

/// Configuration for a simple application.
#[derive(Serialize)]
struct Config {
    name: String,
    value: u32,
}

fn main() {
    let config = Config {
        name: "my_app".to_string(),
        value: 42,
    };

    // Serialize to a String. unwrap() is acceptable here for a demo.
    let toml_str = toml::to_string(&config).unwrap();
    println!("{}", toml_str);
}

Add these dependencies to your Cargo.toml.

[dependencies]
serde = { version = "1.0", features = ["derive"] }
toml = "0.8"

The serde dependency requires the derive feature. This enables the #[derive(Serialize)] macro. Without the feature, the macro is not available, and the derive attribute will fail. The toml crate re-exports the necessary serde integration, so you do not need a separate serde_toml crate. Pin toml to version 0.8. The 0.5 branch is legacy and lacks performance improvements and correctness fixes.

Derive Serialize. Trust the macro. It knows your fields better than you do.

What happens under the hood

When you compile the code, the Serialize derive macro expands into an implementation of the serde::Serialize trait. The implementation looks roughly like this.

impl serde::Serialize for Config {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        use serde::ser::SerializeStruct;
        let mut state = serializer.serialize_struct("Config", 2)?;
        state.serialize_field("name", &self.name)?;
        state.serialize_field("value", &self.value)?;
        state.end()
    }
}

The serialize method takes a generic serializer S. This allows the same struct to serialize to TOML, JSON, or any other format supported by serde. The toml::to_string function passes a TOML serializer to this method. The method calls serialize_struct to start a table. It calls serialize_field for each field. It calls end to finish.

The ? operator propagates errors. If the serializer encounters an issue, it returns an error. The error bubbles up to to_string, which returns a Result<String, toml::ser::Error>. You must handle the result. In production code, use ? to propagate the error to the caller. Do not unwrap in library code.

The generated code is efficient. It avoids allocations where possible. It reuses the serializer's internal buffers. The performance is close to hand-written serialization code.

Real-world configuration

Real configuration files have nested structures, optional fields, and lists. serde handles these naturally.

use serde::Serialize;

/// Database connection settings.
#[derive(Serialize)]
struct Database {
    host: String,
    port: u16,
    // Optional fields become optional in TOML.
    // None values are omitted from the output.
    password: Option<String>,
}

/// Top-level configuration with nested structures.
#[derive(Serialize)]
struct AppConfig {
    app_name: String,
    // Nested structs serialize as TOML tables.
    database: Database,
    // Vectors serialize as arrays.
    features: Vec<String>,
}

fn main() {
    let config = AppConfig {
        app_name: "production_server".to_string(),
        database: Database {
            host: "localhost".to_string(),
            port: 5432,
            password: None, // This field will be omitted.
        },
        features: vec!["logging".to_string(), "metrics".to_string()],
    };

    // Use to_string_pretty for human-readable output.
    let toml_str = toml::to_string_pretty(&config).unwrap();
    println!("{}", toml_str);
}

The output looks like this.

app_name = "production_server"

[database]
host = "localhost"
port = 5432

features = ["logging", "metrics"]

Notice the password field is missing. serde skips Option fields when they are None. This is the default behavior and usually what you want for configuration files. You do not want password = null in your TOML file. TOML does not support null values. If you try to serialize a None value that is not skipped, the serializer will error or produce invalid TOML depending on the version. The skip behavior keeps your files clean.

Use to_string_pretty for files that humans edit. The function adds newlines and indentation. It produces output that is easy to read and modify. Use to_string for machine-to-machine communication or when storage size is critical. The compact output has minimal whitespace.

Convention dictates that configuration structs derive both Serialize and Deserialize. You need Serialize to write the file. You need Deserialize to read it back. Add #[derive(Deserialize)] to your structs when you need round-trip support.

Customizing the output

Sometimes the default serialization does not match your requirements. You might need to rename fields, skip fields, or flatten nested structures. serde provides attributes for these cases.

use serde::Serialize;

/// Configuration with custom serialization rules.
#[derive(Serialize)]
struct Config {
    // Rename the field in the output.
    #[serde(rename = "app_name")]
    name: String,

    // Skip this field during serialization.
    #[serde(skip_serializing)]
    internal_id: u64,

    // Flatten this struct into the parent table.
    #[serde(flatten)]
    database: Database,
}

#[derive(Serialize)]
struct Database {
    host: String,
    port: u16,
}

fn main() {
    let config = Config {
        name: "my_app".to_string(),
        internal_id: 12345,
        database: Database {
            host: "localhost".to_string(),
            port: 5432,
        },
    };

    let toml_str = toml::to_string_pretty(&config).unwrap();
    println!("{}", toml_str);
}

The output merges the Database fields into the root table.

app_name = "my_app"

host = "localhost"
port = 5432

The rename attribute changes the key name in the output. This is useful when you want Rust naming conventions in code but different naming in the config file. The skip_serializing attribute removes a field from the output. Use this for transient data that should not be persisted. The flatten attribute merges a nested struct into the parent. This avoids creating a nested table when the data belongs at the same level.

Use #[serde(skip_serializing_if = "Option::is_none")] explicitly if you want to document the skip behavior. The default behavior skips None values, but the attribute makes the intent clear to readers. It also allows you to customize the condition for skipping.

Convention favors explicit attributes over implicit behavior. If a field is skipped, add the attribute. Future readers will understand why the field is missing. Do not rely on magic defaults without documentation.

Pitfalls and compiler errors

Forgetting the derive attribute is the most common mistake. If you try to serialize a struct without #[derive(Serialize)], the compiler rejects the code with E0277 (the trait bound Config: Serialize is not satisfied). The error message points to the call to toml::to_string. The fix is adding the derive attribute to the struct.

error[E0277]: the trait bound `Config: serde::Serialize` is not satisfied

Another trap is passing the wrong type. toml::to_string takes a reference &T. If you pass a value by move, the compiler rejects it with E0308 (mismatched types). The error message shows the expected type and the provided type. The fix is adding an ampersand to pass a reference.

error[E0308]: mismatched types
  --> src/main.rs:10:38
   |
10 |     let toml_str = toml::to_string(config).unwrap();
   |                    --------------- ^^^^^^ expected `&Config`, found `Config`

Serialization can fail. toml::to_string returns a Result. The error type is toml::ser::Error. Errors occur when the serializer encounters an unsupported value or a custom serializer fails. In production code, handle the error. Use ? to propagate it. Do not unwrap unless you are certain the error is impossible. If you discard the result, the compiler warns you. Use let _ = toml::to_string(&config); to signal that you intentionally dropped the result.

TOML has no null values. If your data model relies on nulls, TOML is a poor fit. serde skips None values by default. If you need to represent "missing" versus "empty", you must use a different strategy. You can use Option<String> where None means missing and Some("") means empty. Or you can use a custom serializer. Or you can switch to a format that supports nulls, like JSON.

Treat the Result as a contract. If serialization can fail, handle the failure. Do not assume success.

Decision matrix

Use toml::to_string_pretty when writing configuration files that humans will edit. The indentation and newlines make the file readable and maintainable.

Use toml::to_string when serializing for storage where size matters slightly more than readability, or when embedding TOML inside another format. The output is compact with minimal whitespace.

Use toml::to_writer when streaming data directly to a file or network socket. This avoids allocating a large intermediate String in memory. Pass a &mut dyn std::io::Write to the function.

Use serde_json when interoperability with JavaScript or web APIs is the priority. JSON is the universal lingua franca, even if TOML is nicer for configs.

Use ron (Rusty Object Notation) when you need a format that supports comments and is slightly more compact than TOML while staying human-readable. RON is designed specifically for Rust workflows.

Pick the format that matches your audience. Humans get pretty TOML. Machines get compact JSON.

Where to go next