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 .env files 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, and model_dump, and requires an explicit get_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_config option that lets nested Pydantic models be populated from flat environment variables using a separator like __ (e.g. GEMINI__API_KEY populates settings.gemini.api_key).
  • lru_cachefunctools.lru_cache() applied to a get_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).

Loading diagram...

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.
  • SecretStr masks the value on print, repr, and model_dump; only get_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 ValidationError at 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 clear ValidationError.
  • for_environment selects the right .env.<name> file via the _env_file constructor parameter.
  • @lru_cache() makes get_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

  1. Do declare every secret as SecretStr — accidental print(settings) calls or structured-log payloads won't leak the value, and get_secret_value() makes intentional access auditable in code review.
  2. Do make required fields required — declare api_key: SecretStr with no default so the process fails at startup when the key is missing, not at the first user request.
  3. Do cache the settings factory with lru_cache() — environment parsing happens once per process; every module that imports get_settings() gets the same validated object.

Don'ts

  1. 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.
  2. Don't store plain str for API keys — without SecretStr, one stray print or repr in a traceback exposes the credential to every log aggregator that ingests the line.
  3. 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

All free lessons in GenAI Agent Engineering