Skip to content

Config Layering

You have multiple environments, shared defaults, per-project overrides, and optional local tweaks. Here's how to layer them.

The mental model

Think of config layering as a stack of transparencies. Each layer adds or overrides values. Later layers win. You start with a base, stack environment-specific overrides on top, then let the user's CLI flags override everything.

base.yaml              (lowest priority)
env/prod.yaml          (overrides base)
~/.myapp/config.yaml   (auto-discovered, user defaults)
+extra.yaml            (CLI file arg)
--flag value           (highest priority)

Multi-file loading

From Python

Pass a list of paths to dracon.load(). They merge left to right:

import dracon

config = dracon.load(['base.yaml', 'prod.yaml'])

prod.yaml overrides base.yaml wherever they overlap.

From the CLI

Use the +file syntax. Each +file is a layer:

myapp +base.yaml +prod.yaml --check-interval 10

Same idea: prod.yaml overrides base.yaml, and --check-interval overrides both.

Selectors with @

You can extract a subtree from a file using @:

myapp +full-config.yaml@database

This loads full-config.yaml, pulls out just the database key, and uses that as the config. Works in Python too:

config = dracon.load(['full-config.yaml@database'])

Selectors support nested paths: +file.yaml@services.api extracts services then api.

Include schemes

Inside YAML, !include pulls in content from various sources. The part before the colon is the scheme:

Scheme What it does Example
file: Local filesystem, $DIR for relative paths !include file:$DIR/db.yaml
pkg: Python package resources !include pkg:mylib/defaults.yaml
env: Environment variable value !include env:MY_CONFIG_VAR
var: In-memory context variable !include var:injected_config
cascade: Walk up directories, merge all matches !include cascade:.myapp.yaml

$DIR always resolves to the directory of the file containing the !include. This means relative paths work regardless of where you run from.

For full details on each scheme, see the reference.

Cascade includes

The cascade: scheme walks up from the current working directory toward the filesystem root, collecting every file that matches the given relative path. It merges them root-first, so the closest file (nearest to CWD) has the highest priority.

# loads .myapp.yaml from every parent directory, merges them
<<{>+}: !include cascade:.myapp.yaml

This is good for monorepos where each subdirectory can have its own .myapp.yaml that inherits from a repo-wide one.

ConfigFile and auto-discovery

When using @dracon_program, you can declare config files that get auto-discovered before any CLI args are processed:

from dracon import dracon_program, ConfigFile

@dracon_program(
    name="myapp",
    config_files=[
        ConfigFile("~/.myapp/config.yaml"),
        ConfigFile(".myapp.yaml", search_parents=True),
    ],
)
class MyConfig(BaseModel):
    db_host: str = "localhost"
    port: int = 5432
  • ConfigFile("~/.myapp/config.yaml") loads from the user's home directory if the file exists. Silently skipped if missing.
  • ConfigFile(".myapp.yaml", search_parents=True) uses the cascade loader, walking up from CWD and merging all matches.
  • ConfigFile("required.yaml", required=True) raises an error if the file is not found.
  • ConfigFile("full.yaml", selector="database") extracts the database subtree.

Auto-discovered configs are prepended as +file before the user's CLI args. So the precedence is:

model defaults  <  auto-discovered  <  +file args  <  --flags

Real-world pattern

A CLI tool with home-dir defaults and project-local overrides:

@dracon_program(
    name="deploy",
    config_files=[
        ConfigFile("~/.deploy/config.yaml"),           # user-wide defaults
        ConfigFile(".deploy.yaml", search_parents=True), # project cascade
    ],
)
class DeployConfig(BaseModel):
    target: str = "staging"
    replicas: int = 1

A developer runs deploy from /repo/services/api/. The cascade finds .deploy.yaml in /repo/ and /repo/services/api/, merges them (repo-wide first, project-local wins), then layers the home-dir config underneath. CLI flags override everything.

Merge strategies

Merge keys control how two mappings or lists combine. The syntax is <<{dict_opts}[list_opts]:.

Quick reference

Key Dict behavior List behavior Use case
<<: Append new keys, existing wins, deep merge Existing wins, replace Standard YAML-like merge
<<{<+}: New wins, deep merge (default list) Included content overrides me
<<{>+}: Existing wins, deep merge (default list) I override the included content
<<{<~}: New wins, shallow replace (default list) Full key replacement
<<[+]: (default dict) Append lists Combine lists
<<[<+]: (default dict) New wins, append Override + combine lists
<<@path: Merge into subtree at path - Target a nested key

Symbols: < = new wins, > = existing wins, + = deep merge / append, ~ = replace.

Example: override with deep merge

The most common pattern. Your environment file overrides the base, but nested dicts merge field by field:

# env/prod.yaml
check_interval: 15
database:
  host: db.prod.internal

<<{>+}: !include file:$DIR/../base.yaml

{>+} means "I (prod.yaml) win conflicts, merge dicts recursively." So database.host comes from prod, but database.port and database.name are kept from base.

Example: append to a list

# extra-sites.yaml
sites:
  - https://new-site.com

<<[+]: !include file:$DIR/base.yaml

[+] appends the sites list from base into the current one, instead of replacing it.

Example: merge into a subtree

# Apply overrides specifically to the database subtree
<<@database: !include file:$DIR/db-overrides.yaml

The contents of db-overrides.yaml get merged into the database key of the current mapping.

Context propagation with (<)

By default, variables defined in a merged-in file don't leak into the parent. If you need them to, use (<):

# settings.yaml
!define version: "2.0"
api_url: "https://api.example.com/v${version}"
# main.yaml
<<{>+}(<): !include file:$DIR/settings.yaml

# version is now available here because of (<)
banner: "Running version ${version}"

Without (<), the ${version} in banner would fail because version is scoped to settings.yaml.

Another common use: sharing defines across multiple includes.

<<(<): !include file:$DIR/constants.yaml

# all !define variables from constants.yaml are now in scope
output: "${project_name}/results"

Optional includes with !include?

!include? (with the question mark) silently returns nothing if the file doesn't exist. No error, no warning.

database:
  host: localhost
  port: 5432

# merge in local overrides if they exist
<<{<+}: !include? file:$DIR/local-overrides.yaml

Good for .gitignored developer-specific tweaks, machine-specific paths, or optional feature configs.

Complete pattern

Here's the full layering pattern for a multi-environment project:

config/
  base.yaml                 # shared defaults
  env/
    dev.yaml                # dev overrides
    prod.yaml               # prod overrides
    staging.yaml            # staging overrides
  local-overrides.yaml      # gitignored, per-developer tweaks
# config/env/prod.yaml
!define environment: prod

check_interval: 15
log_level: WARN

database:
  host: db.prod.internal
  password: ${getenv('DB_PASSWORD')}

<<{>+}: !include file:$DIR/../base.yaml
<<{<+}: !include? file:$DIR/../local-overrides.yaml
# config/base.yaml
!set_default environment: dev

sites:
  - https://example.com
  - https://status.example.com

check_interval: 60
log_level: INFO

database:
  host: localhost
  port: 5432
  name: myapp
  password: dev-pass

Loading +config/env/prod.yaml gives you: prod's overrides on top of base defaults, with optional local tweaks, and the database password pulled from the environment.

Check the result with:

dracon show config/env/prod.yaml -r