Typed, multi-source configuration loading on top of msgspec.
msgspec-config is for applications that need:
- one typed model for configuration shape
- multiple config inputs (files,
.env, environment, CLI, custom providers) - deterministic precedence across all inputs
- strict validation/coercion without writing parsing glue
The core idea is simple: define one DataModel, attach ordered DataSources, and instantiate the model.
Please visit the API docs at this project's github pages site: https://maxpareschi.github.io/msgspec-config/
pip install msgspec-configuv add msgspec-configTested on Python>=3.13, probably works also on Python>=3.11.
config.toml:
host = "toml-host"
port = 7000
[log]
level = "INFO".env:
APP_PORT=7500
APP_LOG_LEVEL=DEBUGfrom msgspec_config import (
APISource,
CliSource,
DataModel,
DotEnvSource,
EnvironSource,
JSONSource,
TomlSource,
datasources,
entry,
group,
)
class LogConfig(DataModel):
level: str = "WARN"
file_path: str = "/var/log/app.log"
@datasources(
TomlSource(toml_path="config.toml"),
DotEnvSource(dotenv_path=".env", env_prefix="APP"),
EnvironSource(env_prefix="APP"),
CliSource(),
)
class AppConfig(DataModel):
host: str = entry("127.0.0.1", min_length=1)
port: int = entry(8080, ge=1, le=65535)
debug: bool = False
log: LogConfig = group(collapsed=True)
cfg = AppConfig(port=9000)
print(cfg.model_dump_json(indent=2))Precedence is deterministic and intentional:
defaults < source_1 < source_2 < ... < source_n < kwargs
With the example above:
- model defaults are the baseline
TomlSourceoverrides defaultsDotEnvSourceoverrides TOMLEnvironSourceoverrides.envCliSourceoverrides environment values- constructor kwargs (
AppConfig(port=9000)) win last
Rationale: this gives safe defaults in code, then progressive override points for deploy/runtime, while still keeping a final explicit override path in Python.
Important:
env_prefixis mandatory for bothEnvironSourceandDotEnvSource.- Empty/blank prefixes raise
ValueError.
Use entry(...) when you need validation metadata and/or safe mutable defaults.
Why it exists:
- attaches
msgspec.Meta(...)constraints directly from field declaration - converts mutable defaults (
list,dict,set) into factories automatically - supports extra UI/schema keys:
hidden_if,disabled_if,parent_group,ui_component
from msgspec_config import DataModel, entry
class ApiConfig(DataModel):
timeout_seconds: int = entry(30, ge=1, le=120, description="Request timeout")
tags: list[str] = entry([], description="Dynamic tags")Use group(...) for nested object/list/dict fields inferred from annotations.
Why it exists:
- creates safe defaults for nested structures without shared state
- adds optional UI/schema hints (
collapsed,mutable)
from msgspec_config import DataModel, group
class Child(DataModel):
value: int = 1
class Parent(DataModel):
child: Child = group(collapsed=True)
children: list[Child] = group(mutable=True)
by_name: dict[str, Child] = group(mutable=True)Notes:
- object annotations used with
group()must be zero-arg constructible group()is for object/list/dict-like fields, not primitive scalars
All built-ins are importable from both msgspec_config and msgspec_config.sources.
When a source is used with resolve(model=...) (or through @datasources(...) on a
DataModel), field resolution accepts both canonical and encoded/alias names, and mapped
output keys are emitted using encoded names.
- load mappings from files using
msgspec.toml.decode/msgspec.yaml.decode - if path is unset or missing, they return
{}(treated as "source absent") - parse/read failures raise
RuntimeErrorwith file context
- decodes inline JSON (
json_data) or loads JSON fromjson_path - if both are set,
json_datatakes precedence - if path is unset/missing, returns
{} - parse/read failures raise
RuntimeErrorwith context
- parses dotenv syntax (
export, quotes, inline comments) - requires non-empty
env_prefix(prefix scoping is mandatory) - nested keys are mapped with
nested_separator(default_) - with a
model, values are coerced to field types - recognized keys that fail coercion are captured in source
__unmapped_kwargs__
Example precedence inside one source:
APP_LOG={"level":"DEBUG"}
APP_LOG_LEVEL=WARNAPP_LOG_LEVEL overrides APP_LOG.level, regardless of line order.
Same mapping/coercion behavior as DotEnvSource, but reads from os.environ.
env_prefix is mandatory, and failed coercions/unmatched keys are captured in
source __unmapped_kwargs__.
EnvironSource(env_prefix="APP", nested_separator="__")
# APP_LOG__LEVEL=ERROR -> {"log": {"level": "ERROR"}}Generates options from model fields (including nested fields).
Key behavior:
- nested fields become flags like
--log-level - bools support both positive and negative forms:
--debug/--no-debug - nested struct fields also accept JSON on the top-level flag:
--log '{"level":"DEBUG"}'
- explicit nested flags override keys from that JSON
- unknown CLI args are stored on source runtime state in
__unmapped_kwargs__ - set
kebab_case=Falseto use dotted long flags (e.g.--log.level) - CLI accepts canonical and encoded/alias field names, and maps parsed values to encoded field names
src = CliSource(cli_args=["--host", "api", "--unknown-flag"])
data = src.resolve(model=AppConfig)
print(data) # {"host": "api"}
print(src.__unmapped_kwargs__) # {"unknown-flag": True}- performs an HTTP
GETrequest againstapi_url - optional auth header via
header_name+header_value - optional
root_nodeto unwrap wrapped payloads (for example{"data": {...}}) - request or parse failures raise
RuntimeErrorwith endpoint context
src = APISource(
api_url="https://example.com/config",
header_name="Authorization",
header_value="Bearer <token>",
root_node="data",
)
data = src.resolve()When built-ins are not enough, implement DataSource.load(...).
from typing import Any
from msgspec_config import DataModel, DataSource, datasources
class SecretsSource(DataSource):
def load(self, model: type[DataModel] | None = None) -> dict[str, Any]:
# Replace this with Vault/AWS/GCP/etc.
return {"host": "secrets-host", "port": 8443}
@datasources(SecretsSource())
class ServiceConfig(DataModel):
host: str = "localhost"
port: int = 8080Rationale: sources are deep-cloned per model instantiation, so source-local mutable state does not leak across DataModel() calls. DataSource.resolve(...) is the public finalized loader (reset + finalize); custom sources should override load(...).
- Do not shadow
DataModel/DataSourcemethod names with fields; this is user responsibility and can break runtime behavior.
DataModel is a msgspec.Struct configured as keyword-only and with dict-like output support.
Useful methods:
from_data(data)to create an instance from a Python mappingfrom_json(json_str)to create an instance from JSON bytes/stringmodel_dump()to get the model converted in Python builtinsmodel_dump_json(indent=...)for JSON outputmodel_json_schema(indent=...)for JSON Schema exportget_datasources_payload(*sources, **kwargs)to retrieve merged source payloads manuallyget_unmapped_payload()to lazily merge source runtime__unmapped_kwargs__in source order plus unknown constructor kwargs (merged last)
Notes:
from_data(...)andfrom_json(...)ignore unknown keys.- Unknown keyword arguments passed to
DataModel(...)are available throughget_unmapped_payload().
Example:
cfg = AppConfig.from_json('{"host":"example.com","port":8081}')
print(cfg.model_dump())
print(AppConfig.model_json_schema(indent=2))DataModel: typed model base class with validation/serialization helpersDataSource: source base class (load(model=...) -> raw mapping,resolve(model=...) -> finalized mapping)datasources(*sources): decorator that attaches ordered source templatesentry(...): field helper with validation metadata and safe mutable defaultsgroup(...): helper for grouped object/list/dict fields- built-ins:
TomlSource,YamlSource,JSONSource,DotEnvSource,EnvironSource,CliSource,APISource
The repository includes a Makefile to standardize common local tasks. Run targets from the project root.
Prerequisites:
uv- GNU Make (
make) - on Windows, use a GNU Make provider (for example Git Bash
makeormingw32-make)
Typical workflow:
make venv # install/update dependencies from lockfile
make ruff # format + lint autofix
make test # run tests
make docs # regenerate docs in ./docsRun the full local pipeline:
make allall expands to:
venv -> ruff -> test -> docs
Makefile targets:
make venv:uv syncmake docs:uv run pdoc -o ./docs --docformat google --favicon assets/msgspec-config-logo.svg --logo assets/msgspec-config-logo.svg --search -t ./docs --show-source msgspec_configmake ruff:uv run ruff format .anduv run ruff check --fix .make test:uv run pytestmake build:uv build --clear --no-sourcesmake publish-testpypi: runsmake build, thenuv publish --index testpypimake publish-pypi: runsmake build, thenuv publish
Equivalent direct commands (without make):
uv sync
uv run ruff format .
uv run ruff check --fix .
uv run pytest
uv run pdoc -o ./docs --docformat google --favicon assets/msgspec-config-logo.svg --logo assets/msgspec-config-logo.svg --search -t ./docs --show-source msgspec_config
uv build --clear --no-sourcesBuild clean artifacts:
make buildPublish to TestPyPI first:
$env:UV_PUBLISH_TOKEN="pypi-<testpypi-token>"
make publish-testpypiPublish to PyPI:
$env:UV_PUBLISH_TOKEN="pypi-<pypi-token>"
make publish-pypiPackaging policy:
- wheel: runtime package only (
msgspec_config) - sdist: includes source, tests, and docs metadata for downstream builds/tests