How to Use Serde with TOML Files in Rust

Use the toml crate with serde to parse TOML configuration files into Rust structs by deriving Deserialize.

When TOML meets Rust structs

You're building a CLI tool. It needs a config file. You pick TOML because it's readable and structured. You write a Rust struct to hold the settings. Now you have a string of TOML and a struct, and you need to bridge the gap. You don't want to write a parser. You want the compiler to help you match the fields.

That's where serde and the toml crate meet. serde is the engine that turns data into Rust types. The toml crate knows TOML syntax and talks to serde. You derive traits on your struct, and the compiler generates the code to move data back and forth. No manual parsing. No string splitting. Just types.

Derive the traits. Let the compiler do the heavy lifting.

How the pieces fit together

serde defines a set of traits: Serialize for turning Rust types into data, and Deserialize for turning data into Rust types. serde doesn't know anything about TOML. It's format-agnostic. The toml crate implements the serde traits for TOML syntax. It reads the text, finds keys and values, and calls the code generated by serde to fill your struct.

Think of serde as a universal adapter for your structs. The toml crate is the plug that fits into the TOML socket. When you write #[derive(Deserialize)], you're asking the compiler to generate an implementation of the Deserialize trait for your struct. The generated code knows how to ask the toml deserializer for specific fields and assign them.

Add serde to your dependencies. The toml crate requires it, and you can't derive the traits without it.

Minimal example

Start with a simple struct and a TOML string. The toml crate provides from_str to parse the string into your type.

[dependencies]
toml = "0.8"
serde = { version = "1", features = ["derive"] }
use serde::Deserialize;

// Derive generates the code to parse TOML into this struct.
#[derive(Deserialize, Debug)]
struct Config {
    name: String,
    version: u32,
}

fn main() {
    let input = r#"
        name = "my-tool"
        version = 1
    "#;

    // toml::from_str returns a Result. unwrap panics on error.
    let config: Config = toml::from_str(input).unwrap();
    println!("{:?}", config);
}

The features = ["derive"] line is essential. Without it, serde won't provide the macro to generate the trait implementations. The toml crate version 0.8 is the current stable release. It uses serde 1.0.

Run this and you get Config { name: "my-tool", version: 1 }. The parser matched the keys to the fields and converted the values.

Keep serde in your dependencies. The toml crate won't work without it.

What happens under the hood

When you call toml::from_str, the toml parser scans the input string. It identifies keys, values, tables, and arrays. For each key, it looks up the corresponding field in your struct. If the field exists, it passes the value to the deserializer. The deserializer checks the type. If the types match, it assigns the value. If they don't, it returns an error.

The #[derive(Deserialize)] macro generates a Visitor struct. The visitor knows the shape of your struct. It tells the deserializer which fields to expect and in what order. The deserializer feeds the values one by one. The visitor collects them and constructs the struct.

This process happens at runtime. The code generation happens at compile time. The compiler inlines the visitor logic, so there's no overhead from reflection or dynamic dispatch. The generated code is as fast as hand-written parsing, but safer.

serde also handles type coercion in some cases. If your struct has a u32 field and the TOML has 42, the parser converts the integer. If the TOML has 42.5, the parser rejects it because it can't lose precision. serde tries to help, but it won't silently truncate data.

Trust the borrow checker. It usually has a point. Here, trust the type system. It catches mismatches before you run.

Realistic configuration

Real config files have nested tables, optional fields, and defaults. serde handles all of this with attributes.

use serde::{Deserialize, Serialize};

// Derive both traits to support reading and writing config.
#[derive(Deserialize, Serialize, Debug)]
struct AppConfig {
    // Optional field with a default value.
    // If the key is missing, serde calls this function.
    #[serde(default = "default_port")]
    port: u16,

    // Nested table maps to a nested struct.
    database: DatabaseConfig,

    // Array of strings.
    tags: Vec<String>,

    // Rename allows TOML keys to differ from Rust field names.
    #[serde(rename = "log-level")]
    log_level: String,
}

fn default_port() -> u16 {
    8080
}

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

The TOML file looks like this:

tags = ["cli", "tool"]
log-level = "debug"

[database]
host = "localhost"
port = 5432

Notice the log-level key. Rust field names can't contain hyphens. The #[serde(rename)] attribute tells serde to map the TOML key log-level to the Rust field log_level. Without this, the parser reports a missing field error.

The #[serde(default = "default_port")] attribute provides a fallback. If the port key is missing from the TOML, serde calls default_port() and uses the result. This is cleaner than using Option<u16> everywhere. Users don't need to specify every field.

The Serialize derive lets you write the config back to TOML. toml::to_string(&config) produces a TOML string. This is useful for tools that edit config files or generate defaults.

Define your defaults once. The parser respects them.

Pitfalls and compiler errors

Missing the derive macro is the most common mistake. If you forget #[derive(Deserialize)], the compiler rejects the code with E0277 (trait bound not satisfied). The error message tells you that Config doesn't implement Deserialize. Add the derive and the error goes away.

error[E0277]: the trait bound `Config: serde::de::Deserialize<'_>` is not satisfied

TOML keys with hyphens break Rust field names. If your TOML has my-key, you must use #[serde(rename = "my-key")]. If you don't, the parser treats my-key as an unknown field and reports a missing field error for the Rust field. This is a runtime error, not a compile error. The compiler can't see the TOML content.

Type mismatches also cause runtime errors. If your struct has version: u32 and the TOML has version = "1", the parser fails. serde won't coerce strings to integers. Fix the TOML or change the struct type.

Using unwrap on the result panics on invalid input. In production code, handle the error. Use match or the ? operator.

let config: Config = match toml::from_str(input) {
    Ok(c) => c,
    Err(e) => {
        eprintln!("Failed to parse config: {}", e);
        std::process::exit(1);
    }
};

The toml::de::Error type implements std::error::Error. It provides context about which field failed and why. Print the error to help users fix their config.

Rename the field. The compiler won't catch TOML key mismatches.

Convention asides

The community convention for serde is to derive both Serialize and Deserialize on config structs. Even if you only read the config now, you might need to write it later. Deriving both costs nothing and saves refactoring later.

When using #[serde(default)], prefer named functions over inline closures for complex defaults. Named functions are easier to test and document. For simple defaults, #[serde(default)] without a function uses Default::default().

TOML keys often use hyphens. Rust fields use underscores. Always use #[serde(rename)] to bridge the gap. Don't rename your Rust fields to match TOML. Keep Rust idiomatic. Let serde handle the translation.

The toml crate re-exports serde in some versions, but don't rely on it. Add serde explicitly to your Cargo.toml. It makes your dependencies clear and avoids version conflicts.

Decision matrix

Use toml::from_str when you have a TOML string and a known struct. This is the standard path for config files.

Use toml::from_str combined with std::fs::read_to_string when reading from a file. Read the file into a string, then parse.

Use toml::Value when the config shape is dynamic or unknown. Value is an enum that represents any TOML value. It lets you inspect keys at runtime without a struct.

Use #[serde(default)] when a field is optional but you want a sensible fallback. This avoids Option<T> boilerplate in your code.

Use #[serde(rename)] when TOML keys use hyphens or different casing than Rust fields. This keeps Rust field names idiomatic.

Use #[serde(deny_unknown_fields)] when you want strict parsing. This rejects any TOML keys that don't match a struct field. It catches typos in config files early.

Use serde_json when you need JSON, not TOML. serde works with many formats. Pick the crate that matches your data.

Stick to structs for config. Reach for Value only when you have to.

Where to go next