Minimal schema-agnostic configuration library for Python.
Provides simple, composable building blocks for configuration management:
- Loaders — fetch config from various sources (environment variables,
.envfiles, TOML) - Merge — combine multiple configs with deep merge and priority control
- Processors — resolve placeholders from external sources (HashiCorp Vault)
Schema-agnostic: use your preferred validation library (Pydantic, msgspec, dataclasses) or none at all.
- Installation
- Quick Start
- Loaders
- Merge
- Processors
- Schema Placeholders
- Using with pydantic-settings
- Migration from pydantic-settings
- Development
- License
pip install justconfFor .env file support:
pip install justconf[dotenv]from typing import Annotated
from pydantic import BaseModel
from justconf import merge, process, toml_loader, env_loader
from justconf.processor import VaultProcessor, TokenAuth
from justconf.schema import Placeholder, extract_placeholders
# Define schema with secret placeholders
class DatabaseConfig(BaseModel):
host: str = "localhost"
port: int = 5432
password: Annotated[str, Placeholder("${vault:secret/data/db#password}")]
class AppConfig(BaseModel):
debug: bool = False
database: DatabaseConfig
# Load and merge (later sources override earlier)
config = merge(
extract_placeholders(AppConfig), # schema defaults with placeholders
toml_loader("config.toml"), # base config file
env_loader(prefix="APP_"), # environment overrides
)
# Resolve secrets from Vault
vault = VaultProcessor(
url="http://vault:8200",
auth=TokenAuth(token="hvs.xxx"),
)
config = process(config, [vault])
# Validate
app_config = AppConfig(**config)Loaders fetch configuration from various sources and return a dictionary.
-
env_loader(prefix=None, case_sensitive=False, nested_delimiter="__", nested_max_split=None) — loads from environment variables. If
prefixis set, filters variables by prefix and strips it from keys. The prefix is matched exactly as given — include the separator if needed (e.g."APP_").config = env_loader(prefix="APP_") # APP_DEBUG=true, APP_PORT=8080 -> {"debug": "true", "port": "8080"}
-
dotenv_loader(path=".env", prefix=None, case_sensitive=False, nested_delimiter="__", nested_max_split=None, encoding="utf-8") — loads from
.envfile. Requirespip install justconf[dotenv]. Supports variable interpolation (${VAR}).config = dotenv_loader(".env", prefix="APP_")
-
toml_loader(path="config.toml", encoding="utf-8") — loads from TOML file using Python's built-in
tomllib. Native TOML types are preserved (int, float, bool, list, dict, datetime).config = toml_loader("config.toml")
Use double underscores (__) to create nested structures from flat environment variables (default delimiter):
export DATABASE__HOST=localhost
export DATABASE__PORT=5432config = env_loader()
# {"database": {"host": "localhost", "port": "5432"}}The delimiter is configurable via nested_delimiter. Set it to None to disable nesting:
# Use dot as delimiter
config = env_loader(prefix="APP_", nested_delimiter=".")
# APP_DATABASE.HOST=localhost -> {"database": {"host": "localhost"}}
# Disable nesting entirely
config = env_loader(prefix="APP_", nested_delimiter=None)
# APP_DATABASE__HOST=localhost -> {"database__host": "localhost"}Use nested_max_split to limit the number of parts when splitting by delimiter (None means unlimited, 0 disables nesting):
config = env_loader(prefix="APP_", nested_max_split=0)
# APP_A__B__C__D=value -> {"a__b__c__d": "value"} (no splitting)
config = env_loader(prefix="APP_", nested_max_split=2)
# APP_A__B__C__D=value -> {"a": {"b__c__d": "value"}} (split into 2 parts)
config = env_loader(prefix="APP_", nested_max_split=3)
# APP_A__B__C__D=value -> {"a": {"b": {"c__d": "value"}}} (split into 3 parts)The merge function combines multiple dictionaries with deep merge. Later arguments have higher priority.
from justconf import merge
config = merge(
{"db": {"host": "localhost", "port": 5432}, "tags": ["a", "b"]},
{"db": {"port": 3306}, "tags": ["c"]},
)
# {"db": {"host": "localhost", "port": 3306}, "tags": ["c"]}Merge strategy:
dict+dict→ recursive deep merge- Everything else (list, str, int, etc.) → overwrite
Processors resolve placeholders in your configuration, fetching values from external sources.
${processor:path#key|modifier:value}
processor— name of the processor (e.g.,vault)path— full API path to the secret (for Vault KV v2, include{mount}/data/{secret_path})key— (optional) specific key within the secretmodifiers— (optional) post-processing modifiers
Placeholders can be embedded within strings:
config = {"dsn": "postgres://user:${vault:secret/data/db#password}@localhost/db"}Allows fetching secrets from HashiCorp Vault (KV v2).
from justconf import process
from justconf.processor import VaultProcessor, TokenAuth
processor = VaultProcessor(
url="http://vault:8200",
auth=TokenAuth(token="hvs.xxx"),
timeout=30, # request timeout in seconds
verify=True, # SSL verification (default: True)
)
config = {"db_pass": "${vault:secret/data/db#password}"}
result = process(config, [processor])
# {"db_pass": "secret_value"}The path from placeholder matches Vault's HTTP API exactly (
GET /v1/{path}). For KV v2, this means{mount}/data/{secret_path}.
In the example, secret/data/db is the Vault path. The #password is the field
name inside the secret.
Finding the path in Vault UI (≥ 1.15): open the secret, go to the
Overview tab (or the Paths tab), and copy the API path. Remove
the /v1/ prefix — the rest is your placeholder path:
API path: /v1/secret/data/db
Placeholder: secret/data/db → ${vault:secret/data/db#field}
Vault < 1.15 (no Paths tab): extract mount and secret path from the URL:
https://vault.example.com/ui/vault/secrets/secret/show/db
~~~~~~ ~~
mount secret path
API path: secret/data/db
Regardless of the UI URL format, the placeholder path is always {mount}/data/{secret_path}.
Since the full path is specified in the placeholder, you can fetch secrets from different mount points in a single config (e.g., secret/data/..., team-kv/data/...) — just ensure your token has access to them.
The verify parameter controls SSL certificate verification:
verify=True(default) — use system CA certificatesverify=False— disable SSL verification (not recommended for production)verify="/path/to/ca-bundle.crt"— use custom CA bundle
# For internal Vault with self-signed certificate
processor = VaultProcessor(
url="https://vault.internal:8200",
auth=TokenAuth(token="hvs.xxx"),
verify="/etc/ssl/certs/internal-ca.crt",
)VaultProcessor supports multiple Vault auth methods:
- TokenAuth(token) — direct token authentication
- AppRoleAuth(role_id, secret_id, mount_path="approle") — for AppRole automated workflows
- JwtAuth(role, jwt, mount_path="jwt") — for JWT/OIDC (GitLab CI/CD, etc.)
- KubernetesAuth(role, jwt=None, jwt_path="...", mount_path="kubernetes") — for Kubernetes pods; JWT is read from
/var/run/secrets/kubernetes.io/serviceaccount/tokenby default - UserpassAuth(username, password, mount_path="userpass") — username/password authentication
Pass a list of auth methods to try them in order until one succeeds:
import os
processor = VaultProcessor(
url="http://vault:8200",
auth=[
TokenAuth(token=os.environ.get("VAULT_TOKEN", "")),
KubernetesAuth(role="myapp"),
AppRoleAuth(role_id="xxx", secret_id="yyy"),
],
)Use vault_auth_from_env() to automatically detect credentials from environment variables:
from justconf.processor import VaultProcessor, vault_auth_from_env
# Detect all available auth methods (sorted by priority)
auths = vault_auth_from_env()
# Use first available (like pydantic-settings-vault)
if auths:
processor = VaultProcessor(
url="http://vault:8200",
auth=auths[0],
)
# Or use fallback chain
processor = VaultProcessor(
url="http://vault:8200",
auth=auths,
)
# Explicit method selection
auths = vault_auth_from_env(method='approle')Supported environment variables (in order of priority):
| Auth Method | Required Variables | Mount Path Override |
|---|---|---|
| AppRoleAuth | VAULT_ROLE_ID + VAULT_SECRET_ID |
VAULT_APPROLE_MOUNT_PATH (default: approle) |
| KubernetesAuth | VAULT_KUBERNETES_ROLE |
VAULT_KUBERNETES_MOUNT_PATH (default: kubernetes) |
| TokenAuth | VAULT_TOKEN |
— |
| JwtAuth | VAULT_JWT_ROLE + VAULT_JWT_TOKEN |
VAULT_JWT_MOUNT_PATH (default: jwt) |
| UserpassAuth | VAULT_USERNAME + VAULT_PASSWORD |
VAULT_USERPASS_MOUNT_PATH (default: userpass) |
Write secrets to files instead of keeping them in memory. Useful for certificates and keys:
config = {
"tls_cert": "${vault:secret/data/tls#cert|file:/etc/ssl/cert.pem}",
"tls_key": "${vault:secret/data/tls#key|file:/etc/ssl/key.pem|encoding:utf-8}",
}
result = process(config, [processor])
# {"tls_cert": "/etc/ssl/cert.pem", "tls_key": "/etc/ssl/key.pem"}If the value is a dict or list, it's serialized as JSON.
Define default placeholder values directly in your schema using Placeholder annotation.
This keeps secret paths co-located with your configuration schema instead of scattered
across config files.
from typing import Annotated
from pydantic import BaseModel
from justconf import merge, process, toml_loader
from justconf.processor import VaultProcessor, TokenAuth
from justconf.schema import Placeholder, extract_placeholders
class DatabaseConfig(BaseModel):
host: str = "localhost"
port: int = 5432
password: Annotated[str, Placeholder("${vault:secret/data/db/creds#password}")]
class AppConfig(BaseModel):
database: DatabaseConfig
api_key: Annotated[str, Placeholder("${vault:secret/data/api#key}")]
# Extract placeholders from schema
schema_defaults = extract_placeholders(AppConfig)
# {'database': {'password': '${vault:secret/data/db/creds#password}'}, 'api_key': '${vault:secret/data/api#key}'}
# Merge with priority: schema defaults < config file < environment
config = merge(
schema_defaults,
toml_loader("config.toml"),
)
# Resolve placeholders
vault_processor = VaultProcessor(url=..., auth=TokenAuth(token=...))
config = process(config, [vault_processor])
# Validate
app_config = AppConfig(**config)Works with any class that has type hints:
from dataclasses import dataclass
from typing import Annotated
from justconf.schema import Placeholder, extract_placeholders
@dataclass
class ServiceConfig:
api_key: Annotated[str, Placeholder("${vault:secret/data/service#key}")]
# Plain classes work too
class PlainConfig:
token: Annotated[str, Placeholder("${vault:secret/data/auth#token}")]
extract_placeholders(ServiceConfig) # {'api_key': '${vault:secret/data/service#key}'}Schema placeholders have the lowest priority. Override them in config files or environment:
[database]
password = "${vault:secret/data/staging/db#password}"Use WithPlaceholders to override placeholders for nested types without modifying the original type.
This is useful when you reuse the same type with different secret sources:
from typing import Annotated
from pydantic import BaseModel
from justconf.schema import Placeholder, WithPlaceholders, extract_placeholders
class DatabaseConfig(BaseModel):
host: str = "localhost"
password: Annotated[str, Placeholder("${vault:secret/data/default#password}")]
username: str = "admin"
class AppConfig(BaseModel):
# Override placeholders for each instance
main_db: Annotated[DatabaseConfig, WithPlaceholders({
'password': '${vault:secret/data/main_db#password}',
'username': '${vault:secret/data/main_db#username}',
})]
replica_db: Annotated[DatabaseConfig, WithPlaceholders({
'password': '${vault:secret/data/replica_db#password}',
})]
result = extract_placeholders(AppConfig)
# {
# 'main_db': {
# 'password': '${vault:secret/data/main_db#password}',
# 'username': '${vault:secret/data/main_db#username}',
# },
# 'replica_db': {
# 'password': '${vault:secret/data/replica_db#password}',
# },
# }Behavior:
- Overrides are merged with placeholders from the nested type (overrides take priority)
- Supports nested dicts for deep structures
- Validates that all keys exist in the target type (raises
PlaceholderErrorfor invalid keys) - Works with
Optional[NestedType]/NestedType | None
When a placeholder omits the #key part, the processor returns the entire value as a dictionary
instead of extracting a single field. This is useful when all fields of a nested type are stored
together under one path:
from typing import Annotated
from pydantic import BaseModel
from justconf import process
from justconf.processor import VaultProcessor, TokenAuth
from justconf.schema import Placeholder, extract_placeholders
class DatabaseConfig(BaseModel):
host: str
port: int
username: str
password: str
class AppConfig(BaseModel):
db: Annotated[DatabaseConfig, Placeholder("${vault:secret/data/db}")]
vault_processor = VaultProcessor(url=..., auth=TokenAuth(token=...))
config = extract_placeholders(AppConfig)
config = process(config, [vault_processor])
# {'db': {'host': 'db.example.com', 'port': 5432, 'username': 'admin', 'password': 'secret'}}
app_config = AppConfig(**config)If you already use pydantic-settings and only need Vault secret resolution, you don't have
to migrate. Install justconf alongside pydantic-settings and use process() to resolve
secrets — it works independently from the rest of justconf:
from typing import Annotated
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
from justconf import process
from justconf.processor import VaultProcessor, vault_auth_from_env
from justconf.schema import Placeholder, extract_placeholders
class DatabaseConfig(BaseModel):
host: str = "localhost"
port: int = 5432
password: Annotated[str, Placeholder("${vault:secret/data/db#password}")]
class AppConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="APP_", env_nested_delimiter="__")
debug: bool = False
database: DatabaseConfig
# Resolve Vault secrets
vault = VaultProcessor(url="http://vault:8200", auth=vault_auth_from_env())
secrets = process(extract_placeholders(AppConfig), [vault])
# Init values have the highest priority in pydantic-settings
config = AppConfig(**secrets)pydantic-settings continues to handle environment variables, .env files, and everything else
it normally does. justconf only resolves ${vault:...} placeholders and passes the result as
init kwargs.
Before (pydantic-settings):
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseConfig(BaseModel):
host: str = "localhost"
port: int = 5432
class AppConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="APP_", env_nested_delimiter="__")
debug: bool = False
port: int = 8080
database: DatabaseConfig = DatabaseConfig()
config = AppConfig()After (justconf):
from pydantic import BaseModel
from justconf import merge, env_loader
class DatabaseConfig(BaseModel):
host: str = "localhost"
port: int = 5432
class AppConfig(BaseModel):
debug: bool = False
port: int = 8080
database: DatabaseConfig = DatabaseConfig()
config = AppConfig(**merge(env_loader(prefix="APP_")))Before (pydantic-settings-vault):
from pydantic import Field
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
from pydantic_vault import VaultSettingsSource
class AppConfig(BaseSettings):
db_password: str = Field(
json_schema_extra={
"vault_secret_path": "secret/data/app",
"vault_secret_key": "db_password",
},
)
api_key: str = Field(
json_schema_extra={
"vault_secret_path": "secret/data/app",
"vault_secret_key": "api_key",
},
)
model_config = {"vault_url": "http://vault:8200"}
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
return (
init_settings,
env_settings,
VaultSettingsSource(settings_cls),
file_secret_settings,
)
config = AppConfig()After (justconf):
from typing import Annotated
from pydantic import BaseModel
from justconf import merge, process, env_loader
from justconf.processor import VaultProcessor, vault_auth_from_env
from justconf.schema import Placeholder, extract_placeholders
class AppConfig(BaseModel):
db_password: Annotated[str, Placeholder("${vault:secret/data/app#db_password}")]
api_key: Annotated[str, Placeholder("${vault:secret/data/app#api_key}")]
config = merge(extract_placeholders(AppConfig), env_loader())
vault = VaultProcessor(
url="http://vault:8200",
auth=vault_auth_from_env(),
)
config = AppConfig(**process(config, [vault]))If you used pydantic-settings-vault, note these environment variable differences:
| pydantic-settings-vault | justconf | Notes |
|---|---|---|
VAULT_AUTH_MOUNT_POINT |
VAULT_APPROLE_MOUNT_PATH |
Per-method variable for AppRole |
VAULT_AUTH_MOUNT_POINT |
VAULT_KUBERNETES_MOUNT_PATH |
Per-method variable for Kubernetes |
VAULT_AUTH_PATH |
VAULT_JWT_MOUNT_PATH |
Per-method variable for JWT |
VAULT_ADDR |
— | Pass URL explicitly via VaultProcessor(url=...) |
VAULT_NAMESPACE |
— | Not supported |
VAULT_CA_BUNDLE |
— | Pass explicitly via VaultProcessor(verify=...) |
In pydantic-settings-vault, a single VAULT_AUTH_MOUNT_POINT variable is shared across all
authentication methods. In justconf, each method has its own variable
(VAULT_APPROLE_MOUNT_PATH, VAULT_KUBERNETES_MOUNT_PATH, VAULT_JWT_MOUNT_PATH), which allows
setting different mount paths for different methods in a fallback chain.
The ~/.vault-token file is not read automatically — the token must be passed explicitly via
TokenAuth(token=...) or the VAULT_TOKEN environment variable.
Authentication method auto-detection priority also differs: pydantic-settings-vault uses
Token → Kubernetes → AppRole → JWT, while justconf uses AppRole → Kubernetes → Token → JWT → Userpass.
In practice this rarely matters, since typically only one method is configured.
| pydantic-settings | justconf |
|---|---|
BaseSettings class inheritance |
Plain BaseModel + loaders |
| Field-level vault config | Placeholders in schema or any config source |
| Implicit env loading | Explicit merge() of sources |
VAULT_AUTH_MOUNT_POINT (shared) |
Per-method mount path env vars (VAULT_APPROLE_MOUNT_PATH, etc.) |
- mise for managing toolchains
-
install toolchains and deps
mise trust && mise install make deps -
create a local
.env.shfor personal environment setup — secrets, tokens, tool overrides, any shell you want (auto-sourced by mise):cp .env.sh.example .env.sh
-
verify the setup by running tests
make test
You can use a real Vault server to debug this project. To make this process
easier, this project includes a docker-compose.yml file that can run a
ready-to-use Vault server.
To run the server and set it up, run the following commands:
docker compose up
make vaultAfter that, you will have a Vault server running at http://localhost:8200, where you can authorize in three ways:
- using the root token (which is
token) - using the JWT method (role=
jwt_role, token=link) - using the AppRole method (the values of role_id and secret_id can be found in the logs of the
make vaultcommand).
MIT