When a config file meets a struct
You're building a CLI tool that needs a configuration file. JSON feels too verbose for humans to edit. YAML promises simplicity but delivers parsing nightmares. You find TOML, which looks like a sensible config format, and you want to read it. You add the toml crate, write a struct, and immediately get hit with a wall of errors about serde and Deserialize. Why does parsing a text file require a whole ecosystem?
The answer lies in how Rust handles data shapes. The toml crate is not a standalone parser. It is an adapter for serde, the serialization framework that powers almost all data interchange in Rust. Your Rust struct defines the shape of data in memory. TOML defines the shape of data in text. serde provides the universal rules for mapping one shape to the other. The toml crate implements those rules for the TOML format. You need both crates working together. serde generates the mapping code via macros. toml reads the text and feeds it to that code.
How the pieces fit together
Think of serde as a universal power adapter. Your struct is a specific appliance with a unique plug. TOML is a wall socket in a different country. The toml crate is just the wire that connects the socket to the adapter. You cannot plug the appliance in without the adapter.
When you write #[derive(Deserialize)], you are asking serde to generate an implementation of the Deserialize trait for your struct. This generated code contains logic that inspects the incoming data stream, matches keys to field names, converts types, and constructs the struct. The toml crate provides the deserializer that tokenizes TOML text and presents it to your generated code in a standard way.
This separation is why you see serde everywhere. The same #[derive(Deserialize)] works for JSON, YAML, message packs, and TOML. You only change the parser crate. The struct stays the same.
Minimal example
Start with a simple struct and a TOML string. You need two dependencies: toml for parsing, and serde with the derive feature for the macro.
[dependencies]
toml = "0.8"
serde = { version = "1.0", features = ["derive"] }
The toml crate depends on serde, but it does not enable the derive feature by default. You must add serde explicitly with features = ["derive"] to use #[derive(Deserialize)]. This is a common stumbling block. The compiler will complain about missing traits if you skip this step.
use serde::Deserialize;
/// Configuration for a simple application.
#[derive(Deserialize, Debug)]
struct Config {
title: String,
edition: String,
}
fn main() {
let toml_str = r#"
title = "The Rust Programming Language"
edition = "2024"
"#;
// toml::from_str parses the string and delegates to serde.
// unwrap() panics on error; handle errors in production code.
let config: Config = toml::from_str(toml_str).unwrap();
println!("{:?}", config);
}
Run this and you get Config { title: "The Rust Programming Language", edition: "2024" }. The parser matched the keys title and edition to the struct fields. It converted the TOML strings to Rust String values.
What happens under the hood
At compile time, the #[derive(Deserialize)] macro scans your Config struct. It generates an impl Deserialize for Config. This implementation contains code that says: "Ask the deserializer for a field named title. Expect a string. Store it. Then ask for edition. Expect a string. Store it." The macro also generates code to handle missing fields, type mismatches, and extra data. If your struct has a field that the TOML lacks, the generated code returns an error unless you configure a default.
At runtime, toml::from_str creates a TOML deserializer. It parses the input string into a stream of tokens. It hands those tokens to your generated Deserialize implementation. The implementation pulls values out of the token stream and constructs the struct. If the TOML is missing title, the generated code returns a toml::de::Error immediately. The error includes the line and column number, making debugging straightforward.
Realistic configuration
Real configs have nested tables, arrays, optional fields, and defaults. serde handles all of these with attributes.
use serde::Deserialize;
/// Application configuration with nested tables and defaults.
#[derive(Deserialize, Debug)]
struct AppConfig {
// Nested table maps to a nested struct.
server: ServerConfig,
// Optional field with a default value.
// If the key is missing, serde calls default_port().
#[serde(default = "default_port")]
port: u16,
// Array of strings.
features: Vec<String>,
}
#[derive(Deserialize, Debug)]
struct ServerConfig {
host: String,
timeout: u64,
}
fn default_port() -> u16 {
8080
}
fn main() {
let toml_str = r#"
[server]
host = "localhost"
timeout = 30
features = ["logging", "metrics"]
"#;
// Expect panics on error; use match or ? in real code.
let app: AppConfig = toml::from_str(toml_str).expect("Valid config");
println!("{:?}", app);
}
The #[serde(default = "default_port")] attribute tells serde to call default_port() when the port key is missing. This is standard practice for config files. Users should not be forced to specify every single option. Provide sensible defaults and let them override what they need.
Convention: Kebab-case vs Snake-case
TOML keys often use hyphens. Rust fields use underscores. The compiler will not match my-config to my_config. You get a "missing field" error even though the key exists.
Add #[serde(rename_all = "kebab-case")] to your struct to bridge the gap automatically. This is a community convention. It saves you from writing #[serde(rename = "...")] on every field.
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
struct Config {
// Matches "max-connections" in TOML.
max_connections: u32,
}
Don't fight the case mismatch. Add rename_all and move on.
Pitfalls and error handling
If you forget the serde dependency or the derive feature, the compiler rejects your code with E0277 (the trait bound Config: Deserialize<'_> is not satisfied). The toml crate requires serde to function. If you forget #[derive(Deserialize)], you get the same error. The struct doesn't know how to deserialize itself.
If you parse a file and the content is malformed, toml::from_str returns a Result. Calling .unwrap() on a bad file panics. Use match or the ? operator to handle errors gracefully.
use std::fs;
fn load_config(path: &str) -> Result<AppConfig, Box<dyn std::error::Error>> {
let content = fs::read_to_string(path)?;
let config: AppConfig = toml::from_str(&content)?;
Ok(config)
}
The ? operator propagates errors up the call stack. fs::read_to_string returns an io::Error if the file is missing. toml::from_str returns a toml::de::Error if the syntax is invalid. Both implement std::error::Error, so Box<dyn std::error::Error> works as a catch-all return type. In production code, consider using a custom error enum for better diagnostics.
TOML is strict about types. A string in TOML cannot deserialize into a u32 in Rust. The error message will point to the line and column of the mismatch. If you need flexibility, use String in your struct and parse the value manually, or use #[serde(deserialize_with = "...")] for custom logic.
Dynamic parsing with toml::Value
Sometimes you don't know the structure ahead of time. You're building a linter, a migration tool, or a plugin system. You cannot define a struct for every possible config. Use toml::Value. It is an enum that represents any TOML value. You can match on it or use helper methods like get().
use toml::Value;
fn main() {
let toml_str = r#"
[database]
host = "localhost"
port = 5432
"#;
// Parse into a generic Value enum.
let value: Value = toml::from_str(toml_str).unwrap();
// Access nested values safely using get().
// and_then chains the Option results.
if let Some(host) = value.get("database").and_then(|db| db.get("host")) {
if let Some(host_str) = host.as_str() {
println!("Host: {}", host_str);
}
}
}
toml::Value gives you full access to the data without a schema. It is slower than struct deserialization because it requires runtime checks. Use it only when you truly need dynamic access. Treat toml::Value as a safe map, not a magic box. You still have to handle missing keys and type mismatches manually.
Writing TOML back to disk
The toml crate can also serialize Rust structs back to TOML strings. Use toml::to_string or toml::to_string_pretty. The latter adds indentation and comments for readability.
use serde::Serialize;
#[derive(Serialize, Debug)]
struct Config {
title: String,
edition: String,
}
fn main() {
let config = Config {
title: "My App".to_string(),
edition: "2024".to_string(),
};
// Serialize to a pretty-printed TOML string.
let toml_str = toml::to_string_pretty(&config).unwrap();
println!("{}", toml_str);
}
You need #[derive(Serialize)] for writing. This generates the Serialize trait implementation. The process is the reverse of deserialization. serde walks the struct and emits TOML tokens. The toml crate formats those tokens into text.
Decision matrix
Use toml when you need a human-readable configuration format for CLI tools or applications where users edit files by hand. Use serde_json when you are building an API or exchanging data between services where compactness and universal support matter more than readability. Use toml::Value when you need to parse TOML dynamically without a predefined struct, such as when building a tool that processes arbitrary configuration files. Use #[serde(default)] when your config fields should have sensible fallbacks if the user omits them, rather than failing on missing keys. Use #[serde(rename_all = "kebab-case")] when your TOML keys use hyphens and your Rust fields use underscores.