How to Use TOML for Configuration in Rust

Configure mdBook settings like title, edition, and preprocessors using the book.toml file.

Hardcoding configuration is a trap

You are building a CLI tool. You hardcode the database URL and the log level. It works on your machine. You send the binary to a teammate. It crashes because the database URL is wrong. You realize configuration is not optional. You need a file format that humans can edit without crying. You look at JSON and cringe at the braces. You look at YAML and fear the indentation hell. You pick TOML.

TOML is the configuration format of the Rust ecosystem. Cargo.toml defines your dependencies. rustfmt.toml defines your style. Your application deserves the same treatment. TOML stands for Tom's Obvious, Minimal Language. It avoids the verbosity of JSON and the ambiguity of YAML. It uses key-value pairs and tables. Rust maps TOML tables to structs with near-zero friction.

The mold and the manifest

Your Rust struct is a mold with labeled slots. TOML is the manifest listing what goes in each slot. The toml crate reads the manifest. The serde crate acts as the crane operator, grabbing values from the manifest and dropping them into the slots of your struct.

If the manifest says "Slot A gets a string" and the slot expects a number, the crane operator stops immediately and reports the error. This is a feature. Configuration errors should be caught at startup, not at runtime when a user request fails. You need two crates: toml for parsing the text, and serde for converting the parsed data into your structs.

Add them to your Cargo.toml. The serde crate requires the derive feature to generate the boilerplate code automatically.

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

Minimal example

Start with a simple struct and a TOML string. Derive Deserialize on the struct. This tells serde how to fill the fields. Call toml::from_str to parse. The function returns a Result. You must handle the error.

use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Config {
    title: String,
    author: String,
    port: u16,
}

fn main() {
    let toml_str = r#"
        title = "Rust FAQ"
        author = "Dev Team"
        port = 8080
    "#;

    // toml::from_str returns Result<Config, toml::de::Error>.
    // Unwrap here for brevity; handle errors in real code.
    let config: Config = toml::from_str(toml_str).expect("Valid TOML");

    println!("Title: {}", config.title);
    println!("Port: {}", config.port);
}

The #[derive(Deserialize)] attribute generates the code that matches TOML keys to struct fields. Field names must match exactly, or you must use a rename attribute. Types must match. port is u16 in Rust, so the TOML value must be a valid integer. Put the port in the file, not the binary.

Reading from a file

Configuration lives in files, not string literals. Read the file with std::fs::read_to_string, then pass the content to toml::from_str. This separates I/O errors from parsing errors.

use serde::Deserialize;
use std::fs;

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

fn load_config(path: &str) -> AppConfig {
    // Read the file content. This can fail if the file is missing.
    let content = fs::read_to_string(path).expect("Config file exists");

    // Parse the TOML. This can fail if the format is wrong.
    toml::from_str(&content).expect("Config is valid TOML")
}

fn main() {
    let config = load_config("config.toml");
    println!("Host: {}, Port: {}", config.host, config.port);
}

Handle the Result properly in production code. Print the error message and exit with a non-zero status. toml errors are descriptive. They tell you exactly which line and key caused the problem. Trust the error message. It usually points to the typo.

Realistic configuration

Real configs have nested tables, arrays, and optional fields. TOML tables map to nested structs. Arrays map to Vec. Use #[serde(default)] to provide fallback values for missing keys. This prevents the parser from crashing when a user omits an optional setting.

Create a config.toml with nested structure.

[server]
host = "0.0.0.0"
port = 3000

[database]
url = "postgres://localhost/mydb"
pool_size = 10

[features]
# This array is optional. If missing, serde uses the default.
enabled = ["auth", "logging"]

Define the structs to match the hierarchy. Add #[serde(default)] to the features field. This tells serde to use Vec::default() (an empty vector) if the key is absent.

use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct AppConfig {
    server: ServerConfig,
    database: DatabaseConfig,
    #[serde(default)]
    features: Vec<String>,
}

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

#[derive(Debug, Deserialize)]
struct DatabaseConfig {
    url: String,
    #[serde(default = "default_pool_size")]
    pool_size: u32,
}

