Skip to content
Select theme

How to read a JSON config file

Short answer: Read the file with fs::read_to_string, then hand the text to serde_json::from_str into a struct that derives Deserialize. The struct is the schema: a key the file has that the struct doesn’t know is ignored, and a field the struct needs that the file doesn’t have is an error at start-up rather than a silent default.

This continues the json-demo project from the earlier pages in this chapter — cargo new json-demo, with the same two crates:

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

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.json next to Cargo.toml:

config.json
{
"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.json",
r#"{
"host": "localhost",
"port": 8080,
"retries": 3
}"#,
)
.unwrap();
let text = fs::read_to_string("config.json").unwrap();
let config: Config = serde_json::from_str(&text).unwrap();
println!("{config:?}");
}
Config { host: "localhost", port: 8080, retries: 3 }

Reading a config file is the same fs::read_to_string any other file uses — the JSON part starts once the text is in memory. from_str returns a Result, and unwrap says the program should die rather than start on a config it couldn’t read. For a config file that’s the right default: the alternative is a process running on settings nobody chose.

The file and the struct move on different schedules — the file is written by hand, or by an older build — so the interesting failures are the ones where they disagree. serde’s rule is one line: keys the struct doesn’t know are dropped, and fields the struct needs must be there.

A file that knows a key the struct doesn’t:

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

Nothing complains. An unrecognised key is ignored, which is how a typo — retrys where the struct says retries — configures nothing at all and still looks like it worked. #[serde(deny_unknown_fields)] turns that into the error it should have been:

src/main.rs
use serde::Deserialize;
use std::fs;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Config {
host: String,
port: u16,
}
fn main() {
fs::write(
"config.json",
r#"{
"host": "localhost",
"port": 8080,
"retries": 3
}"#,
)
.unwrap();
let text = fs::read_to_string("config.json").unwrap();
match serde_json::from_str::<Config>(&text) {
Ok(config) => println!("{config:?}"),
Err(e) => println!("error: {e}"),
}
}
error: unknown field `retries`, expected `host` or `port` at line 4 column 11

The other direction fails loudly by default. A struct that gains a field breaks every file written before it:

src/main.rs
use serde::Deserialize;
use std::fs;
#[derive(Debug, Deserialize)]
struct Config {
host: String,
port: u16,
retries: u32,
}
fn main() {
// A config.json from before the struct knew about retries.
fs::write("config.json", r#"{"host": "localhost", "port": 8080}"#).unwrap();
let text = fs::read_to_string("config.json").unwrap();
match serde_json::from_str::<Config>(&text) {
Ok(config) => println!("{config:?}"),
Err(e) => println!("error: {e}"),
}
}
error: missing field `retries` at line 1 column 35

#[serde(default)] on the field fills it from Default::default() — 0 for a u32 — so files written before the change keep working:

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

A default that isn’t zero needs its own function, and the difference between Option and default is what How to handle a missing field in JSON covers. When the file spells a key differently from the struct, rename it — that has its own page too.

The next page in this chapter parses TOML, where the same struct reads a different format.