Typed configuration from the environment, validated at start-up instead of failing at midnight.
Overview
Configuration is untrusted input
Your application needs configuration. A database address, an API key, a debug flag, a port number. These usually come from environment variables or files, and that makes them untrusted input, just like a request from a user. They can be missing, misspelled or the wrong type.
Pydantic has a companion package for this, called pydantic-settings. You describe your settings as a model, and it reads the values from the environment and validates them. The same type checks and constraints apply. A bad configuration is caught as soon as the program starts, not hours later when some code path finally needs the value.
This article explains why configuration deserves validation and why failing at start-up is the right behaviour. It covers how names map between environment variables and fields. You will see how to check values, not only type them, so that a port number really is a sensible port. It then covers nested settings for grouped options, loading from files, and secrets, which should be handled so they do not leak into logs. The result is configuration you can trust before the first request arrives.
all in this track has argued that data crossing into your program should be checked at the boundary. config is such data. It's often treated as though it isn't.
The common code is int(os.environ.get("PORT", "8000")) scattered through a code, with no central statement of what config exists, no defaults in one place. No check that anything needed is really set. A missing var turns a None that travels until a thing fails on it.
pydantic-settings applies the model discipline to it. BaseSettings is a BaseModel that reads its values from the setting.
class Settings(BaseSettings):
app_port: int = 8000
app_debug: bool = False
database_url: str
Field names map to setting vars case-insensitively. app_port reads APP_PORT. Values arrive as strings and are coerced by the plain rules. Which is just the case lax coercion was designed for.
Note app_debug: bool. The boolean vocab from the coercion module means APP_DEBUG=yes, =1, =true and =on all work. =maybe raises. That is a much better outcome than bool(os.environ.get("APP_DEBUG")). Here The string "false" is True.
Worth knowing
BaseSettings is a BaseModel that reads its values from the environment. Everything you know about models applies.
Field names map to environment variables case-insensitively; env_prefix namespaces the whole model and validation_alias pins one field.
A missing required setting fails at start-up, naming the variable — not at 3am when the code path is first taken.
Constraints and validators work, so configuration can be checked, not merely typed: a port in range, an environment from a fixed set.
Nested models come from JSON in one variable, or from env_nested_delimiter names such as REDIS__HOST.
Use SecretStr for credentials. A settings object is the thing most likely to be printed at start-up, and that is how secrets reach logs.
Settings Management: Configuration That Fails Early
Typed, checked config from the setting - and why start-up is the right place to fail.
Configuration is a boundary too
os.environ gives strings and no guarantees. A settings model reads, converts and checks in one place.
example_01.pyPydantic
import os
from pydantic_settings import BaseSettings
# The environment as your process actually sees it: strings, or nothing.
os.environ["APP_PORT"] = "8642"
os.environ["APP_DEBUG"] = "yes"
print("raw :", repr(os.environ.get("APP_PORT")),
repr(os.environ.get("APP_DEBUG")))
class Settings(BaseSettings):
app_port: int = 8000
app_debug: bool = False
app_name: str = "VizLearn"
s = Settings()
print("typed :", s)
print("port+1 :", s.app_port + 1, "(a real int)")
print("not :", not s.app_debug)
Output
Missing configuration fails at start-up
A required setting with no value raises immediately, naming the variable — rather than surfacing as an error hours later.
example_02.pyPydantic
import os
from pydantic_settings import BaseSettings
from pydantic import ValidationError
for key in ("DATABASE_URL", "SECRET_KEY"):
os.environ.pop(key, None)
class Settings(BaseSettings):
database_url: str # required
secret_key: str # required
timeout: int = 30
try:
Settings()
except ValidationError as e:
print("missing at start-up:", e.error_count())
for err in e.errors():
print(" ", err["loc"][0], "->", err["type"])
os.environ["DATABASE_URL"] = "postgres://localhost/viz"
os.environ["SECRET_KEY"] = "s3cret"
print()
print("with values:", Settings())
Output
Prefixes and explicit names
env_prefix namespaces a whole model; validation_alias pins one field to a specific variable.
example_03.pyPydantic
import os
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
os.environ["VIZ_HOST"] = "0.0.0.0"
os.environ["VIZ_PORT"] = "8642"
os.environ["DATABASE_URL"] = "postgres://localhost/viz"
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="VIZ_")
host: str = "127.0.0.1"
port: int = 8000
# This one does not follow the prefix - name it explicitly.
db: str = Field(validation_alias="DATABASE_URL")
print(Settings())
Output
Constraints and validators still apply
A settings model is a model. Everything from the earlier tiers works, which is what makes configuration checkable rather than merely typed.
example_04.pyPydantic
import os
from typing import Literal
from pydantic import Field, ValidationError
from pydantic_settings import BaseSettings
os.environ.update({"PORT": "70000", "ENV": "staging", "WORKERS": "0"})
class Settings(BaseSettings):
port: int = Field(ge=1, le=65535)
env: Literal["dev", "test", "prod"]
workers: int = Field(gt=0)
try:
Settings()
except ValidationError as e:
for err in e.errors():
print("%-8s %-22s %s" % (err["loc"][0], err["type"], err["msg"]))
os.environ.update({"PORT": "8642", "ENV": "prod", "WORKERS": "4"})
print()
print("valid:", Settings())
Output
Nested settings from one variable
A nested model can be filled from JSON in a single variable, or from delimited names.
example_05.pyPydantic
import os
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class Redis(BaseModel):
host: str = "localhost"
port: int = 6379
os.environ["REDIS"] = '{"host": "cache.internal", "port": "6380"}'
class Settings(BaseSettings):
redis: Redis = Redis()
print("from json :", Settings().redis)
# Or with a delimiter, one variable per leaf:
os.environ.pop("REDIS")
os.environ["REDIS__HOST"] = "other.internal"
os.environ["REDIS__PORT"] = "6381"
class Nested(BaseSettings):
model_config = SettingsConfigDict(env_nested_delimiter="__")
redis: Redis = Redis()
print("from parts :", Nested().redis)
Output
Secrets, and not printing them
SecretStr keeps a value out of logs and tracebacks, which matters most in the object every process prints at start-up.
example_06.pyPydantic
import os
from pydantic import SecretStr
from pydantic_settings import BaseSettings
os.environ.update({"SECRET_KEY": "sk_live_abcdef123456",
"DATABASE_URL": "postgres://user:hunter2@db/viz"})
class Unsafe(BaseSettings):
secret_key: str
database_url: str
class Safe(BaseSettings):
secret_key: SecretStr
database_url: SecretStr
print("unsafe:", Unsafe())
print()
print("safe :", Safe())
print("dump :", Safe().model_dump())
print()
print("reading it deliberately:", Safe().secret_key.get_secret_value()[:6] + "...")
Output
Failing at start-up
The single biggest gain is *when* the failure happens.
database_url: str has no default. So It's needed. If the var isn't set, making Settings() raises at once, naming the field. A deployment missing a var fails at boot, visibly, before serving anything.
The option is a None that sits quietly until the first request touching the DB. This May be minutes or hours later. Makes an error about NoneType far from the cause.
That is why settings should be instantiated once at start-up, not lazily on first use. The whole value is in failing before the process claims to be ready.
Naming
Three methods, in growing specificity.
silent.app_port reads APP_PORT. Case-insensitive.
Prefix.env_prefix="VIZ_" in SettingsConfigDict makes each field read VIZ_-prefixed vars. This is how you keep an use's config from colliding with all else in a holder.
clear.Field(validation_alias="DATABASE_URL") pins one field to one var. This Is what you need for the shared names that don't follow your prefix — DATABASE_URL, PORT, TZ.
AliasChoices works here too. This Is the clean way to accept both an old and a new var name during a migration.
Checking, not just typing
A settings model is a model. All from the earlier tiers applies. And config is a place where that matters more than people expect.
port: int = Field(ge=1, le=65535)
env: Literal["dev", "test", "prod"]
workers: int = Field(gt=0)
ENV=staging now fails at boot with a message naming the three permitted values. Without it, staging passes through the use and makes action no one intended. Some if env == "prod" was false and nothing said so.
This is the strongest arg for settings models over a config dict. Config errors are among the most common causes of production incidents. Almost all of them are typos or values outside a permitted set. Both are just what check catches.
checkers work too, for cross-field rules. "if TLS_ENABLED, then CERT_PATH must be set" is a model_validator and a truly handy one.
Nesting
Grouped config can be a nested model, filled two ways.
From JSON in one var: REDIS='{"host": "cache", "port": 6380}'.
Or from delimited names, with env_nested_delimiter="__": REDIS__HOST=cache and REDIS__PORT=6380.
The delimited form is often nicer operationally. Each value is its own var. It can be set on its own and overridden per setting without rewriting a JSON blob.
Files
env_file=".env" in SettingsConfigDict reads a dotenv file. This Is where local development config often lives. Real setting vars take precedence. A deployed process is never affected by a file that happened to ship.
There's also secrets_dir, which reads each field from a file of that name in a folder. The shape Docker and Kubernetes secrets use. Here A secret is mounted as a file rather than exposed in the setting.
The precedence order, highest first: values passed right to Settings(...), then the setting, then the dotenv file, then the secrets folder, then defaults. That is worth knowing when a value isn't what you expected.
Secrets
Use SecretStr for credentials, and the reason is exact.
A settings object is the single thing most likely to be printed. Log it at start-up to record the config. A plain str password is in your logs forever. Include it in an error report and it goes to your error tracker. repr it in a debugger session that gets pasted into a ticket, and it's in the ticket.
SecretStr displays as ********** all over and needs .get_secret_value() to read. This Makes each real access deliberate and greppable.
Note that a SecretStr in model_dump() stays hidden. A settings dump is safe to log. Which is the property you want.
One instance
Build it once and pass it around, or use a cached accessor:
Making settings reads the setting and validates. Doing that per request is wasted work. The cached function is also convenient in FastAPI. Here It can be a dependency and overridden in tests.
Avoid a module-level global made at import. It runs at import time. This Makes it awkward to test with other values and can fail before logging is configured. Making a start-up crash with no handy output.
Testing
Two habits make settings testable.
Create with clear values: Settings(app_port=1234) bypasses the setting fully. This Is what a unit test wants.
And use monkeypatch.setenv for tests that truly exercise the setting reading. Don't mutate os.environ right. The change leaks into each test that follows.
Layering environments
Most uses need the same settings with other values per setting. The clean shape uses plain class chain:
class Settings(BaseSettings):
env: Literal["dev", "test", "prod"] = "dev"
debug: bool = False
db_pool_size: int = 5
class ProdSettings(Settings):
debug: bool = False
db_pool_size: int = 20
That is better than branching on env inside the use. Each setting's config is stated in one place and can be read without tracing conditionals.
Where it goes wrong is depth. Three levels of settings class chain with replaces at each is harder to reason about than a flat model whose values come from the setting. Prefer supplying other values to the same model wherever you can. Reserve subclassing for genuine structural differences.
What belongs in settings
Not all configurable belongs here.
Yes: anything that differs between settings (URLs, credentials, pool sizes, log levels), anything secret, anything an op may need to change without a deploy.
No: use constants that never vary, feature logic dressed up as config, or anything a caller supplies per request. That is a request model.
The test is whether a value could fairly differ between your laptop and production. If it couldn't, it's a constant. Making it configurable adds a failure mode with no gain. Another var that can be unset, misspelt or set to a thing nonsensical.
Settings Management: summary
BaseSettings treats config as untrusted input crossing a boundary. This Is what it's. Field names map to setting vars, env_prefix namespaces them, validation_alias pins the exceptions.
needed fields with no default make a misconfigured deployment fail at boot with the var named, rather than hours later. Constraints and Literal catch the typos and out-of-range values behind most config incidents. SecretStr keeps credentials out of the logs a settings object is uniquely likely to reach.
Build it once, cache it, and let it refuse to start when a thing is wrong.
Mistakes people make with Settings Management
Instantiating settings lazily. The entire gain is failing at boot with the var named. Make them at start-up. A settings object built on first use turns a config error into a runtime one, hours later.
Giving all a default. A default on DATABASE_URL means a misconfigured deployment starts happily and points at the wrong DB. needed fields should be needed.
A module-level global built at import. It runs before logging is configured. A failure makes a crash with no handy output. It's awkward to test with other values. A cached accessor function is better.
**Plain str for credentials.** A settings object is the thing most likely to be logged at start-up or attached to an error report. SecretStr is the gap between that being routine and being an incident.
**env: str instead of a Literal.** ENV=staging then silently takes each else-branch in the use. Nothing anywhere says the value wasn't recognised.
**Mutating os.environ in tests.** It leaks into each test that follows and makes failures that depend on sorting. monkeypatch.setenv, or pass values right.
Configuring things that never vary. each setting is another var that can be unset, misspelt or set to a thing nonsensical. If it couldn't fairly differ between your laptop and production, it's a constant.
Testing configuration
Two habits keep settings testable.
Create with clear values where the test isn't about the setting: Settings(port=1234) bypasses reading it fully. This Is what a unit test wants and is far clearer than arranging vars around the call.
Use monkeypatch.setenv when the test truly exercises the reading, never a direct mutation of os.environ. That leaks into each test after it and makes failures that depend on sorting.
And test the failure case. A test asserting that a missing DATABASE_URL raises is worth having. It's the action the whole module exists for. It's the one no one notices has broken until a deployment comes up healthy with no DB.
A last note on start-up
There's a general principle behind this module worth stating on its own.
The best time to discover that a thing is misconfigured is before the process claims to be ready. Not on the first request, not when a code path is first taken. Not at three in the morning when the only person who knows what WORKERS should be is asleep.
A settings model turns config from a thing an use discovers bit by bit into a thing it asserts at boot. each needed var is checked, each value is converted to the type the code expects, each constrained field is inside its range. If any of that fails the process stops with a message naming just what is wrong.
That is the same arg as checking a request body, applied to the other kind of input a program takes. It's just that request bodies are obviously untrusted and config often isn't treated that way. Which is just why config errors cause so many incidents.
Questions people ask about Settings Management
What is BaseSettings? A model, from the pydantic-settings package, that fills its fields from environment variables and other sources. It validates them like any other model.
Why is it a separate package? Since v2, settings live in pydantic-settings. Install it with pip install pydantic-settings and import BaseSettings from there.
How do environment variable names map to fields? By default a field database_url reads DATABASE_URL, case-insensitively. Add env_prefix="APP_" in SettingsConfigDict to namespace them.
How do I load a .env file? Set env_file=".env" in model_config = SettingsConfigDict(...). Real environment variables take priority over the file.
How do I keep secrets out of logs? Type them as SecretStr. Printing shows asterisks, and you call .get_secret_value() when you actually need the string.
What happens when a required variable is missing? The app fails at start-up with a ValidationError naming the field, rather than failing later at the first use.
One habit
Create settings on the first line of your use's start-up, before anything else runs.
That single placement choice is what converts a class of production incident into a failed deploy. all else in this module is detail around it.
Check yourself
0 of 4
Answer without scrolling back up.
Why does a required setting with no default matter?
Failing before the process claims to be ready is the whole point. A missing variable that becomes None surfaces far from its cause, whenever that code path is first taken.
`APP_DEBUG=false` with a `bool` field gives what?
Pydantic reads the meaning of the word, unlike `bool(os.environ.get(...))` where any non-empty string is truthy. That difference is a classic configuration bug.
Why is `env: Literal["dev", "test", "prod"]` better than `env: str`?
Typos and out-of-range values cause most configuration incidents. A closed set catches them at start-up rather than letting them alter behaviour silently.
Why use `SecretStr` in a settings model specifically?
It is not encryption - it hides the value from repr, logs and tracebacks, and requires `.get_secret_value()` so every real access is deliberate.
Cheat sheet
Settings Management
Load configuration from environment variables into a typed Pydantic model that validates every value before your application starts.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.