Skip to content
Select theme

How to parse a TOML config file

Short answer: Read the file with fs::read_to_string, then hand the text to toml::from_str into a struct that derives Deserialize. The derive is the same one the JSON pages use; only the crate changes.

This continues the json-demo project from the earlier pages in this chapter — cargo new json-demo, with one more crate:

Cargo.toml
[dependencies]
serde = { version = "1.0.229", features = ["derive"] }
toml = "1.1.8"

The exact versions depend on when you run cargo add; the derive feature is the part that matters. Each block below writes the file it reads before reading it, so it runs on its own — in your own code the file is already there and you delete that line.

config.toml
host = "localhost"
port = 8080
retries = 3
src/main.rs
use serde::Deserialize;
use std::fs;
#[derive(Debug, Deserialize)]
struct Config {
host: String,
port: u16,
retries: u32,
}
fn main() {
fs::write(
"config.toml",
r#"
host = "localhost"
port = 8080
retries = 3
"#,
)
.unwrap();
let text = fs::read_to_string("config.toml").unwrap();
let config: Config = toml::from_str(&text).unwrap();
println!("{config:?}");
}
Config { host: "localhost", port: 8080, retries: 3 }

TOML spells out what JSON leaves to the parser: 8080 is an integer and "8080" is a string, and the field’s type decides which one parses. from_str returns a Result, and unwrap says the program should die rather than start on a config it couldn’t read — the same default as the JSON page, for the same reason.

The [server] header opens a table that lasts until the next header, and each table maps to a struct:

config.toml
[server]
host = "localhost"
port = 8080
[database]
url = "postgres://localhost/app"
pool_size = 10
src/main.rs
use serde::Deserialize;
use std::fs;
#[derive(Debug, Deserialize)]
struct Config {
server: Server,
database: Database,
}
#[derive(Debug, Deserialize)]
struct Server {
host: String,
port: u16,
}
#[derive(Debug, Deserialize)]
struct Database {
url: String,
pool_size: u32,
}
fn main() {
fs::write(
"config.toml",
r#"
[server]
host = "localhost"
port = 8080
[database]
url = "postgres://localhost/app"
pool_size = 10
"#,
)
.unwrap();
let text = fs::read_to_string("config.toml").unwrap();
let config: Config = toml::from_str(&text).unwrap();
println!("{config:?}");
}
Config { server: Server { host: "localhost", port: 8080 }, database: Database { url: "postgres://localhost/app", pool_size: 10 } }

The dotted-key form names the same place, so a file can stay flat if that reads better:

config.toml
server.host = "localhost"
server.port = 8080

A key outside every table is a key on the root table, which is why the headerless file in the first block matched a flat struct.

The point of a config file is that it doesn’t have to say everything. #[serde(default = "path")] names a function that supplies the value when the key is absent, so the file overrides only what it mentions:

src/main.rs
use serde::Deserialize;
use std::fs;
fn default_port() -> u16 {
8080
}
fn default_retries() -> u32 {
5
}
#[derive(Debug, Deserialize)]
struct Config {
host: String,
#[serde(default = "default_port")]
port: u16,
#[serde(default = "default_retries")]
retries: u32,
}
fn main() {
fs::write(
"config.toml",
r#"
host = "localhost"
"#,
)
.unwrap();
let text = fs::read_to_string("config.toml").unwrap();
let config: Config = toml::from_str(&text).unwrap();
println!("{config:?}");
}
Config { host: "localhost", port: 8080, retries: 5 }

host has no default, so it is still required; port and retries fall back to their functions. #[serde(default)] with no path uses Default::default() instead — 0, false, an empty String — which is rarely the number you wanted, and How to handle a missing field in JSON covers the Option version of the same question.

TOML errors quote the offending line. Two of the ones a config file actually produces:

src/main.rs
use serde::Deserialize;
use std::fs;
#[derive(Debug, Deserialize)]
struct Config {
host: String,
port: u16,
}
fn main() {
fs::write("config.toml", "host = \"localhost\"\nport = \"8080\"\n").unwrap();
let text = fs::read_to_string("config.toml").unwrap();
match toml::from_str::<Config>(&text) {
Ok(config) => println!("{config:?}"),
Err(e) => println!("error: {e}"),
}
// The port is fixed; now the host is missing.
fs::write("config.toml", "port = 8080\n").unwrap();
let text = fs::read_to_string("config.toml").unwrap();
match toml::from_str::<Config>(&text) {
Ok(config) => println!("{config:?}"),
Err(e) => println!("error: {e}"),
}
}
error: TOML parse error at line 2, column 8
|
2 | port = "8080"
| ^^^^^^
invalid type: string "8080", expected u16
error: TOML parse error at line 1, column 1
|
1 | port = 8080
| ^
missing field `host`

The type error underlines the value itself, so the caret lands on "8080". The missing-field error points at line 1, column 1 — the top of the file — because a key that isn’t there has no line to point at. A key the struct doesn’t know is ignored, exactly as in JSON, so a typo like prot = 8080 configures nothing at all and still parses; #[serde(deny_unknown_fields)] is the fix, and the JSON config page shows it.

The next page in this chapter reads CSV into structs, where the header row has to match the field names.