How to merge config from file, environment and flags
Short answer: Put every source into one builder, in increasing order of urgency: Config::builder().add_source(File::new(..)).add_source(Environment::with_prefix(..)).build() from the config crate — a source added later wins over an earlier one — and hand the command line the last word with builder.set_override(key, value), which beats everything. The order is decided once, at the builder: flags beat environment beats file, and a key that a higher layer does not set falls through to the one below it.
Three sources for the same value is the normal state of a real program: a config file for the defaults, environment variables for the deployment, command-line flags for “just this once, run on port 9000”. Written ad hoc, each key grows its own little chain — env::var("PORT").unwrap_or(file_port) — and every chain drifts: one checks the environment before the file, another after, a third forgets the flag entirely. The fix is a single merge point with a single precedence order, and the config crate’s builder is exactly that.
The whole program
Section titled “The whole program”[dependencies]config = "0.15.27"serde = { version = "1.0.229", features = ["derive"] }The demo writes its own config file and parses its own arguments so the whole thing is one runnable program; in a real application the file already exists and the arguments come from a proper parser — the merge below does not care where either came from.
use config::{Config, Environment, File, FileFormat};use serde::Deserialize;
#[derive(Deserialize)]struct Server { host: String, port: u16,}
#[derive(Deserialize)]struct Settings { server: Server,}
fn main() { let path = std::env::temp_dir().join("merge-config-demo.toml"); std::fs::write(&path, "[server]\nhost = \"127.0.0.1\"\nport = 8080\n").unwrap();
let mut builder = Config::builder() .add_source(File::new(path.to_str().unwrap(), FileFormat::Toml)) .add_source(Environment::with_prefix("APP").prefix_separator("_").separator("__"));
for arg in std::env::args().skip(1) { let Some((key, value)) = arg.split_once('=') else { continue; }; builder = builder.set_override(key, value).unwrap(); }
let settings: Settings = builder.build().unwrap().try_deserialize().unwrap(); println!("host = {} port = {}", settings.server.host, settings.server.port);}With nothing set — the file is the only source speaking — the file’s values come through:
cargo run -qhost = 127.0.0.1 port = 8080The environment overrides the file
Section titled “The environment overrides the file”The Environment source maps variable names onto config keys. prefix_separator("_") strips the APP_ prefix, and separator("__") turns the double underscore into the nesting dot, so APP_SERVER__PORT becomes the key server.port:
APP_SERVER__PORT=7000 cargo run -qhost = 127.0.0.1 port = 7000Two things to notice in that output. The port moved, and the host did not — APP_SERVER__PORT says nothing about server.host, so the host falls through to the file. Precedence is per key, not per source.
The values from the environment are strings, but the Settings type still gets a real u16 — the crate coerces the string into the type the struct asks for, so "7000" deserializes without a try_parsing call.
The double separator call has a trap worth spelling out, because it silently produces the first run’s output instead of an error: in config 0.15, Environment::with_prefix("APP").separator("__") — without prefix_separator("_") — uses __ between the prefix and the rest too, so the variable it listens for is APP__SERVER__PORT. Setting the conventional APP_SERVER__PORT is not a failure; the program just quietly keeps the file’s value. prefix_separator("_") is what restores the usual APP_ spelling.
Flags override the environment
Section titled “Flags override the environment”set_override writes a key into a layer above every source. Feeding it key=value arguments is a five-line loop, and the key is the same dotted path the struct reads:
APP_SERVER__PORT=7000 cargo run -q -- server.port=9000host = 127.0.0.1 port = 9000The environment said 7000, the file said 8080, and the flag wins with 9000. That is the whole precedence order in one run. It also works with nothing else set — cargo run -q -- server.port=9000 prints port 9000 with the file’s host — and a flag for a key the struct does not have is simply ignored at try_deserialize time.
The order, written down once
Section titled “The order, written down once”- The file is the base: it speaks for every key it contains.
- The environment overrides the file, one key at a time.
- Flags override the environment, via
set_override, which sits above every source. - A key a higher layer does not set falls through to the layer below.
Any other order is a different program. The point of the builder is that the order lives in exactly one place — the sequence of add_source calls — instead of being re-decided at every read site.
This Settings struct is the kind of value the previous article’s LazyLock exists to hold: merge the layers once at startup, put the result behind a static, and every function after that reads a settled answer. The next article turns the process around and writes a config file back out from a struct.