Hardcoding configuration breaks deployment
You built a web server. It works on your machine. You try to deploy it, and suddenly the port is wrong. Or the database URL points to localhost instead of the production cluster. Hardcoding configuration values turns every deployment into a search-and-replace game. You need a way to load settings from files, environment variables, and command-line flags, all in one place, without writing a parser for every format.
The config crate solves this by merging multiple sources into a single configuration object. You define defaults, load a file, apply environment variable overrides, and get a typed result. The crate handles the merging logic, type conversion, and error reporting.
The configuration funnel
Think of the config crate as a funnel with layers. You pour in sources in a specific order. The first source provides base values. The second source overrides matching keys. The third source overrides both. The result is a unified map where every key has exactly one value, determined by the last source that provided it.
This layering pattern matches how real applications work. You start with sensible defaults baked into the code. You load a configuration file for the specific environment. You apply environment variables to override sensitive values or cluster-specific settings without touching files. The crate manages this priority chain automatically.
Minimal example
Start with a simple TOML file and a builder chain. The crate detects the file format from the extension, so you don't need to specify it manually.
use config::{Config, File, FileFormat};
/// Load configuration from a TOML file.
fn main() -> Result<(), config::ConfigError> {
// Start the builder. This creates an empty configuration state.
let settings = Config::builder()
// Add a file source. The crate infers the format from the extension.
.add_source(File::with_name("config.toml"))
// Build the final configuration. This merges all sources.
.build()?;
// Extract a value. The crate handles parsing and type conversion.
let port: u16 = settings.get("server.port")?;
println!("Port: {}", port);
Ok(())
}
Convention aside: prefer File::with_name over explicit format constructors. The crate supports TOML, JSON, YAML, and RON out of the box. Using with_name keeps the code flexible if you switch formats later. The extension drives the parser, not the Rust code.
Keep the builder chain readable. Add sources in order of priority.
How merging works
Config::builder() returns a builder object. You chain add_source calls to register providers. Each provider can be a file, an environment variable set, a command-line parser, or a custom source. When you call build(), the crate iterates through the providers in the order you added them.
For each provider, the crate parses the data and extracts key-value pairs. It merges these pairs into an internal map. If a key already exists, the new value overwrites the old one. This means the last source wins. If config.toml sets port = 8080 and an environment variable sets APP_PORT = 9090, the final value is 9090.
The build method returns a Config object. You query it with get, passing a key path like "server.port". The get method takes a type parameter. The crate attempts to convert the stored value to that type. If the key is missing, get returns a ConfigError::NotFound. If the value exists but isn't the right type, get returns a ConfigError::Type. For example, if the file has port = "abc" and you ask for u16, the conversion fails.
You can also set defaults directly in the builder using set_default. This is useful for values that rarely change but need a fallback.
let settings = Config::builder()
// Set a default value in code.
.set_default("server.port", 8080)?
// File values override the default.
.add_source(File::with_name("config.toml"))
.build()?;
Defaults live in the code. Files override defaults. Environment variables override files. This hierarchy keeps your application robust. If a file is missing a key, the default saves you. If a deployment needs a specific value, the environment variable takes precedence.
Typed configuration with structs
Calling get for every field gets tedious. Real applications deserialize the entire configuration into a typed struct. This gives you compile-time checks for missing fields and correct types. You query the struct, not the config object.
use config::{Config, ConfigError, File};
use serde::Deserialize;
/// Application configuration structure.
#[derive(Debug, Deserialize)]
struct Settings {
/// Server configuration.
pub server: ServerConfig,
/// Database configuration.
pub database: DatabaseConfig,
}
#[derive(Debug, Deserialize)]
struct ServerConfig {
/// Port to listen on.
pub port: u16,
/// Host address.
pub host: String,
}
#[derive(Debug, Deserialize)]
struct DatabaseConfig {
/// Connection string.
pub url: String,
/// Maximum pool size.
pub max_connections: u32,
}
/// Load and deserialize configuration into a typed struct.
fn load_config() -> Result<Settings, ConfigError> {
let settings = Config::builder()
// Base configuration file.
.add_source(File::with_name("config.toml"))
// Environment variables override file values.
// Prefix prevents collisions with other env vars.
.add_source(config::Environment::with_prefix("APP"))
.build()?;
// Deserialize the entire config into the Settings struct.
settings.try_deserialize()
}
fn main() -> Result<(), ConfigError> {
let settings = load_config()?;
println!("Server: {}:{}", settings.server.host, settings.server.port);
println!("DB Max Connections: {}", settings.database.max_connections);
Ok(())
}
Convention aside: always use a prefix for environment variables. Environment::with_prefix("APP") maps APP_SERVER_PORT to server.port. Without a prefix, you risk clashing with system variables or other tools. The Rust community treats unprefixed config env vars as a bug waiting to happen.
Convention aside: call try_deserialize once at startup. This method consumes the Config object. You cannot call get after deserialization. Deserialize early, pass the typed struct through your application, and avoid querying configuration at runtime. Configuration should be static after initialization.
If you forget #[derive(Deserialize)] on your struct, the compiler rejects try_deserialize with E0277 (trait bound not satisfied). The error points to the struct and suggests adding the derive macro. Fix the derive, and the deserialization works.
Environment variable mapping
The config crate maps environment variables to nested keys using separators. By default, it uses underscores. APP_SERVER_PORT maps to server.port. APP_DATABASE_URL maps to database.url. The crate strips the prefix and converts underscores to dots.
You can change the separator if your environment uses a different convention. Some systems use double underscores or hyphens.
config::Environment::with_prefix("APP")
// Use double underscores as the separator.
.separator("__")
With this setting, APP_SERVER__PORT maps to server.port. This is useful when your deployment platform uses a specific naming scheme.
Environment variables are always strings. The crate converts them to the target type during deserialization. APP_PORT=8080 converts to u16. APP_DEBUG=true converts to bool. If the conversion fails, you get a type error. Test your environment variables with invalid values to ensure your error handling catches them.
Pitfalls and errors
Configuration loading is a common source of startup crashes. Handle errors explicitly. Don't unwrap configuration results in production code.
File::with_name searches relative to the current working directory, not the binary location. This breaks when running via cargo run versus running the binary directly. cargo run sets the working directory to the project root. A deployed binary might run from a different directory. Use File::from_str with an absolute path, or resolve the path relative to the executable using std::env::current_exe.
use std::path::PathBuf;
fn get_config_path() -> PathBuf {
// Resolve config relative to the binary location.
let exe = std::env::current_exe().expect("Failed to get executable path");
let dir = exe.parent().expect("Executable has no parent directory");
dir.join("config.toml")
}
This approach ensures your binary finds its configuration file regardless of where it's invoked from.
Another pitfall is shadowing keys. If you have server.port in the file and APP_PORT in the environment, the environment variable wins. This is usually desired, but it can hide bugs if you expect the file value to stick. Log your configuration at startup to verify the final values.
If you request a key that doesn't exist, get returns ConfigError::NotFound. The error message includes the key path. Use this to debug missing values. If you use try_deserialize, missing fields cause a deserialization error. The error message lists the missing fields.
Test your configuration loading with missing files and bad types. A crash at startup is better than a silent failure later. Fail fast.
Deserialize once at startup. Pass the typed struct through your application. Don't query the config object repeatedly at runtime.
Decision matrix
Use config crate when you need to merge multiple sources like files, environment variables, and command-line flags into a single configuration object.
Use serde with a raw file parser like toml or serde_json when you only have one source and don't need merging or environment variable overrides.
Use std::env::var for simple scripts where configuration is just a few environment variables and you want zero dependencies.
Use a dedicated CLI parser like clap when your configuration comes primarily from command-line arguments with complex flag handling.
Pick the tool that matches your complexity. Don't bring in a merger if you only have one source.