How to use config crate in Rust configuration

The `config` crate provides a unified interface to load settings from multiple sources (files, environment variables, command-line arguments) with automatic type conversion and merging.

Configuration as a stack of layers

You're building a web service. Locally, you want to tweak settings in a file without restarting the build. In production, you need environment variables for secrets and overrides. You also want sensible defaults so the app doesn't crash if a key is missing. Writing parsers for YAML, JSON, and environment variables separately is tedious. Merging them correctly is worse. The config crate solves this by letting you stack sources and merge them automatically.

The crate treats configuration as a stack of layers. You define a base layer of defaults. Then you add a file layer. Then an environment variable layer. Each new layer sits on top of the previous one. When you ask for a value, the crate checks the top layer first. If it finds the key, it returns that value. If not, it digs down to the next layer. This is called precedence. The last source you add wins.

How the builder pattern works

The config crate uses a builder pattern to construct the configuration object. You start with Config::builder(). You chain methods to add sources. Each method returns the builder, allowing you to stack calls. Finally, you call build() to produce a Config object. This object holds the merged data. You then call try_deserialize() to convert the merged data into a typed Rust struct.

The builder pattern makes the order of sources explicit. You can see exactly which layers exist and in what order. This prevents accidental overrides. It also separates the setup phase from the usage phase. You build the config once at startup and pass the typed struct around your application.

use config::{Config, ConfigError, File, Environment};
use serde::Deserialize;

/// Application settings loaded from multiple sources.
#[derive(Debug, Deserialize)]
struct Settings {
    /// Name of the service.
    app_name: String,
    /// Port to listen on.
    port: u16,
    /// Enable debug logging.
    debug: bool,
}

fn load_config() -> Result<Settings, ConfigError> {
    // Start with a builder to stack configuration sources.
    let builder = Config::builder()
        // Set a default port so the app runs without a file.
        .set_default("port", 8080)?
        // Add a YAML file. If missing, this is ignored because required is false.
        .add_source(File::with_name("config").required(false))
        // Add environment variables. Prefix "MY_APP_" maps to fields.
        // Double underscore separates nested keys, though we have none here.
        .add_source(Environment::with_prefix("MY_APP").separator("__"));

    // Build the merged configuration object.
    // This triggers reading files and scanning environment variables.
    let config = builder.build()?;

    // Convert the merged config into our typed struct.
    // Returns an error if any field is missing or has the wrong type.
    config.try_deserialize()
}

Run this with MY_APP_PORT=9000 and watch the environment variable override the file. The merge happens silently. Order matters.

Nested structures and deep merging

Real applications often have nested configuration. The config crate handles this via dot notation in keys. You can define nested structs in Rust. The crate maps keys like database.host to the host field inside the database struct.

Environment variables map to nested keys using the separator. If you set the separator to __, the variable MY_APP__DATABASE__HOST maps to database.host. This allows you to override specific fields in a nested object without replacing the whole object.

#[derive(Debug, Deserialize)]
struct DatabaseConfig {
    host: String,
    port: u16,
}

#[derive(Debug, Deserialize)]
struct AppSettings {
    // Nested struct maps to "database" key in config sources.
    database: DatabaseConfig,
    server_port: u16,
}

fn load_nested_config() -> Result<AppSettings, ConfigError> {
    let config = Config::builder()
        // Defaults for the nested database section.
        .set_default("database.host", "localhost")?
        .set_default("database.port", 5432)?
        // File source for complex configuration blocks.
        .add_source(File::with_name("config").required(false))
        // Environment variables with double underscore separator.
        .add_source(Environment::with_prefix("MY_APP").separator("__"))
        .build()?;

    config.try_deserialize()
}

Deep merge saves you from copy-pasting entire objects just to change one field. If your YAML defines a database block with host, port, and credentials, and you override only the host via environment variable, the port and credentials remain intact. The crate merges at the key level, not the object level.

Pitfalls and error handling

The config crate relies on serde for deserialization. If you forget to add serde to Cargo.toml, the compiler rejects the code with E0277 (the trait bound Settings: serde::Deserialize<'_> is not satisfied). The error message points to the derive macro. Add serde with the derive feature to fix this.

[dependencies]
config = "0.13"
serde = { version = "1.0", features = ["derive"] }

Another common issue is the Default trait. The config crate does not use Rust's Default trait to fill missing fields. If a field is missing from all sources, try_deserialize returns an error. You must explicitly set defaults via set_default or mark fields as Option<T>. This forces you to think about what happens when configuration is absent. It prevents silent fallbacks that hide bugs.

Environment variable naming can also cause confusion. The crate converts struct field names to uppercase for matching. A field named app_name matches the key APP_NAME. If you use a prefix, the variable becomes MY_APP_APP_NAME. The separator helps with nested keys. Using a single underscore as a separator is risky because underscores often appear inside variable names. The community convention leans toward __ for flat structures or explicit separators for nested ones to avoid collisions.

If deserialization fails, try_deserialize returns a ConfigError. The error message usually points to the missing key or type mismatch. Don't ignore this error. Wrap it in a Result and propagate it. If you unwrap blindly, your app crashes at startup with a panic instead of a clean error message. Treat missing configuration as a fatal error. If the app can't run without a value, fail fast.

Convention asides

The community convention is to mark files as required(false) during development. This lets you run the binary without creating a config file immediately. Production code often flips this to required(true) to fail fast if the config file is missing.

Another convention is to keep the Config builder in a dedicated module. This separates configuration logic from business logic. It also makes it easier to test different configuration setups. You can write tests that inject mock sources without touching the file system.

Decision matrix

Use the config crate when you need to merge multiple sources like files, environment variables, and command-line arguments into a single typed struct. Use the toml crate directly when your configuration lives in a single TOML file and you don't need environment variable overrides or complex merging logic. Use manual parsing with std::env when your application has only a few flags and adding a dependency feels like overkill. Use dotenv when you just need to load a .env file into environment variables before your app starts, without any structured merging or type conversion.

Don't over-engineer config. If you have one file, a parser is enough. If you have layers, reach for config.

Where to go next