Python

Read variables

Read Envless variables in Python: coercion to int, float and bool, optional reads, the client view, typing with a Protocol or pydantic, test overrides, and why env refuses to be serialised.

Once a load has run, read a variable as an attribute of env. A name that is a Python keyword, class for instance, reads the same way by subscription. Names that start and end with two underscores are reserved for Python itself and unreachable through env, so read those from the dict load() returns.

python
from envless_env.server import env

database_url = env.DATABASE_URL
same_value = env['DATABASE_URL']

Coercion

Every read is coerced to the type the variable has in Envless, set in the dashboard or with envless var set --type.

Type in EnvlessPython valueExamples
stringstr'postgres://db/app'
numberint when whole, up to 2**53, otherwise float'3000' reads 3000, '0.25' reads 0.25, '1e20' reads 1e+20, 'abc' reads nan
booleanboolTrue for exactly 'true' or '1', False for anything else, 'TRUE' included

Numbers are parsed the way JavaScript's Number() parses them, so both runtimes agree on every input: surrounding whitespace is ignored, '0x1A' reads 26, '1e3' reads 1000, an empty value reads 0, and anything unparseable reads nan rather than raising. Envless does not validate values, so if you need a port that is really a port, validate it, for instance with pydantic-settings.

Nothing is memoised behind a read, so a reload or an override is visible at once.

Missing variables

Reading a name the store does not hold raises MissingVariableError with the message Missing env var: NAME. It is both an AttributeError and a KeyError, so getattr(env, 'NAME', default) and except KeyError work the way they do everywhere else in Python.

For a read that is genuinely allowed to come back empty, use optional. It is the same view with one rule relaxed: a missing name reads None. It still raises NotLoadedError before the first load, so a misconfigured process fails loudly instead of reading None everywhere.

python
from envless_env.server import env, optional

stripe_key = env.STRIPE_SECRET
sentry_dsn = optional.SENTRY_DSN

'NAME' in env answers without raising, and dir(env) lists the names, never the values.

The client view

envless_env.client sees only the variables marked client in Envless, the ones that are safe to send to a browser. Reading a server variable through it raises ServerOnlyError, and server names are hidden from in and dir().

python
from envless_env.client import env, snapshot

public_url = env.PUBLIC_API_URL
public_values = snapshot()

snapshot() returns a dict of every client variable, coerced, which is what the Django and Flask integrations hand to templates. Use it for a JSON config endpoint your frontend reads, and never build that payload from the server view. See server vs client for how visibility is set.

Typing

Without help, a type checker sees env.PORT as str | int | float | bool. python -m envless_env types generates a typed module from your variables in Envless, which is the quickest way to type every read. To type only the names you use, describe them in a Protocol and cast once.

python
from typing import Protocol, cast

from envless_env.server import env as untyped


class Env(Protocol):
    DATABASE_URL: str
    PORT: int
    DEBUG: bool


env = cast(Env, untyped)

port: int = env.PORT

The cast changes nothing at runtime, so it is only as true as the types you declare: a number variable holding 1.5 still reads 1.5. For validation as well as types, read through a pydantic-settings class with EnvlessSettingsSource instead.

Overrides in tests

env is read-only. Setting or deleting a name raises ReadOnlyError. override() layers temporary values on top of whatever was loaded, and the innermost layer wins until it is closed. It works before a load too, which is often all a unit test needs.

python
from envless_env.server import env, override

with override({'API_URL': 'http://localhost:9999'}):
    assert env.API_URL == 'http://localhost:9999'

An override always carries strings, and coercion still follows the type of the loaded variable, so a name the loader never saw reads back as a string. Outside a with block, call close() on the object override() returns.

Secrets stay inside env

The env objects hold decrypted secrets, so they refuse the operations that would copy every one of them somewhere else.

OperationResult
repr(env), str(env), an f-string, a log line<envless_env.server.env: 12 variables>, never a value
pickle.dumps(env)SerialisationError
for name in env, list(env), dict(env)An error, never a copy: SerialisationError once loaded
json.dumps(env), vars(env)TypeError
copy.copy(env), copy.deepcopy(env)env itself

This stops the accidental copy, a Django debug page or an error tracker reading repr(), a framework serialising a response. It is not a containment boundary: reading every name you know still copies the values out. When you genuinely need every value at once, use the dict that load() returns, a copy you own.