When text needs to become data
You are building a command-line tool that needs configuration. You write a config.toml file with your settings. Now your Rust program needs to read that file and turn the text into actual data you can use. You could write a parser from scratch, but that is reinventing the wheel. The ecosystem has a standard way to handle this that feels natural once you see the pattern.
Rust makes configuration parsing safe and ergonomic. You define the shape of your data, and the compiler guarantees the data fits.
The serde engine
Parsing TOML in Rust relies on a pair of crates: toml and serde. The toml crate knows how to read the text format. serde is the workhorse that turns that text into Rust types.
Think of serde like a universal adapter for data. You define the shape of your data using a Rust struct. serde looks at that shape, reads the TOML, and fills in the blanks. If the TOML matches your struct, you get a populated object. If it does not, you get a clear error.
The magic happens through the Deserialize trait. When you add #[derive(Deserialize)] to a struct, the compiler generates code that tells serde how to construct an instance of that struct from external data. The toml crate provides the deserializer that feeds data into this generated code.
Add these dependencies to your Cargo.toml:
[dependencies]
toml = "0.8"
serde = { version = "1.0", features = ["derive"] }
The derive feature in serde is essential. It enables the #[derive(Deserialize)] macro. Without it, you cannot use the derive attribute.
Minimal parsing example
Start with a simple struct and a string literal. This example shows the core pattern without file I/O noise.
use serde::Deserialize;
/// Configuration for the application.
#[derive(Deserialize, Debug)]
struct AppConfig {
/// The name of the app.
name: String,
/// The version number.
version: u32,
}
fn main() {
let toml_text = r#"
name = "my-tool"
version = 2
"#;
// Parse the string into our struct.
let config: AppConfig = toml::from_str(toml_text).expect("Failed to parse config");
println!("Running {} v{}", config.name, config.version);
}
The r# syntax creates a raw string literal. This lets you include quotes and newlines without escaping. The toml::from_str function takes the string and returns a Result. The expect call panics if parsing fails, which is fine for a quick test but not for production code.
Derive the trait. Parse the string. Trust the types.
How the parser fills the struct
When you call toml::from_str, the toml crate parses the text into an intermediate representation. Then serde takes over. It uses the Deserialize trait on AppConfig to map the TOML keys to struct fields.
The name field expects a String, so serde takes the TOML string and creates a Rust String. The version field expects a u32, so serde parses the integer. If everything matches, you get your struct.
The mapping is based on field names. By default, serde expects the TOML keys to match the Rust field names exactly. Rust uses snake_case. TOML often uses kebab-case. This mismatch causes a common error.
Add #[serde(rename_all = "kebab-case")] to the struct to handle this automatically. This is a community convention for configuration structs. It lets you write my-tool in the TOML file while keeping my_tool in the Rust code.
#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
struct AppConfig {
app_name: String,
log_level: String,
}
Now the TOML key app-name maps to the field app_name. The rename attribute applies to all fields. Use #[serde(rename = "custom-key")] on a specific field if you need a one-off exception.
Convention aside: Always add #[derive(Debug)] to config structs. It lets you print the struct during development. You will thank yourself when debugging a weird config value.
Real-world configuration loading
Real applications read config from files. This requires file I/O and proper error handling. The Result type is your friend here.
use serde::Deserialize;
use std::fs;
/// Database connection settings.
#[derive(Deserialize, Debug)]
struct DatabaseConfig {
host: String,
port: u16,
}
/// Top-level configuration.
#[derive(Deserialize, Debug)]
struct Config {
app_name: String,
debug: bool,
database: DatabaseConfig,
/// Optional feature flags.
features: Option<Vec<String>>,
}
/// Load configuration from a TOML file.
fn load_config(path: &str) -> Result<Config, Box<dyn std::error::Error>> {
// Read the file contents into a String.
let content = fs::read_to_string(path)?;
// Parse the TOML string into the Config struct.
let config: Config = toml::from_str(&content)?;
Ok(config)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = load_config("config.toml")?;
println!("{:#?}", config);
Ok(())
}
The load_config function returns a Result. The ? operator propagates errors. If the file is missing, read_to_string returns an error and load_config returns early. If the TOML is malformed, from_str returns an error and load_config returns early.
The main function also returns a Result. This lets you use ? in main and have the runtime print the error for you. This is standard practice for CLI tools.
Nested structs work naturally. serde recurses into the database field and deserializes the DatabaseConfig struct. The features field is an Option<Vec<String>>. If the key is missing in the TOML, serde sets the field to None. If the key is present, serde parses the array.
Return Result. Let the caller decide how to handle a bad config file.
Handling missing and optional fields
Configuration files often have defaults. You do not want to require every field in the TOML if a sensible default exists.
Use Option<T> for fields that might be missing and where None is a valid state. Use #[serde(default)] for fields that should fall back to a default value when missing.
#[derive(Deserialize, Debug)]
struct ServerConfig {
host: String,
/// Defaults to 8080 if not specified.
#[serde(default = "default_port")]
port: u16,
/// Defaults to false if not specified.
#[serde(default)]
debug: bool,
}
fn default_port() -> u16 {
8080
}
The #[serde(default)] attribute on debug uses the Default trait for bool, which is false. The #[serde(default = "default_port")] attribute calls the default_port function. This function must be in scope.
You can also use #[serde(default)] on a struct field that implements Default. For custom types, implement Default on the type itself.
If you want to reject unknown fields, add #[serde(deny_unknown_fields)]. This turns typos in the TOML file into errors. Without this, serde silently ignores extra keys. This is a safety feature for configuration.
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct StrictConfig {
name: String,
}
If the TOML contains name = "test" and typo_field = "oops", the parse fails with an error about an unknown field.
Use Option for truly optional data. Use default for sensible fallbacks. Deny unknown fields to catch typos early.
Pitfalls and compiler errors
Parsing TOML trips up on a few common patterns. The compiler and runtime errors point you in the right direction.
If you forget #[derive(Deserialize)], the compiler rejects the code with E0277 (the trait Deserialize is not implemented for Config). The fix is to add the derive attribute.
// This fails with E0277.
struct Config {
name: String,
}
let config: Config = toml::from_str(toml_str)?;
If you have a type mismatch, serde returns a runtime error. For example, if the TOML has version = "two" but the struct expects u32, the parse fails. The error message tells you the expected type and the actual value.
If you try to use toml::from_str with a type that does not implement Deserialize, you get E0277. This also happens if you forget to add the derive feature to serde in Cargo.toml.
Case sensitivity causes silent failures if you are not careful. If the struct uses snake_case and the TOML uses kebab-case, the fields end up as default values or None. The parse succeeds, but the data is wrong. Use #[serde(rename_all = "kebab-case")] to prevent this.
If you hardcode a wrong type in the struct, the compiler catches it with E0308 (mismatched types). For example, if you declare version: String but try to assign a u32 literal, the compiler rejects it. This is a compile-time check, not a parsing error.
Check for E0277 first. The derive is usually the missing piece.
Decision matrix: parsing strategies
Choose the parsing function based on your data source and performance needs.
Use toml::from_str when you have the TOML content as a String or &str. This is the standard choice for most applications.
Use toml::from_slice when you read a file into a Vec<u8> and want to avoid an extra allocation for the string. This is slightly more efficient for large files.
Use toml::Value when you need to inspect the structure dynamically without a predefined struct. This is useful for tools that process arbitrary TOML files.
Use serde_json when your config format is JSON instead of TOML. The API is identical. You just swap the crate.
Reach for manual parsing only when you are building a custom DSL and standard TOML is too restrictive. This is rare. The toml crate covers almost all use cases.
Match the parsing function to your data source. Avoid unnecessary allocations.