Skip to main content

litestar-settings

Python License

Typed settings for Litestar apps, built on msgspec.Struct - like pydantic-settings, but msgspec instead of Pydantic.

Features

  • Framework-agnostic core. litestar_settings.core has no dependency on Litestar; a settings schema is just a msgspec.Struct.
  • Composable sources. EnvSource, DotEnvSource, TomlSource, YamlSource, SecretsDirSource, MappingSource - each reads from one place and returns a raw nested dict.
  • Explicit merge order. You pass load_settings(cls, sources) an ordered list; later sources override earlier ones, recursively for nested dicts. No hidden "env beats file beats default" precedence.
  • One validation pass. All sources are merged into a single raw mapping first; that mapping is converted into your Struct exactly once via msgspec.convert().
  • Secret[T] fields. Wrap secret-bearing fields (api_key: Secret[str]) so they never leak into repr()/logs by accident - built automatically, no dec_hook wiring needed.
  • Thin Litestar plugin. SettingsPlugin builds the Struct once, exposes it through DI, and fails app boot - not the first request - if the merged config doesn't satisfy the schema.
  • No hot-reload. Settings load once, at startup - see Why no hot-reload.

Install

pip install litestar-settings              # msgspec + python-dotenv, incl. DotEnvSource
pip install 'litestar-settings[yaml]'      # adds YamlSource (ruamel.yaml)
pip install 'litestar-settings[litestar]'  # adds the Litestar plugin's dependency

TOML support needs no extra - it uses the stdlib tomllib (Python 3.11+).

Quick start

import msgspec
from litestar_settings import DotEnvSource, EnvSource, MappingSource, TomlSource, load_settings


class DBSettings(msgspec.Struct):
    host: str
    port: int = 5432


class Settings(msgspec.Struct, kw_only=True):
    debug: bool = False
    db: DBSettings


settings = load_settings(
    Settings,
    [
        TomlSource("settings.toml"),          # 1. defaults / base config
        DotEnvSource(".env", prefix="APP_"),  # 2. local dev overrides
        EnvSource(prefix="APP_"),             # 3. real environment wins
        MappingSource({"debug": True}),       # 4. explicit override, e.g. from a CLI flag
    ],
)

Sources are applied left to right; each later source deep-merges onto the result of the ones before it. Nested keys work through the same __ convention across EnvSource, DotEnvSource, and SecretsDirSource:

APP_DEBUG=true
APP_DB__HOST=localhost
APP_DB__PORT=6543

becomes {"debug": "true", "db": {"host": "localhost", "port": "6543"}}, which load_settings then converts (lax by default, so "6543" becomes 6543: int).

Pass strict=True to load_settings to disable that coercion and require exact types.

Usage

Secrets files (Docker/Kubernetes style)

from litestar_settings import SecretsDirSource

# /run/secrets/db__password -> {"db": {"password": "<file contents>"}}
SecretsDirSource("/run/secrets")

Secret fields

msgspec.Struct's default repr() prints every field verbatim - including secrets. Wrap secret-bearing fields in Secret[T] so they don't end up in logs or tracebacks by accident:

import msgspec
from litestar_settings import Secret, load_settings, TomlSource


class Settings(msgspec.Struct, kw_only=True):
    api_key: Secret[str]
    db_port: Secret[int] = Secret(5432)


settings = load_settings(Settings, [TomlSource("settings.toml")])

print(settings)                       # Settings(api_key=Secret('**********'), db_port=Secret('**********'))
settings.api_key.get_secret_value()   # the real value

load_settings() builds Secret[...] fields automatically - the wrapped type (str, int, ...) still goes through the normal msgspec.convert() coercion, so Secret[int] from an env var string works the same as a plain int field would. No dec_hook wiring needed; pass your own dec_hook for other custom types and it's consulted after Secret.

Secret only guards repr()/str() - it doesn't stop you from serializing the wrapped value elsewhere (e.g. msgspec.json.encode(settings.api_key.get_secret_value())), and encoding a Secret-typed field back to JSON needs its own enc_hook since msgspec doesn't know the type.

Custom sources

Source is a structural Protocol - any object with a zero-arg load() method returning a raw nested mapping satisfies it, no subclassing required:

from litestar_settings import RawMapping


class RedisSource:
    def __init__(self, client: Redis, key: str) -> None:
        self._client = client
        self._key = key

    def load(self) -> RawMapping:
        return json.loads(self._client.get(self._key) or "{}")

RawMapping is just dict[str, object], exported for readability - using the plain dict[str, object] annotation works identically.

Litestar usage

from __future__ import annotations

import msgspec
from litestar import Litestar, get
from litestar.config.app import AppConfig
from litestar.di import NamedDependency
from litestar_settings import EnvSource, TomlSource
from litestar_settings.plugin import SettingsPlugin


class Settings(msgspec.Struct, kw_only=True):
    debug: bool = False
    api_key: str


