Skip to content

Define a configuration class with EnvConfig

EnvConfig lets you describe environment-based configuration declaratively — each field carries its type, default, description, and any conversion hints in one place.

from env_proxy import EnvConfig, EnvProxy, Field

class MyConfig(EnvConfig):
    env_proxy = EnvProxy(prefix="MYAPP")

    debug: bool = Field(description="Enable debug mode", default=False)
    database_url: str = Field(description="Database connection URL")
    max_connections: int = Field(description="Maximum DB connections", default=10)
    cache_backends: list[str] = Field(description="Cache backends", type_hint="list")

Configure the proxy

Two equivalent ways to attach an EnvProxy to a config class:

# Option A — class attribute (most flexible; full EnvProxy control)
class MyConfig(EnvConfig):
    env_proxy = EnvProxy(prefix="MYAPP", uppercase=True, underscored=True)
    ...

# Option B — shorthand prefix
class MyConfig(EnvConfig):
    env_prefix: str = "MYAPP"
    ...

Per-field overrides are also available: pass env_prefix= to a specific Field() to escape the class-level prefix.

Access values

Instantiate the class and read attributes:

config = MyConfig()
print(config.debug)            # looks up MYAPP_DEBUG
print(config.database_url)     # raises EnvKeyMissingError if unset

Fields are resolved on first access, not at construction. That keeps construction cheap and avoids touching env vars you don't end up reading — but it also means malformed env values surface lazily. To force eager resolution at startup, see Validate and freeze.

Required vs optional fields

class MyConfig(EnvConfig):
    env_prefix: str = "MYAPP"

    # Required — no `default`. Missing env raises EnvKeyMissingError.
    api_token: str = Field()

    # Optional — `default` provided.
    timeout: int = Field(default=30)

    # Optional — `default=None` for an explicit "missing means None".
    callback_url: str | None = Field(default=None)

Per-instance defaults with default_factory

When you want a fresh value for each instance — a mutable container, a generated identifier, a timestamp captured at startup — pass a zero-arg callable as default_factory:

import uuid
from datetime import datetime

from env_proxy import EnvConfig, Field

class MyConfig(EnvConfig):
    env_prefix: str = "MYAPP"

    tags: list[str] = Field(default_factory=list)
    request_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
    started_at: datetime = Field(
        convert_using=datetime.fromisoformat,
        default_factory=datetime.now,
    )

Always use default_factory for mutable defaults. Field(default=[]) shares one list across every instance of the class; mutating cfg.tags mutates the next MyConfig()'s tags too. This is the same trap dataclasses warns about, and the fix is the same: Field(default_factory=list).

The factory runs once at MyConfig() construction time, mirroring dataclasses.field(default_factory=...). The result is stored on the instance and used whenever the env var is missing, so started_at captures the moment the config was built — not the moment you first read the attribute. Two separate MyConfig() instances each get their own factory call.

default_factory is mutually exclusive with default; passing both raises EnvConfigError at class-definition time. A constructor override (MyConfig(tags=[...])) skips the factory entirely.

Choosing between default and default_factory

Field shape Use
Immutable scalar (int, str, bool) default=
Explicit None fallback default=None
Mutable container (list, dict, set) default_factory=
Per-instance identity (uuid, now) default_factory=
Computed from runtime state default_factory=

Customizing field names

By default the env-var name is derived from the field name (uppercased, prefixed). Use alias to override on a per-field basis:

class MyConfig(EnvConfig):
    env_prefix: str = "MYAPP"
    # Reads MYAPP_LEGACY_NAME instead of MYAPP_DATABASE_URL:
    database_url: str = Field(alias="legacy_name")

See Field options reference for the full list of Field() parameters.