Performance Benchmarks#
Comparison of dature against pydantic-settings, python-decouple, dynaconf, and hydra, split into three independent costs:
- Import — the one-time cost of importing the library into a process. Measured in a clean per-library venv (see below), because it's easy to overstate.
- Build + load — one full cycle with nothing reused: declare the model, build the source and loader, load. This is what "function mode" pays on every call.
- Warm reuse — dature only: the hot path once the loader is built once and reused, or cached.
Methodology#
Import (bench_import.py) — each library is measured in its own fresh virtualenv with only that package installed, and the common stdlib is pre-imported first, so the number is the library's own marginal import cost. Measuring inside the project's benchmark venv would overstate it roughly 2× — a large site-packages slows Python's import machinery, and part of any import is really the stdlib the library pulls in. Each sample is a fresh subprocess timed inside itself; speed in ms, memory as tracemalloc peak in MiB.
Speed (bench_speed.py) — in-process, timeit.repeat(number=500, repeat=5). The library is already imported (a warmup call warms sys.modules), so these numbers exclude import and capture only the per-call work. Every library re-declares its model class each call, so it's apples-to-apples.
Memory (bench_memory.py) — two different tools, each matched to what it measures honestly:
- Build + load → retained RSS. How much resident memory stays after building N objects and keeping them alive (process RSS growth ÷ N, measured in a fresh subprocess per row). We use RSS rather than
tracemallocbecausetracemalloconly sees the Python heap: pydantic-settings does most of its schema work in a Rust extension (pydantic_core) thattracemalloccannot see, which understates it ~20× and makes the comparison meaningless. RSS counts native allocations too, so it is a fair cross-library number. (Atracemallocpeak for a single dature build reads a few hundred KiB, but that is transient code-generation scratch that is freed immediately — not retained footprint.) - Warm reuse →
tracemallocpeak per call. Here nothing new stays resident (the loader is pre-built and reused), so an RSS delta would read ~0;tracemalloccorrectly captures the transient per-call allocation churn.
Machine: Apple M3, Python 3.13.13. Schema (flat): 8 fields — host: str, port: int, debug: bool, max_connections: int, timeout: float, db_name: str, workers: int, log_level: str. Library versions: pydantic-settings 2.14.2 · python-decouple 3.8 · dynaconf 3.2.13 · hydra-core 1.3.3.
uv sync --group benchmarks
uv run --group benchmarks python benchmarks/bench_import.py
uv run --group benchmarks python benchmarks/bench_speed.py
uv run --group benchmarks python benchmarks/bench_memory.py
The vs columns are ratios to the best value in that column (speed and memory have independent baselines).
1. Import (one-time, per process)#
Clean per-library venv, stdlib pre-imported.
| Library | Import speed | vs | Import memory | vs |
|---|---|---|---|---|
| adaptix (dature's engine) | 112.4 ms | baseline | 8.3 MiB | baseline |
| pydantic-settings | 114.1 ms | 1.0× | 12.1 MiB | 1.5× |
| dature | 161.8 ms | 1.4× | 11.5 MiB | 1.4× |
dature imports in ~162 ms and ~11.5 MiB — about 1.4× pydantic-settings on time, and slightly lighter on memory. Most of dature's import is adaptix (its type engine, ~112 ms); dature's own code adds ~50 ms. This is a one-time cost paid once per process, not per config load.
2. Build + load (per fresh load, import excluded)#
One full cycle with nothing reused. Rows ordered by speed. Memory is retained RSS per build (see Methodology) — the resident cost that actually stays, counting native/Rust allocations, not the transient tracemalloc peak.
By default the decorator (like the function) does not retain its compiled engine — cache_engine defaults to False (see Caching). The ENV table below contrasts that default against the explicit cache_engine=True opt-in, which is what pays for the cheap warm reuse in section 3.
ENV (os.environ → typed dataclass)#
| Library | Speed | vs | Memory (RSS) | vs |
|---|---|---|---|---|
| python-decouple | 147 µs | baseline | 11.2 KiB | baseline |
| pydantic-settings | 287 µs | 2.0× | 27.4 KiB | 2.4× |
| dature (func) | 1.1 ms | 7.6× | 22.3 KiB | 2.0× |
dature (decorator, cache_engine=False, default) | 1.1 ms | 7.6× | 30.4 KiB | 2.7× |
dature (decorator, cache_engine=True) | 1.1 ms | 7.6× | 100.9 KiB | 9.0× |
| dynaconf | 3.9 ms | 26.3× | 11.8 KiB | 1.1× |
ENV file (.env → typed dataclass)#
| Library | Speed | vs | Memory (RSS) | vs |
|---|---|---|---|---|
| python-decouple | 410 µs | baseline | 11.4 KiB | baseline |
| pydantic-settings | 1.3 ms | 3.1× | 28.0 KiB | 2.5× |
| dature (func) | 2.7 ms | 6.5× | 22.7 KiB | 2.0× |
| dature (decorator) | 2.7 ms | 6.5× | 31.2 KiB | 2.7× |
| dynaconf | 8.5 ms | 20.8× | 12.6 KiB | 1.1× |
JSON file#
| Library | Speed | vs | Memory (RSS) | vs |
|---|---|---|---|---|
| pydantic-settings | 1.0 ms | baseline | 28.3 KiB | 2.5× |
| dature (func) | 2.4 ms | 2.4× | 22.8 KiB | 2.0× |
| dature (decorator) | 2.8 ms | 2.7× | 31.2 KiB | 2.7× |
| dynaconf | 3.8 ms | 3.7× | 11.4 KiB | baseline |
python-decouple: no JSON file support.
TOML file#
| Library | Speed | vs | Memory (RSS) | vs |
|---|---|---|---|---|
| pydantic-settings | 709 µs | baseline | 28.1 KiB | 2.4× |
| dature (decorator) | 2.4 ms | 3.4× | 31.0 KiB | 2.7× |
| dature (func) | 2.5 ms | 3.6× | 22.7 KiB | 2.0× |
| dynaconf | 3.2 ms | 4.5× | 11.5 KiB | baseline |
python-decouple: no TOML file support.
YAML file#
| Library | Speed | vs | Memory (RSS) | vs |
|---|---|---|---|---|
| pydantic-settings | 803 µs | baseline | 28.2 KiB | 2.4× |
| dature (func) | 2.6 ms | 3.3× | 22.7 KiB | 1.9× |
| dature (decorator) | 2.7 ms | 3.3× | 31.3 KiB | 2.6× |
| dynaconf | 3.3 ms | 4.1× | 11.9 KiB | baseline |
| hydra (DictConfig, not typed) | 17.5 ms | 21.8× | — | — |
python-decouple: no YAML file support. hydra returns an OmegaConf DictConfig, not a typed dataclass; its RSS is not measured (GlobalHydra is a process singleton, so it can't be built in a tight loop).
Nested model, 5 levels deep (ENV source)#
Five dataclasses nested five levels deep (Level1.inner → … → Level5), from __-joined env keys.
| Library | Speed | vs | Memory (RSS) | vs |
|---|---|---|---|---|
| pydantic-settings | 670 µs | baseline | 65.2 KiB | baseline |
| dature (func) | 2.4 ms | 3.6× | 65.2 KiB | baseline |
| dature (decorator) | 2.4 ms | 3.6× | 71.3 KiB | 1.1× |
python-decouple / hydra: no schema-driven nested model from ENV.
Three models loaded at once (ENV source)#
| Library | Speed | vs | Memory (RSS) | vs |
|---|---|---|---|---|
| pydantic-settings | 576 µs | baseline | 45.3 KiB | 4.8× |
| dature (func) | 2.3 ms | 4.0× | 41.6 KiB | 4.4× |
| dature (decorator) | 2.4 ms | 4.1× | 63.8 KiB | 6.7× |
| dynaconf | 7.5 ms | 13.1× | 9.5 KiB | baseline |
On a fresh build dature is a few× slower than pydantic-settings on speed, because it generates and compiles an adaptix loader (Python codegen) while pydantic builds its schema in Rust. On memorydature (func) retains ~23–39 KiB per build — the string-value sources (ENV, ENV file) sit slightly higher than the file-format sources (JSON, TOML, YAML, ~23 KiB). dature (decorator) retains more (~100 KiB) because a decorated class keeps its Loader and compiled retort alive for the class lifetime — a one-time cost per class, not per load, and exactly what makes warm reuse cheap (see below).
3. Warm reuse#
Hot path once the loader is built at module level, and with caching. The steady state of a long-running service.
Both dature (loader built once) and pydantic-settings (schema cached on the class) are measured re-loading over their pre-built object.
| Mode | Speed | vs | Memory | vs |
|---|---|---|---|---|
dature — decorator, hot, cache=True (default) | 1.0 µs | baseline | 1.0 KiB | baseline |
dature — decorator, hot, cache=timedelta(...) (TTL) | 1.1 µs | 1.1× | 1.1 KiB | baseline |
dature — Loader reuse, cache_engine=True | 56.0 µs | 55.3× | 10.2 KiB | 10.0× |
dature — decorator, hot, cache_engine=True | 57.8 µs | 57.1× | 10.6 KiB | 10.3× |
| pydantic-settings — reuse | 119.7 µs | 118.1× | 20.6 KiB | 20.1× |
dature — decorator, hot, cache_engine=False | 889 µs | 876.9× | 424.5 KiB | 413.5× |
| dature — function mode, fixed schema, no reuse | 920 µs | 907.7× | 424.7 KiB | 413.8× |
In steady state, opting into cache_engine=True flips the ranking versus build+load: dature (Loader reuse) is faster than pydantic-settings reused (~56 µs vs ~120 µs) and about half the memory (10.2 vs 20.6 KiB). Caching (cache=True/cache=timedelta) drops dature to ~1 µs / ~1 KiB — over 100× faster than either. cache=timedelta adds automatic TTL expiry a plain @lru_cache wrapper can't do.
The last two rows are the honest counter-example: with cache_engine left at its default (False), nothing about the compiled engine survives a call, so both the decorator (with cache=False) and function mode pay full recompilation every time — ~889–920 µs and ~425 KiB of transient churn per call. That's the trade-off cache_engine makes explicit: the default keeps the decorator's retained footprint low (section 2) at the cost of the hot path; set cache_engine=True (or leave the default cache=True, which caches the result and never needs to recompile after the first call) to get the fast path back.
Key takeaways#
Split the cost and dature looks very different from a native "full-cycle" number. The three pieces are independent: import (~162 ms, once per process), fresh build+load (~1.1–2.8 ms, only in function mode), and warm reuse (~57 µs with cache_engine=True, ~1 µs cached).
Import is reasonable — comparable to pydantic-settings, lighter on RAM. ~162 ms vs ~114 ms, and 11.5 MiB vs 12.1 MiB. (A native measurement inside a fat venv reports ~2× higher for everyone — site-packages size inflates import time; always measure imports in a clean venv.)
Don't use function mode in a hot path. Building and compiling the adaptix loader on every call costs ~1.1–2.8 ms. Even with a fixed schema, a throwaway Loader per call costs ~920 µs because each fresh loader discards its setup — build the loader once and opt into cache_engine=True (decorator or Loader reuse) and it drops to ~56–58 µs; cache the result (cache=True, the default) and it's ~1 µs. Function mode is for scripts and one-shot tools, not per-request loading.
cache_engine controls whether the compiled engine is retained — default off. At the default cache_engine=False, dature (decorator) retains only ~30 KiB per build, close to dature (func) and pydantic-settings, because nothing about the compiled loader survives past the call that built it. Opting into cache_engine=True trades that back for ~100 KiB of retained memory in exchange for a ~15× faster hot path (~58 µs vs ~890 µs) — the right call for a long-running service, the wrong call for a short-lived script. cache (which defaults to True) is a separate knob: it caches the loaded result, so after the first call the compiled engine is never needed again regardless of cache_engine — which is exactly why the default pairs cache=True with cache_engine=False.
dature's memory is comparable-to-lighter than pydantic. Measured as retained RSS (a fair, native-aware metric), dature (func) holds ~22 KiB per build vs pydantic's ~27–28 KiB. tracemalloc would mislead here — it sees dature's transient Python codegen but is blind to pydantic's Rust core, which is why we report RSS (see Methodology).
pydantic-settings leads on cold build speed, but dature wins the steady state. On a cold build pydantic is faster (Rust codegen vs Python); memory is comparable-to-better. Reused with cache_engine=True, dature is faster (~56 µs vs ~120 µs) and about half the memory, and cached it's over 100× faster than either. For a long-running service — build once, load many — that steady state is what matters.