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.
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 Envless | Python value | Examples |
|---|---|---|
string | str | 'postgres://db/app' |
number | int when whole, up to 2**53, otherwise float | '3000' reads 3000, '0.25' reads 0.25, '1e20' reads 1e+20, 'abc' reads nan |
boolean | bool | True 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.
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().
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.
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.PORTThe 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.
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.
| Operation | Result |
|---|---|
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.