When config files become your problem
You're writing a Rust app and it needs configuration: a database URL, a couple of feature flags, maybe a list of allowed origins. You could put it all in environment variables, but a TOML file is nicer. Cargo itself uses TOML, your editor uses TOML, half the Rust ecosystem reaches for it the moment a config file is involved. The format is simple, comments are allowed, and the syntax stays out of your way.
Reading TOML in Rust is one of those tasks where the answer fits in three lines but the surrounding context is worth understanding. There are a couple of crates to choose from, two different "shapes" of API depending on whether you want strongly-typed config or a free-form value tree, and a handful of pitfalls when your TOML schema gets richer than a flat list of keys.
Setting up the dependency
The crate you want is plain toml. It's the de-facto standard, written by the same folks who maintain Cargo's parser, and it integrates with serde so you get derive-based deserialization for free.
# Add toml plus serde with the derive feature.
cargo add toml
cargo add serde --features derive
That gives you these lines in Cargo.toml:
[dependencies]
toml = "0.8"
serde = { version = "1", features = ["derive"] }
Why both? The toml crate handles the syntax of TOML (parsing the text, formatting values), but it leans on serde for the deserialization framework. If you want to deserialize into your own structs, you need serde with the derive feature so you can put #[derive(Deserialize)] on them. If you only ever use toml::Value, you can technically get away with just toml, but you'll almost always want the typed path eventually.
Parsing into a typed struct
The most useful pattern: define a Rust struct that mirrors the TOML schema and deserialize the file into it.
use serde::Deserialize;
// A struct that mirrors the TOML layout. Field names match TOML keys
// by default. Each field type tells the parser what to expect.
#[derive(Debug, Deserialize)]
struct Config {
name: String,
version: String,
debug: bool,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let raw = r#"
name = "example"
version = "0.1.0"
debug = true
"#;
// toml::from_str parses the string and runs serde over our struct.
// If a field is missing or has the wrong type, you get a parse error.
let config: Config = toml::from_str(raw)?;
println!("{config:?}"); // Config { name: "example", version: "0.1.0", debug: true }
Ok(())
}
Three things worth noticing. The struct fields don't need to be in the same order as the TOML keys: TOML is unordered, like a JSON object, so toml::from_str matches by name. Missing fields cause an error by default; if a field is optional, wrap its type in Option<T> or give it a default with #[serde(default)]. Extra fields in the TOML are silently ignored unless you opt in to strict checking with #[serde(deny_unknown_fields)].
Reading from a file in practice
Production code reads TOML from disk, not a string literal. The pattern is two steps: read the file, then parse.
use serde::Deserialize;
use std::{fs, path::Path};
#[derive(Debug, Deserialize)]
struct Config {
server: ServerConfig,
database: DatabaseConfig,
}
// Nested structs map to TOML's [section] tables. You define one
// struct per section and reference them from the top-level struct.
#[derive(Debug, Deserialize)]
struct ServerConfig {
host: String,
port: u16,
}
#[derive(Debug, Deserialize)]
struct DatabaseConfig {
url: String,
pool_size: u32,
}
// Load + parse the config from a path. Returns the deserialized struct
// or an error explaining what went wrong.
fn load_config(path: impl AsRef<Path>) -> Result<Config, Box<dyn std::error::Error>> {
// fs::read_to_string slurps the whole file as UTF-8.
let raw = fs::read_to_string(path)?;
let cfg = toml::from_str(&raw)?;
Ok(cfg)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cfg = load_config("config.toml")?;
println!("{cfg:#?}");
Ok(())
}
The matching config.toml looks like this:
[server]
host = "127.0.0.1"
port = 8080
[database]
url = "postgres://localhost/app"
pool_size = 8
The [server] and [database] headers are TOML's table syntax. They map cleanly onto nested structs. If you needed an array of tables, like [[users]], that maps onto Vec<UserConfig>.
When you don't have a fixed schema
Sometimes you can't define a struct in advance. You're writing a generic config viewer, or you only care about a subset of keys, or the schema varies by user. Use toml::Value, the dynamic tree representation:
use toml::Value;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let raw = r#"
title = "TOML Example"
[owner]
name = "Tom Preston-Werner"
dob = 1979-05-27T07:32:00-08:00
[database]
ports = [ 8000, 8001, 8002 ]
"#;
// Parse into a Value: a tagged enum that can represent any TOML data.
let value: Value = raw.parse()?;
// Drill in by key. Each step returns Option, so chain with `?` (here
// we use Option-style indexing).
if let Some(title) = value.get("title").and_then(|v| v.as_str()) {
println!("Title: {title}");
}
if let Some(ports) = value.get("database").and_then(|d| d.get("ports")).and_then(|p| p.as_array()) {
for p in ports {
println!("Port: {}", p.as_integer().unwrap_or(0));
}
}
Ok(())
}
Value is more verbose to use because every step is an Option. The reward is full flexibility: you don't need to predict the schema. For most apps, the typed path is nicer and you should reach for Value only when the schema is genuinely dynamic.
Optional fields and defaults
Real configs often have fields that are present sometimes and not others. The Option<T> and #[serde(default)] patterns cover almost every case.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Config {
// Required: parsing fails if absent.
name: String,
// Optional: missing means None, present means Some(value).
description: Option<String>,
// Default: missing means the type's Default::default(), present means parsed.
// For u32 that's 0, for String "", for bool false.
#[serde(default)]
debug: bool,
// Custom default value via a function. Use this when 0 or "" isn't right.
#[serde(default = "default_threads")]
threads: u32,
}
fn default_threads() -> u32 { 4 }
Both patterns let you add new fields to a config schema without breaking anyone's existing files. That backward compatibility matters more than the syntax sugar suggests.
Writing TOML back out
The same crate handles serialization. Derive Serialize alongside Deserialize and call toml::to_string:
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct Config {
name: String,
port: u16,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cfg = Config { name: "demo".into(), port: 3000 };
// to_string produces compact TOML. to_string_pretty is similar
// but spreads things across more lines.
let text = toml::to_string(&cfg)?;
println!("{text}");
Ok(())
}
For round-tripping while preserving comments and whitespace, you want a different crate, toml_edit. The plain toml crate throws away comments on parse, which is fine for read-only configs but wrong if you're editing user-owned files programmatically.
Common pitfalls
You wrote Result<serde_json::Value, _> instead of Result<toml::Value, _>. They're similar but not interchangeable. Mixing them up gives a confusing trait-bound error. Use toml::Value with toml::from_str.
You forgot the derive feature on serde. Symptom: cannot find derive macro Deserialize. Fix the Cargo.toml line: serde = { version = "1", features = ["derive"] }.
The TOML you tried to parse has a typo or unquoted string. The parse error looks like:
Error: TOML parse error at line 3, column 12
|
3 | name = example
| ^
invalid string
expected a quoted string
TOML strings need quotes. Numbers, booleans, and dates don't. The error usually points right at the offending column.
You used {} formatting on a TOML value and got an unhelpful error. Value doesn't implement Display, only Debug. Use {:?} or {:#?} for pretty.
You hit "missing field". By default, every struct field is required. Wrap optional ones in Option<T> or add #[serde(default)].
Your config has a Rust keyword as a key (like type). You can rename: #[serde(rename = "type")] kind: String,. Or use raw identifiers: r#type: String,.
When to use what
Reach for the typed approach (#[derive(Deserialize)] plus a struct) for application configs. The compile-time check is most of the value: typos in your TOML become parse errors instead of silently wrong runtime behavior.
Reach for toml::Value when you genuinely don't know the schema or you're building something that operates on arbitrary TOML.
Reach for toml_edit when you need to modify a user's TOML file while preserving their comments and formatting (think: tools that update Cargo.toml).
If your config is small and you don't want a TOML dependency at all, environment variables and a couple of std::env::var calls work. But the moment your config grows past five or six values, TOML or a similar format starts paying for itself.
Where to go next
Reading config files is one corner of the broader topic of file I/O and data formats in Rust.