Free lesson · GenAI Agent Engineering
Configure agents with Pydantic Settings
You can load configuration from a specific .env file, hide secrets behind SecretStr, and read nested environment variables (e.g. GEMINI__API_KEY) into structured settings models.
Course: GenAI Agent Engineering · Chapter 4 · The Data Validator
Free to read — no subscription required.
Introduction
When you ship an agent that calls a hosted LLM, every API key, model name, timeout, and rate limit must be configurable from the deployment environment — not hardcoded in source. Teams that scatter os.environ.get(...) calls and untyped config dicts across modules eventually leak a key into a log line, deploy with a missing variable that fails at the first user request, or silently fall back to wrong defaults in production. Pydantic Settings collapses this risk into a single typed model that reads from .env files and the process environment, validates types at startup, and masks secrets when the object is logged. By the end of this lesson you'll be able to define a BaseSettings model for an agent, load API keys safely with SecretStr, and select environment-specific configuration without sprinkling environment-variable lookups through your code.
Key Terminology
- BaseSettings — the Pydantic class that reads field values from the environment and
.envfiles at instantiation, applying type coercion and validation. It is the entry point for every configured agent in this lesson. - SecretStr — a Pydantic type that wraps a string, masks it on
print,repr, andmodel_dump, and requires an explicitget_secret_value()call to read the underlying value. Used for API keys and passwords so they never leak through logs. - alias — a
Field(alias=...)argument that maps a Python attribute name (gemini_api_key) to an environment-variable name (GEMINI_API_KEY), letting you keep snake_case in code while reading SCREAMING_SNAKE from the environment. - env_nested_delimiter — a
model_configoption that lets nested Pydantic models be populated from flat environment variables using a separator like__(e.g.GEMINI__API_KEYpopulatessettings.gemini.api_key). - lru_cache —
functools.lru_cache()applied to aget_settings()factory ensures the settings object is constructed once per process, so file I/O and environment parsing happen at startup rather than on every call.
Concepts
Type-Safe Configuration with BaseSettings
A BaseSettings subclass declares the agent's configuration as typed attributes. At instantiation, Pydantic populates each attribute by reading the environment, then a .env file, then the field's default. Coercion is automatic — port: int = 8080 parses PORT=9000 into the integer 9000 and raises a ValidationError if PORT=abc. A misspelled environment variable, a missing required key, or a wrong-typed value fails at process startup with a clear error, not as a KeyError halfway through the first inference call (see Code Walkthrough).
Secret Handling with SecretStr
API keys, OAuth tokens, and database passwords leak when they appear in logs, error tracebacks, or model_dump() output. Declaring a field as SecretStr ensures every accidental serialization renders **********; only an explicit get_secret_value() call returns the underlying string. This is defense in depth — a misconfigured logger, a Sentry breadcrumb, or a debug print(settings) will not exfiltrate the key (see Code Walkthrough).
Environment-Specific Settings
Development, staging, and production almost always need different log levels, timeouts, mock toggles, and provider endpoints. The _env_file constructor parameter on BaseSettings lets a single class load from .env.development or .env.production, and a Literal["development", "staging", "production"] field rejects typos at startup. Wrapping the factory in lru_cache() guarantees the settings object is built once per process, so the cost of reading the environment is paid at boot, not on every request (see Code Walkthrough).
Code Walkthrough
Now that you've seen type-safe configuration with BaseSettings, secret handling with SecretStr, and environment-specific settings, this walkthrough turns them into working code.
The first snippet shows the foundational pattern — a BaseSettings subclass that loads provider keys as SecretStr and applies aliases to read SCREAMING_SNAKE environment variables. The second snippet adds environment selection and a cached factory so the same class can serve development, staging, and production without duplicated configuration code.
Code snippetpython
1from pydantic import Field, SecretStr 2from pydantic_settings import BaseSettings 3 4class AgentSettings(BaseSettings): 5 """Agent configuration loaded from environment and .env.""" 6 7 # Provider keys are SecretStr so they never leak through logs or model_dump 8 gemini_api_key: SecretStr = Field(alias='GEMINI_API_KEY') 9 openai_api_key: SecretStr = Field(alias='OPENAI_API_KEY') 10 anthropic_api_key: SecretStr = Field(alias='ANTHROPIC_API_KEY') 11 12 # Model defaults — overridable per deployment 13 default_model: str = "gemini-2.0-flash" 14 default_temperature: float = 0.7 15 default_max_tokens: int = 4000 16 17 # Rate limits 18 max_concurrent_calls: int = 10 19 rate_limit_per_minute: int = 60 20 21 model_config = { 22 'env_file': '.env', 23 'env_file_encoding': 'utf-8', 24 'extra': 'ignore', 25 } 26 27settings = AgentSettings() 28print(settings.gemini_api_key) # SecretStr('**********') 29print(settings.model_dump()) # secrets stay masked here too 30real_key = settings.gemini_api_key.get_secret_value()
Field(alias='GEMINI_API_KEY')maps the snake_case attribute to the SCREAMING_SNAKE env var.SecretStrmasks the value onprint,repr, andmodel_dump; onlyget_secret_value()exposes the underlying string.extra='ignore'lets unrelated env vars (e.g.PATH,HOME) coexist without raising.- A missing required key or a wrong-typed value raises
ValidationErrorat instantiation, not at first request.
Code snippetpython
1from functools import lru_cache 2from typing import Literal 3import os 4 5from pydantic_settings import BaseSettings 6 7class AgentSettings(BaseSettings): 8 environment: Literal["development", "staging", "production"] 9 log_level: str = "INFO" 10 api_timeout: int = 30 11 mock_llm_responses: bool = False 12 13 @classmethod 14 def for_environment(cls, env: str) -> "AgentSettings": 15 return cls(_env_file=f".env.{env}") 16 17 @property 18 def is_production(self) -> bool: 19 return self.environment == "production" 20 21@lru_cache() 22def get_settings() -> AgentSettings: 23 env = os.getenv("ENVIRONMENT", "development") 24 return AgentSettings.for_environment(env)
Literal[...]rejects unknown environment names at startup with a clearValidationError.for_environmentselects the right.env.<name>file via the_env_fileconstructor parameter.@lru_cache()makesget_settings()build the object once per process; every module that imports it gets the same instance.
You'll know it works when calling get_settings() with ENVIRONMENT=production returns a settings object whose is_production is True, every secret field prints as **********, and a deliberate misspelling like ENVIRONMENT=prod fails immediately with a ValidationError instead of running silently with wrong defaults.
Do's and Don'ts
Having walked through the material above, the following Do's and Don'ts distill it into practice.
Do's
- ✓Do declare every secret as
SecretStr— accidentalprint(settings)calls or structured-log payloads won't leak the value, andget_secret_value()makes intentional access auditable in code review. - ✓Do make required fields required — declare
api_key: SecretStrwith no default so the process fails at startup when the key is missing, not at the first user request. - ✓Do cache the settings factory with
lru_cache()— environment parsing happens once per process; every module that importsget_settings()gets the same validated object.
Don'ts
- ✗Don't sprinkle
os.environ.get(...)across modules — type information is lost, defaults drift, and there is no single place to validate the configuration shape at startup. - ✗Don't store plain
strfor API keys — withoutSecretStr, one strayprintorreprin a traceback exposes the credential to every log aggregator that ingests the line. - ✗Don't mutate a cached settings object after startup — downstream code assumes immutability; if you need a per-request override, pass an explicit argument instead of patching the global.
This lesson is free to read. Its hands-on lab — real code, in a cloud IDE — is part of the GenAI Agent Engineering subscription.
From · cancel anytime
More free lessons in GenAI Agent Engineering
- Ch 1Isolate your agent's Python environment
- Ch 1Secure API key management
- Ch 4Write custom Pydantic validators
- Ch 4Parse LLM output into Pydantic models
- Ch 4Configure agents with Pydantic SettingsYou are here
- Ch 11Practical use cases — security, parameters, observability
- Ch 15Use Pydantic for tool schemas