What is Hydra?
Hydra is an open-source Python framework developed by Meta (formerly Facebook AI Research) that elegantly configures complex applications. At its core, Hydra provides a way to compose hierarchical configurations dynamically, enabling developers to manage application parameters across multiple files, environments, and experiments without hardcoding values or resorting to brittle argument parsers.
Unlike traditional configuration approaches—where you might use a single config.yaml file, environment variables, or command-line arguments in isolation—Hydra unifies all of these into a single, composable system. It automatically generates command-line interfaces from your configuration schema, supports configuration inheritance and overriding, and can launch multiple runs with different parameter combinations (sweeps). Hydra is particularly popular in machine learning workflows, but its utility extends to any Python application that benefits from structured, reproducible configuration.
Why Hydra Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →In modern software development, configuration complexity grows exponentially with the number of features, environments, and experiments. Consider a typical ML training script: you need to manage dataset paths, model hyperparameters, optimizer settings, logging destinations, hardware allocation, and perhaps experiment-specific tweaks. Without a robust configuration framework, developers often fall into one of several traps:
- Hardcoded defaults scattered across source files, making experiments difficult to reproduce
- Brittle argument parsers that grow unwieldy as parameters multiply
- Monolithic YAML files that become impossible to navigate and prone to accidental changes
- Poor experiment tracking because the exact configuration used for a given run is lost or ambiguous
Hydra addresses all of these pain points by introducing a disciplined, composable configuration paradigm. Key benefits include:
- Reproducibility: Every run logs its exact configuration, so you can always reconstruct what happened
- Composability: Configurations are split into logical groups that can be mixed and matched
- Override power: Any configuration value can be changed from the command line without modifying files
- Experiment scaling: Built-in support for sweeps (grid search, Bayesian optimization, etc.) via plugins
- Type safety: Structured configs using Python dataclasses enable static analysis and IDE support
Installation and Setup
Hydra can be installed via pip. The core package includes everything needed for basic usage, while additional plugins provide extended functionality like launching jobs on SLURM clusters, integrating with cloud storage, or performing hyperparameter optimization.
# Install Hydra core
pip install hydra-core
# Install with common plugins (optional but recommended)
pip install hydra-core hydra-joblib-launcher hydra-optuna-sweeper
# For a full ML workflow, consider:
pip install hydra-core omegaconf rich
The core dependency that Hydra relies on for configuration parsing is OmegaConf, which provides a hierarchical configuration system with merge semantics. OmegaConf is installed automatically with Hydra, but understanding its role helps when debugging configuration resolution issues.
To verify your installation and check the version, run:
python -c "import hydra; print(hydra.__version__)"
Core Configuration Concepts
Config Files: YAML with Superpowers
Hydra's primary configuration format is YAML, but with several enhancements provided by OmegaConf. You can use variable interpolation, relative references, and even custom resolvers. Here's a simple example configuration file config.yaml:
# config.yaml
db:
driver: postgresql
host: localhost
port: 5432
username: ${oc.env:DB_USER,default_user}
password: ${oc.env:DB_PASSWORD}
app:
name: my_application
log_level: INFO
retry_count: 3
training:
learning_rate: 0.001
batch_size: 32
epochs: 100
optimizer: adam
Notice the ${oc.env:DB_USER,default_user} syntax—this is OmegaConf's environment variable resolver. It reads the DB_USER environment variable and falls back to default_user if the variable is not set. This pattern keeps secrets out of configuration files while maintaining sensible defaults for development.
Command-Line Overrides
One of Hydra's most powerful features is the ability to override any configuration value directly from the command line. This eliminates the need to edit files for quick experiments. The syntax uses the same dot-separated path notation as the config hierarchy:
# Override a single value
python my_app.py training.learning_rate=0.01
# Override multiple values
python my_app.py training.learning_rate=0.01 training.batch_size=64
# Override structured elements
python my_app.py db.host=production.example.com db.port=5433
# Append to a list (using + prefix)
python my_app.py +training.new_param=42
# Force-add even if the key doesn't exist in schema
python my_app.py ++experiment.tag=baseline_v2
Hydra's override syntax is intuitive and expressive. The + prefix appends a new key to the configuration, while ++ forces the addition bypassing strict schema checks. This flexibility is invaluable during rapid experimentation.
Configuration Groups
As applications grow, a single configuration file becomes unwieldy. Hydra introduces config groups—collections of YAML files organized in directories that can be selected at runtime. This is the cornerstone of Hydra's composability.
Consider a machine learning project with multiple datasets and models. Instead of one monolithic config, you structure your conf/ directory like this:
conf/
├── config.yaml # Top-level config with defaults
├── dataset/
│ ├── cifar10.yaml
│ ├── imagenet.yaml
│ └── mnist.yaml
├── model/
│ ├── resnet18.yaml
│ ├── resnet50.yaml
│ └── vit_base.yaml
└── optimizer/
├── adam.yaml
├── sgd.yaml
└── adamw.yaml
Each group file contains only the parameters relevant to that component. For example, dataset/cifar10.yaml might look like:
# conf/dataset/cifar10.yaml
dataset:
name: cifar10
path: /data/cifar10
num_classes: 10
img_size: 32
augment: true
normalize_mean: [0.4914, 0.4822, 0.4465]
normalize_std: [0.2023, 0.1994, 0.2010]
And model/resnet50.yaml:
# conf/model/resnet50.yaml
model:
name: resnet50
pretrained: true
num_layers: 50
dropout: 0.1
Your top-level config.yaml specifies which defaults to use:
# conf/config.yaml
defaults:
- dataset: cifar10
- model: resnet18
- optimizer: adam
- _self_
hydra:
output_subdir: null
run:
dir: ./outputs/${now:%Y-%m-%d}/${now:%H-%M-%S}
The defaults list is the heart of Hydra's composition system. The _self_ directive controls merge order—it determines whether the top-level config's own values take precedence over defaults. Placing _self_ last means the top-level config can override values from the defaults.
At runtime, you can switch entire groups with a single command-line argument:
# Run with ResNet50 and SGD optimizer
python train.py dataset=cifar10 model=resnet50 optimizer=sgd
# Run with ViT and AdamW on ImageNet
python train.py dataset=imagenet model=vit_base optimizer=adamw
Multi-Run and Sweeps
Hydra supports launching multiple runs with different configurations—a feature called multi-run. Combined with a launcher plugin (like JobLib for local parallelism or Submitit for cluster submission), you can perform grid searches, random searches, or Bayesian optimization.
For basic grid search without additional plugins, you can use the --multirun flag with comma-separated override values:
# Grid search over learning rates and batch sizes
python train.py --multirun training.learning_rate=0.001,0.01,0.1 training.batch_size=32,64,128
# This launches 9 runs (3x3 combinations)
For more sophisticated sweeps, install the Optuna sweeper plugin and configure it in your config:
# conf/config.yaml
defaults:
- override hydra/sweeper: optuna
hydra:
sweeper:
sampler:
_target_: optuna.samplers.TPESampler
n_startup_trials: 10
direction: minimize
study_name: my_study
storage: sqlite:///optuna.db
n_jobs: 4
Then define search spaces in your config using special interval syntax or by referencing Optuna suggest functions. Hydra handles the orchestration, parallelization, and result logging automatically.
How to Use Hydra in Practice
Basic Integration with @hydra.main
The simplest way to integrate Hydra into a Python application is through the @hydra.main decorator. This decorator transforms your main function to receive the composed configuration as its first argument:
import hydra
from omegaconf import DictConfig, OmegaConf
@hydra.main(version_base=None, config_path="conf", config_name="config")
def main(cfg: DictConfig) -> None:
# cfg is the fully resolved configuration object
print(f"Database host: {cfg.db.host}")
print(f"Learning rate: {cfg.training.learning_rate}")
# Print the entire configuration (useful for logging)
print(OmegaConf.to_yaml(cfg))
# Your application logic here
# ...
if __name__ == "__main__":
main()
The decorator parameters are important:
version_base: UseNonefor Hydra 1.3+ to avoid deprecation warnings. For Hydra 1.0/1.1 compatibility, specify"1.1"or"1.2"config_path: Relative path to the directory containing your configuration files (or an absolute path)config_name: The name of the main config file (without.yamlextension)
Structured Configs with Dataclasses
For production-grade applications, Hydra supports structured configs—using Python dataclasses to define configuration schemas with type safety and validation. This approach enables IDE autocompletion, static type checking, and runtime validation:
from dataclasses import dataclass, field
from typing import List, Optional
from hydra.core.config_store import ConfigStore
import hydra
@dataclass
class DatabaseConfig:
driver: str = "postgresql"
host: str = "localhost"
port: int = 5432
username: str = "default_user"
password: Optional[str] = None
@dataclass
class TrainingConfig:
learning_rate: float = 0.001
batch_size: int = 32
epochs: int = 100
optimizer: str = "adam"
@dataclass
class AppConfig:
name: str = "my_application"
log_level: str = "INFO"
retry_count: int = 3
@dataclass
class MyFullConfig:
db: DatabaseConfig = field(default_factory=DatabaseConfig)
training: TrainingConfig = field(default_factory=TrainingConfig)
app: AppConfig = field(default_factory=AppConfig)
# Register the structured config
cs = ConfigStore.instance()
cs.store(name="base_config", node=MyFullConfig)
@hydra.main(version_base=None, config_path=None, config_name="base_config")
def main(cfg: MyFullConfig) -> None:
# cfg is now typed as MyFullConfig
print(f"Database: {cfg.db.driver}://{cfg.db.username}@{cfg.db.host}:{cfg.db.port}")
print(f"Training for {cfg.training.epochs} epochs with {cfg.training.optimizer}")
# IDE autocompletion works on cfg.db, cfg.training, etc.
if __name__ == "__main__":
main()
When using structured configs, you set config_path=None because the configuration is defined in code rather than in YAML files. However, you can combine both approaches—use YAML files for most configuration and dataclasses for validation, or register multiple config nodes in the ConfigStore.
Working with Configuration Resolution
Understanding how Hydra resolves configurations is crucial for debugging. The resolution follows this precedence (from lowest to highest priority):
- Config group defaults specified in the
defaultslist - Values from included config files
- Values in the top-level config file (if
_self_is in the defaults list) - Command-line overrides
You can inspect the resolved configuration at any point using OmegaConf utilities:
from omegaconf import OmegaConf
# Pretty-print the full config
print(OmegaConf.to_yaml(cfg))
# Access as a plain dictionary
config_dict = OmegaConf.to_container(cfg, resolve=True)
# Check if a key exists
if OmegaConf.is_missing(cfg, "training.learning_rate"):
print("Learning rate not specified, using defaults")
Output Directory Management
By default, Hydra creates timestamped output directories for each run, storing the resolved configuration, any file outputs your code generates, and run metadata. This is essential for experiment tracking. You can customize the output directory structure:
# In config.yaml
hydra:
run:
dir: ./outputs/${now:%Y-%m-%d}/${now:%H-%M-%S}
sweep:
dir: ./multirun/${now:%Y-%m-%d}/${now:%H-%M-%S}
subdir: ${hydra.job.num}
Inside your application, you can access the run directory path via hydra.runtime.cwd or through the HydraConfig singleton:
from hydra.core.hydra_config import HydraConfig
@hydra.main(version_base=None, config_path="conf", config_name="config")
def main(cfg: DictConfig) -> None:
hydra_cfg = HydraConfig.get()
run_dir = hydra_cfg.run.dir
print(f"Saving outputs to: {run_dir}")
# Save artifacts to the run directory
with open(f"{run_dir}/metrics.json", "w") as f:
json.dump({"accuracy": 0.95}, f)
Variable Interpolation and Custom Resolvers
OmegaConf supports powerful interpolation syntax that lets you reference other configuration values dynamically. You can also register custom resolvers for advanced use cases:
from omegaconf import OmegaConf
import hydra
import random
# Register a custom resolver
OmegaConf.register_new_resolver(
"random_seed",
lambda: random.randint(0, 2**32 - 1),
use_cache=True # Cache the result so it's consistent within a run
)
OmegaConf.register_new_resolver(
"multiply",
lambda x, y: x * y,
use_cache=False
)
# Now you can use these in your YAML configs:
# seed: ${random_seed:}
# scaled_lr: ${multiply:${training.learning_rate},10}
Custom resolvers are registered globally and available in all configuration files. The use_cache parameter is important—for random values you typically want True so the value is generated once and reused, preventing inconsistencies.
Best Practices
1. Keep Configuration Files Focused and Modular
Resist the temptation to put everything into a single YAML file. Instead, decompose your configuration into logical groups that mirror your application's components. A well-structured config directory might look like:
conf/
├── config.yaml
├── data/
│ ├── default.yaml
│ ├── production.yaml
│ └── synthetic.yaml
├── model/
│ ├── baseline.yaml
│ ├── experimental.yaml
│ └── ensemble.yaml
├── training/
│ ├── default.yaml
│ ├── fast.yaml # Fewer epochs, higher LR
│ └── thorough.yaml # More epochs, early stopping
├── logging/
│ ├── wandb.yaml
│ ├── tensorboard.yaml
│ └── csv.yaml
└── environment/
├── local.yaml
├── cluster.yaml
└── cloud.yaml
Each group should represent a coherent, independently switchable aspect of your system. This maximizes reusability and makes it trivial to experiment with different combinations.
2. Use Structured Configs for Validation
Even if you primarily use YAML files, add structured configs (dataclasses) to validate critical parameters at startup. This catches type errors, missing required fields, and invalid values before your application runs for hours:
from dataclasses import dataclass
from typing import List
from enum import Enum
class OptimizerChoice(Enum):
ADAM = "adam"
SGD = "sgd"
ADAMW = "adamw"
@dataclass
class ValidatedTrainingConfig:
learning_rate: float = 0.001
batch_size: int = 32
epochs: int = 100
optimizer: OptimizerChoice = OptimizerChoice.ADAM
def __post_init__(self):
if self.learning_rate <= 0:
raise ValueError("learning_rate must be positive")
if self.batch_size <= 0:
raise ValueError("batch_size must be positive")
Use __post_init__ for cross-field validation and complex business logic that can't be expressed through type annotations alone.
3. Log the Resolved Configuration
Always log the complete resolved configuration at the start of every run. Hydra does this automatically in the output directory, but also consider logging it to your experiment tracker (Weights & Biases, MLflow, etc.) or application logs:
import logging
from omegaconf import OmegaConf
logger = logging.getLogger(__name__)
@hydra.main(version_base=None, config_path="conf", config_name="config")
def main(cfg: DictConfig) -> None:
# Log full config as YAML for readability
logger.info("Resolved configuration:\n%s", OmegaConf.to_yaml(cfg))
# Also log as structured metadata for searchability
config_flat = OmegaConf.to_container(cfg, resolve=True)
logger.info("Configuration (flat): %s", config_flat)
This practice is invaluable for post-hoc analysis—you can always determine exactly which parameters produced a given result.
4. Separate Secrets from Configuration
Never store passwords, API keys, or other secrets in your configuration files, even if they're in a private repository. Use environment variable interpolation:
# config.yaml
db:
password: ${oc.env:DB_PASSWORD} # Required - fails if not set
api_key: ${oc.env:API_KEY,default_key} # Optional with fallback
For production deployments, inject secrets through your orchestration system (Kubernetes secrets, HashiCorp Vault, AWS Secrets Manager) and expose them as environment variables that Hydra picks up.
5. Design for Override-Friendly Workflows
Structure your configuration so that common experimental changes require only command-line overrides, not file edits. This means:
- Provide sensible defaults for every parameter
- Use config groups for the dimensions most frequently varied (model architecture, dataset, optimizer)
- Keep group files small and focused—ideally one concern per file
- Document the override syntax in your project README or a Makefile for common workflows
# Example Makefile entries for common Hydra overrides
.PHONY: train-baseline train-fast train-sweep
train-baseline:
python train.py dataset=cifar10 model=resnet18 optimizer=adam
train-fast:
python train.py dataset=cifar10 model=resnet18 optimizer=adam training.epochs=10 training.batch_size=256
train-sweep:
python train.py --multirun dataset=cifar10 model=resnet18,resnet50 optimizer=adam,sgd training.learning_rate=0.001,0.01,0.1
6. Use the ConfigStore for Programmatic Composition
When you need to compose configurations programmatically—for example, dynamically generating configs based on available hardware or user permissions—use the ConfigStore API:
from hydra.core.config_store import ConfigStore
from dataclasses import dataclass
@dataclass
class DynamicConfig:
gpu_count: int
batch_size_per_gpu: int
total_batch_size: int
cs = ConfigStore.instance()
# Register different configs for different hardware profiles
cs.store(
group="hardware",
name="single_gpu",
node=DynamicConfig(gpu_count=1, batch_size_per_gpu=32, total_batch_size=32)
)
cs.store(
group="hardware",
name="four_gpu",
node=DynamicConfig(gpu_count=4, batch_size_per_gpu=32, total_batch_size=128)
)
cs.store(
group="hardware",
name="eight_gpu",
node=DynamicConfig(gpu_count=8, batch_size_per_gpu=32, total_batch_size=256)
)
# Users select with: python train.py hardware=four_gpu
7. Handle Missing and Optional Configs Gracefully
Not all configuration values are always needed. Use OmegaConf's missing value support and optional fields to handle these cases:
from dataclasses import dataclass
from typing import Optional
from omegaconf import MISSING
@dataclass
class ExperimentConfig:
# Required field - will fail if not provided
experiment_name: str = MISSING
# Optional field - None if not provided
notes: Optional[str] = None
# Required with validation
seed: int = MISSING
def __post_init__(self):
if self.seed is MISSING:
raise ValueError("seed is required for reproducibility")
The MISSING sentinel is a powerful tool for declaring required parameters without default values. Hydra will raise an informative error if a MISSING value is not provided through config files or overrides.
8. Leverage Hydra's Callbacks and Hooks
Hydra provides a rich hook system for injecting custom behavior at various lifecycle stages. Use hooks for tasks like validating configurations, setting up logging, or cleaning up resources:
from hydra.core.hydra_config import HydraConfig
from hydra.core.plugins.common.utils import configure_logging
def on_config_load(config: DictConfig, config_name: str, config_path: str) -> None:
"""Called after config is loaded but before resolution."""
print(f"Loading config: {config_name}")
def on_job_start(config: DictConfig, **kwargs) -> None:
"""Called right before your main function."""
# Set up custom logging
configure_logging(config.logging, config.hydra.job_logging)
def on_job_end(config: DictConfig, **kwargs) -> None:
"""Called after your main function completes."""
# Cleanup, send notifications, etc.
print("Job completed successfully")
# Register hooks via the Hydra config
# In config.yaml:
# hydra:
# callbacks:
# on_config_load:
# _target_: my_package.hooks.on_config_load
# on_job_start:
# _target_: my_package.hooks.on_job_start
9. Test Your Configuration Independently
Treat your configuration as code and write tests for it. Verify that configs resolve correctly, that overrides produce expected values, and that MISSING fields are caught:
from hydra import compose, initialize
from omegaconf import OmegaConf
import pytest
def test_config_resolution():
with initialize(version_base=None, config_path="conf"):
cfg = compose(config_name="config", overrides=[
"dataset=cifar10",
"model=resnet50",
"training.learning_rate=0.01"
])
assert cfg.dataset.name == "cifar10"
assert cfg.model.name == "resnet50"
assert cfg.training.learning_rate == 0.01
def test_missing_required_field():
with initialize(version_base=None, config_path="conf"):
with pytest.raises(Exception):
compose(config_name="config", overrides=[
"experiment.seed=" # Empty override for required field
])
def test_full_config_is_valid():
with initialize(version_base=None, config_path="conf"):
cfg = compose(config_name="config")
# Verify all required fields are present
container = OmegaConf.to_container(cfg, resolve=True)
assert container["training"]["learning_rate"] is not None
assert container["training"]["batch_size"] > 0
Use hydra.compose for programmatic configuration resolution in tests—it mirrors exactly what happens at runtime but without the decorator overhead.
10. Document Your Configuration Schema
A configuration system is only as useful as its documentation. For every config group and key parameter, document its purpose, valid values, and interactions. Structured configs help here because you can add docstrings:
@dataclass
class TrainingConfig:
"""Configuration for model training hyperparameters."""
learning_rate: float = 0.001
"""Initial learning rate for the optimizer. Typical values: 0.1, 0.01, 0.001."""
batch_size: int = 32
"""Number of samples per gradient update. Adjust based on available GPU memory."""
epochs: int = 100
"""Maximum number of training epochs. Early stopping may terminate earlier."""
optimizer: str = "adam"
"""Optimizer algorithm. One of: adam, sgd, adamw."""
Additionally, maintain a README.md in your config directory explaining the overall structure and how to run common configurations.
Conclusion
Hydra transforms configuration management from an afterthought into a first-class engineering discipline. By embracing composable config groups, powerful override semantics, and structured validation, teams can dramatically reduce the friction of experimentation while improving reproducibility. The framework's design encourages modular thinking—each config group becomes a well-defined interface between system components, making it easier to swap datasets, models, optimizers, and environments without code changes.
The best practices outlined here—modular configs, structured validation, comprehensive logging, secret separation, override-friendly design, and thorough testing—form a foundation for building maintainable, scalable Python applications. Whether you're running a single training job on a laptop or orchestrating thousands of hyperparameter sweeps across a compute cluster, Hydra provides the configuration infrastructure that grows with your needs. Start with a simple @hydra.main integration, gradually decompose your config into groups, add structured configs for validation, and before long you'll wonder how you ever managed without it.