def apply_app_config(settings: Settings, app_config: AppConfig) -> AppConfig:
    app_config.debug = settings.debug
    return app_config


settings_plugin = SettingsPlugin(
    Settings,
    [TomlSource("settings.toml"), EnvSource(prefix="APP_")],
    apply_app_config=apply_app_config,  # optional - see below
)


@get("/status")
async def status(settings: NamedDependency[Settings]) -> dict:
    return {"debug": settings.debug}


app = Litestar(route_handlers=[status], plugins=[settings_plugin])

Fail-fast timing

By default SettingsPlugin builds Settings lazily behind a cached factory and forces that first build from an on_startup hook - so a missing api_key fails ASGI lifespan startup (litestar run, AsyncTestClient(app=app), ...), not the first request that happens to depend on settings. Constructing Litestar(...) itself never fails on bad config; importing the app module stays cheap even if the environment isn't fully set up yet (useful for litestar routes, schema generation, etc.).

Pass apply_app_config when you want native AppConfig fields (debug, openapi_config, ...) derived from Settings. Since AppConfig must be finalized synchronously inside on_app_init, this switches the plugin to build Settings eagerly at that point instead of at on_startup - still fail-fast, just earlier (at Litestar(...) construction time).

Custom dependency key

SettingsPlugin(Settings, [...], dependency_key="app_settings")
@get("/status")
async def status(app_settings: NamedDependency[Settings]) -> dict: ...

Design

Core is framework-agnostic. litestar_settings.core has no dependency on Litestar and no magic layered on top of typing. A settings schema is just a msgspec.Struct. Its only required dependencies are msgspec and python-dotenv (for DotEnvSource); YamlSource and the Litestar plugin are behind extras - see Install.

Sources are plain objects with a load() method. EnvSource, DotEnvSource, TomlSource, YamlSource, SecretsDirSource, MappingSource - each reads from one place and returns a raw nested dict. None of them validate or coerce anything.

Merge order is an explicit list, not hidden precedence. You pass load_settings(cls, sources) an ordered list; later sources override earlier ones, recursively for nested dicts. There is no built-in "env beats file beats default" rule baked into the source types themselves.

One convert() pass at the end. All sources are merged into a single raw mapping first; that mapping is converted into your Struct exactly once via msgspec.convert(). Sources never touch msgspec at all.

Secret[T] is built into that one pass. load_settings() composes a dec_hook that recognizes Secret[...] field types and wraps them automatically; a dec_hook you pass yourself is consulted for anything Secret doesn't handle. See Secret fields.

Litestar layer is a thin wrapper on top. SettingsPlugin builds the Struct once, exposes it through DI (Provide(get_settings, use_cache=True)), and fails app boot - not the first request - if the merged config doesn't satisfy the schema.

Load-once at startup, no hot-reload. See Why no hot-reload.

Why no hot-reload

Settings load once, at startup. There's no file watcher, no signal handler, no live-reload path.

  • Predictability. If config could change under a running process, two reads of settings.db_url within the same request could theoretically disagree. That's a class of bug worth not having.
  • Fail-fast stays simple. With validation only at startup, the service either boots with a valid config or doesn't boot. Hot-reload forces a decision about what to do with an invalid config arriving mid-flight (roll back? crash? ignore the bad value?) - a separate problem this library doesn't try to solve.
  • Most settings don't need it. DB URLs, secret keys, uptime-scoped feature flags - a restart (systemd restart, a new container) is cheap and makes the reload story trivial.

If you need live-updated values (e.g. a feature flag toggled without a restart), that's a different problem - a small polling/pubsub-backed accessor - and deliberately out of scope here.

Development

uv sync --all-extras

uv run ruff check
uv run ruff format --check
uv run pyrefly check
uv run pyrefly coverage check

uv run pytest

License

BSD 3-Clause. See LICENSE.

Release files for litestar-settings 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for litestar-settings 0.1.0
File Size Uploaded
litestar_settings-0.1.0.tar.gz 65.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for litestar-settings 0.1.0
File Interpreter ABI Platform
litestar_settings-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 81.1 kB

Release files / litestar_settings-0.1.0.tar.gz

Download URL litestar_settings-0.1.0.tar.gz
Size 65.1 kB
Tags Source
SHA-256 checksum
How to use checksums
51b15f45e23e375d37fce37d3ae50b9d09842e0082c4cf0ae15354e30a2b0c65
BLAKE2b-256 checksum
How to use checksums
0764aef3091efb427112a0367d8077d44a0a85330b264fe41a16ec682c88a828
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / litestar_settings-0.1.0-py3-none-any.whl

Download URL litestar_settings-0.1.0-py3-none-any.whl
Size 16.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a9ee1e7e110d9a9a6cb1933c215446f209c871bf93b63fe96129eedda0eb68b7
BLAKE2b-256 checksum
How to use checksums
48f1ac551277542cf6159c617153bffbf290b89a6d6f1f7fcfa9b28fa4199e56
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page