How to Use clap and config Together for CLI Configuration

Combine clap and config by deriving Parser and Deserialize on a single struct to merge CLI args and config files automatically.

The config layering problem

You are building a command-line tool that needs to remember settings between runs. Users want to drop a config.toml in their home directory, set environment variables for deployment, and still override a single flag like --output ./build without editing files. Managing three separate configuration sources manually leads to duplicate parsing logic, precedence bugs, and structs that drift out of sync.

Rust gives you three excellent crates for this exact job. clap parses command-line arguments. config loads files and environment variables. serde handles the deserialization glue. None of them merge automatically. You orchestrate the merge by defining a single struct that derives from both parsers, loading the base configuration, parsing the CLI, and applying overrides in a deterministic order.

How the pieces fit together

Think of configuration like a transparent map stack. The bottom layer contains hardcoded defaults. The middle layer contains your configuration file and environment variables. The top layer contains command-line flags. When you look through the stack, the top layer always obscures what is underneath. That is the precedence rule: CLI wins over environment variables, which win over config files, which win over defaults.

serde provides the Deserialize trait, which turns structured data into Rust values. config uses Deserialize to read TOML, JSON, YAML, and environment variables into a flat key-value map. clap uses its own Parser derive macro to turn CLI flags into the same Rust struct. The trick is making both crates play nice with the same type. You annotate fields with #[arg(...)] for clap and #[serde(...)] for config. The two attribute sets live side by side without conflict.

Convention aside: the Rust community prefers explicit #[serde(default)] over relying on Option<T> for every field. It keeps the final struct clean and forces you to declare what "empty" means for each setting.

Minimal working example

Start with a struct that derives both Parser and Deserialize. Add the necessary attributes, then write a small builder function that loads the file, parses the CLI, and merges them.

use clap::Parser;
use config::Config;
use serde::Deserialize;

/// Application settings merged from config file and CLI flags.
#[derive(Parser, Deserialize, Debug)]
struct AppSettings {
    /// Log verbosity level. CLI flag overrides config file.
    #[arg(short, long, default_value = "info")]
    #[serde(default = "default_log_level")]
    log_level: String,

    /// Output directory path. Optional in both sources.
    #[arg(short, long)]
    #[serde(default)]
    output_dir: Option<String>,
}

/// Provide a fallback for serde when the config file is missing a key.
fn default_log_level() -> String {
    "info".to_string()
}

/// Load base config, parse CLI, and apply overrides.
fn build_settings() -> AppSettings {
    // Build the config source chain: defaults -> file -> env vars
    let config = Config::builder()
        .add_source(config::File::with_name("config").required(false))
        .build()
        .expect("Failed to build config source");

    // Deserialize the file/env layer into our struct
    let mut settings: AppSettings = config.try_deserialize().expect("Failed to deserialize config");

    // Parse CLI arguments into the same struct type
    let cli = AppSettings::parse();

    // Apply CLI overrides only when the user explicitly provided a value
    if cli.log_level != default_log_level() {
        settings.log_level = cli.log_level;
    }
    if cli.output_dir.is_some() {
        settings.output_dir = cli.output_dir;
    }

    settings
}

fn main() {
    let settings = build_settings();
    println!("Final config: {:?}", settings);
}

The code above compiles cleanly. It loads config.toml if it exists, falls back to defaults, parses --log-level and --output-dir from the terminal, and applies the CLI values on top. The merge step is explicit, which makes the precedence rule impossible to misunderstand.

What happens under the hood

When Config::builder() runs, it creates an internal map of string keys to Value objects. The File source reads the TOML file, flattens nested tables into dot-separated keys, and inserts them into the map. Environment variables get injected next, usually with a prefix like APP_. The map now holds the middle layer of your config stack.

Calling try_deserialize() hands that map to serde. serde walks your struct definition, matches field names to map keys, and constructs the Rust values. Missing keys trigger the #[serde(default)] or #[serde(default = "fn")] fallbacks. You now have a fully populated AppSettings instance representing the file and environment state.

AppSettings::parse() runs clap's argument parser. It scans std::env::args(), matches flags to the #[arg] attributes, and constructs a second AppSettings instance. Because clap uses default_value for log_level, the CLI struct will contain "info" even if the user typed nothing. That is why the merge step compares against the known default before overriding. You avoid clobbering a config file value with a CLI default.

