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:
[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.
Read the file, then parse the text
Section titled “Read the file, then parse the text”config.json next to Cargo.toml:
{ "host": "localhost", "port": 8080, "retries": 3}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.
What a schema change breaks
Section titled “What a schema change breaks”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:
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:
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 11The other direction fails loudly by default. A struct that gains a field breaks every file written before it:
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:
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.
- How to handle a missing field in JSON —
Option,default, and the difference between the two - How to rename JSON fields with serde — when the file spells a key differently
- How to let a CLI read a config file — the same derive, reading TOML instead of JSON
- Back to Working with JSON, TOML and CSV
The next page in this chapter parses TOML, where the same struct reads a different format.