// Custom default function for pool_size.
fn default_pool_size() -> u32 {
    5
}

fn main() {
    let content = std::fs::read_to_string("config.toml").unwrap();
    let config: AppConfig = toml::from_str(&content).unwrap();

    println!("Server: {}:{}", config.server.host, config.server.port);
    println!("DB Pool: {}", config.database.pool_size);
    println!("Features: {:?}", config.features);
}

The #[serde(default = "default_pool_size")] attribute calls a function to get the default value. This is more flexible than #[serde(default)], which relies on the Default trait. Use custom defaults when the fallback value is not the type's default. Don't wrap everything in Option. Use Option<T> only when you need to distinguish "not set" from "default". For primitives, #[serde(default)] is cleaner.

Pitfalls and errors

Configuration parsing introduces specific failure modes. Know them before they bite you.

Missing fields crash the parse

By default, every field in the struct must exist in the TOML. If you add a field to the struct, existing config files break. This is good for safety. It forces you to update configs. It is bad for backward compatibility. Use #[serde(default)] or Option<T> for fields that can be absent. Decide explicitly whether a missing field is an error or a default.

Type mismatches fail fast

TOML is loosely typed. A number can be an integer or a float. Rust is strict. If you define count: u32 and the file has count = 1.5, the parse fails. toml tries to coerce integers to floats, but not floats to integers. Keep types consistent. If the config value might be a float, use f64 in Rust.

The E0277 trap

If you forget #[derive(Deserialize)], the compiler rejects your code with E0277 (the trait bound Config: Deserialize is not satisfied). This happens because toml::from_str requires the target type to implement Deserialize. Add the derive attribute. If you cannot modify the struct, implement Deserialize manually. The derive macro covers 99% of cases.

Renaming keys

TOML keys are case-sensitive. If your TOML uses db_url but your struct uses dbUrl, the parse fails. Use #[serde(rename = "db_url")] to map the struct field to the TOML key. This lets you follow Rust naming conventions in code while matching the config file.

#[derive(Debug, Deserialize)]
struct DatabaseConfig {
    #[serde(rename = "db_url")]
    connection_string: String,
}

Convention aside: The community prefers snake_case in TOML and snake_case in Rust structs. Rename only when the config file comes from an external source or legacy system. Keep naming consistent to reduce cognitive load.

Dynamic configuration

Sometimes you do not know the shape of the config ahead of time. Plugin systems often define their own config keys. Use toml::Value for dynamic access. It is an enum representing any TOML value. You can iterate over tables and arrays without a struct.

use toml::Value;

fn main() {
    let content = std::fs::read_to_string("plugins.toml").unwrap();
    let config: Value = toml::from_str(&content).unwrap();

    // Access nested values dynamically.
    if let Some(plugin_table) = config.get("plugins").and_then(|v| v.as_table()) {
        for (name, settings) in plugin_table {
            println!("Plugin: {}", name);
            if let Some(enabled) = settings.get("enabled").and_then(|v| v.as_bool()) {
                println!("  Enabled: {}", enabled);
            }
        }
    }
}

toml::Value gives you full control. You can inspect keys, check types, and handle arbitrary structures. Use structs for known configuration. Use Value for unknown or extensible configuration. Don't use Value for everything. Structs give you compile-time safety and IDE autocomplete. Reserve Value for the edges.

Decision matrix

Choose the right tool for the configuration layer. Each format has a specific role.

Use TOML when you need a human-readable config file with a clear hierarchy and you want to match the Rust ecosystem's conventions.

Use JSON when your configuration is generated by a machine or consumed by web APIs that expect strict schema validation.

Use environment variables when deploying to containerized environments where injecting secrets via files is difficult or insecure.

Use CLI arguments when users need to override specific settings for a single execution without editing files.

Use the config crate when you want to merge defaults, files, environment variables, and CLI arguments into a single configuration object automatically.

Treat the config file as a contract. If the file is missing or malformed, the application should fail fast with a clear error. Don't silently fall back to unsafe defaults. Let serde handle the mapping. You handle the logic.

Where to go next