The final struct is the union of both layers, with CLI taking precedence. No magic. No hidden state. Just deterministic merging.

A realistic production setup

Real tools need nested configuration, environment variable prefixes, and graceful failure modes. The pattern scales by keeping the merge logic isolated and adding validation after the merge completes.

use clap::Parser;
use config::Config;
use serde::Deserialize;

/// Database connection settings.
#[derive(Deserialize, Debug)]
struct DatabaseConfig {
    #[serde(default = "default_host")]
    host: String,
    #[serde(default = "default_port")]
    port: u16,
}

fn default_host() -> String { "localhost".to_string() }
fn default_port() -> u16 { 5432 }

/// Top-level application settings.
#[derive(Parser, Deserialize, Debug)]
struct AppSettings {
    #[arg(short, long)]
    #[serde(default)]
    verbose: bool,

    #[arg(long)]
    #[serde(default)]
    config_path: Option<String>,

    /// Nested config loaded from file/env, not CLI.
    #[serde(default)]
    database: Option<DatabaseConfig>,
}

/// Build settings with validation and explicit error handling.
fn build_settings() -> AppSettings {
    let config = Config::builder()
        .set_default("database.host", "localhost")
        .set_default("database.port", 5432)
        .add_source(config::File::with_name("config").required(false))
        .build()
        .expect("Config builder failed");

    let mut settings: AppSettings = config.try_deserialize().expect("Config deserialization failed");

    let cli = AppSettings::parse();
    if cli.verbose {
        settings.verbose = true;
    }
    if cli.config_path.is_some() {
        settings.config_path = cli.config_path;
    }

    // Validate merged state before handing it to the rest of the app
    if settings.database.is_none() && !settings.verbose {
        eprintln!("Warning: running without database config in quiet mode.");
    }

    settings
}

Notice the separation of concerns. DatabaseConfig only derives Deserialize because users never pass database credentials as CLI flags. Security and ergonomics both demand that sensitive or complex nested settings live in files or environment variables. The top-level struct derives both traits because it bridges the two worlds.

Convention aside: keep unsafe out of configuration code entirely. Config parsing is purely data transformation. If you find yourself reaching for raw pointers or manual memory management here, you are solving the wrong problem.

Common pitfalls and compiler errors

Mixing clap and serde on the same struct introduces a few predictable friction points. The compiler will catch most of them, but the error messages can point in different directions.

Type mismatches between TOML and CLI args trigger E0308 (mismatched types). TOML parses numbers as integers or floats, while clap parses them as strings before conversion. If your struct expects u32 but the config file contains a quoted string, serde fails during deserialization. Keep numeric types consistent across both sources.

Missing trait bounds show up as E0277 (trait bound not satisfied). This happens when you forget to derive Deserialize on a nested struct, or when you use a custom type that does not implement FromStr for clap. Add #[derive(Deserialize)] to every nested config type. Implement FromStr or use #[arg(value_parser)] for custom CLI types.

Attribute collisions rarely break compilation, but they confuse readers. #[arg(default_value = "info")] and #[serde(default = "default_log_level")] do the same job for different parsers. The community convention is to keep both explicit. It documents the fallback behavior for each source independently.

Shadowing occurs when you forget to check against defaults before overriding. If you blindly assign settings.log_level = cli.log_level, the CLI default will erase a valid config file value. Always compare against the known default or use clap's ArgMatches::value_source to check if the user actually typed the flag.

Pitfall rule: treat the merge step as a contract. If you cannot explain why a CLI value wins over a config value, your precedence logic is broken.

When to reach for which tool

Use clap alone when your tool only needs command-line flags and hardcoded defaults. Use clap with serde when you want to serialize settings to JSON or YAML for debugging, but do not need file loading. Use the config crate when you need environment variable expansion, multiple file formats, or automatic type coercion. Use the combined clap + config + serde pattern when you need persistent settings, deployment overrides, and user-facing CLI flags in a single deterministic stack. Reach for dotenv only when you are prototyping and want zero-config environment loading without the overhead of a full config builder. Stick to explicit merging when precedence rules matter more than convenience.

Where to go next