Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 42

Pydantic BaseSettings for Environment Management

Recommended resources

Hello! Welcome back to our course on mastering FastAPI.

In our last lesson, we designed a scalable folder structure, separating concerns into routers, schemas, services, and repositories. A crucial piece of that structure was a core/config.py file, meant to handle application settings. Today, we'll build on that foundation and focus on this exact topic.

This lesson addresses the learning outcome: Manage environment variables and application settings using Pydantic's BaseSettings.

You'll learn the modern, type-safe way to manage configuration in FastAPI. This is a critical skill for building applications that are secure, configurable across different environments (like development, testing, and production), and easy to maintain.

For you, coming from Django, this entire concept is analogous to Django's settings.py file. However, instead of manually using libraries like python-decouple or django-environ to parse environment variables, you'll see how FastAPI leverages Pydantic to do this with automatic type-checking and validation, which is a significant advantage.

The Problem with Hardcoded Settings

In any real-world application, you have configuration values that change between environments:

  • Database URLs: You'll use a local PostgreSQL database for development but a managed one in production.
  • API Keys: Your Stripe key for testing is different from your live key.
  • Debug Flags: You want DEBUG=True locally but False in production.
  • Secret Keys: These should never be stored in your version control system (like Git).

Hardcoding these values is insecure and inflexible. The industry standard, inspired by the Twelve-Factor App methodology, is to store configuration in environment variables. However, environment variables are just simple strings, which means your Python code needs to:

  1. Read the string value.
  2. Convert it to the correct type (e.g., str to int or bool).
  3. Validate that the value is correct.

This is where Pydantic's settings management shines.


Introducing pydantic-settings

FastAPI recommends using the pydantic-settings library to manage configuration. It provides a BaseSettings class that inherits all the power of Pydantic models but is specifically designed to read values from environment variables.

Let's dive into how it works.

Settings and Environment Variables - FastAPI

The official FastAPI documentation provides a clear introduction to why we need external settings and how to create a basic Settings object using Pydantic's BaseSettings.

Please read the initial sections of the documentation, from the start down to 'Settings in another module'. This will cover: The rationale for using environment variables. How to install pydantic-settings. The basic structure of a class inheriting from BaseSettings. How to create a separate config.py module for your settings.

As the documentation explains, you define your configuration as a class that inherits from BaseSettings. Pydantic then automatically and case-insensitively tries to populate the class attributes from environment variables.


Creating and Using Your First Settings Class

The following video provides a great hands-on demonstration of the core concepts. You'll see how to define a Settings class, set default values, and override them with environment variables.

pydantic-settings - Modern, Type-Safe Configuration for Python Apps

Watch this segment from the 'pydantic-settings' tutorial by BugBytes to see the concepts from the documentation put into practice.

Watch from the beginning until 05:45. Focus on: How a config.py file is created. Defining a Settings class that inherits from BaseSettings. Adding attributes with type hints and default values (e.g., app_name: str = "..."). How an environment variable (e.g., APP_NAME=new_name) automatically overrides the default value when the application starts.

Let's solidify this with a code example that mirrors what you just saw. Within your project structure from the last lesson, you'd create app/core/config.py:




# app/core/config.py

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    APP_NAME: str = "My Awesome FastAPI App"
    ADMIN_EMAIL: str
    DEBUG_MODE: bool = False




# Create a single, reusable instance of the settings
settings = Settings()

And in your main.py or a router file, you can import and use it:




# app/main.py

from fastapi import FastAPI
from app.core.config import settings

app = FastAPI()

@app.get("/info")
def get_app_info():
    return {
        "app_name": settings.APP_NAME,
        "admin_email": settings.ADMIN_EMAIL,
        "debug_mode": settings.DEBUG_MODE,
    }

To run this, you would set the required ADMIN_EMAIL environment variable:
ADMIN_EMAIL="admin@example.com" uvicorn app.main:app --reload

Notice that DEBUG_MODE would automatically be False unless you explicitly set DEBUG_MODE=True in the environment. Pydantic handles the string-to-boolean conversion for you.


Loading Settings from a .env File

Managing multiple environment variables in the command line is fine for one or two, but it quickly becomes cumbersome for local development. A common practice is to store them in a .env file (which should always be added to your .gitignore file!).

pydantic-settings can automatically read from a .env file. You just need to tell it where to look.

pydantic-settings - Modern, Type-Safe Configuration for Python Apps

Let's return to the BugBytes video to see how to configure our Settings class to read from a .env file.

Watch the segment from 09:33 to 13:26. Pay attention to: The format of a .env file (key-value pairs). Adding a model_config attribute to the Settings class. Using SettingsConfigDict to specify the env_file.

Here's how you'd update your config.py:




# app/core/config.py

from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    APP_NAME: str = "My Awesome FastAPI App"
    ADMIN_EMAIL: str
    DEBUG_MODE: bool = False




    # This tells pydantic to load variables from a .env file
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

settings = Settings()

Now, you can create a .env file in your project's root directory:




# .env

APP_NAME="My Dev App"
ADMIN_EMAIL="dev@example.com"
DEBUG_MODE=True

When you run uvicorn app.main:app --reload, Pydantic will load these values automatically. The order of priority is:

  1. Arguments passed directly to the Settings initializer (e.g., Settings(APP_NAME="...")).
  2. Environment variables.
  3. Values from the .env file.
  4. Default values defined in the class.

Advanced Validation and Special Types

The real power of pydantic-settings comes from leveraging Pydantic's rich ecosystem of validation types. Since environment variables are just strings, this ensures your application receives clean, validated, and correctly-typed data from the start.

The next video segments demonstrate several powerful types you can use in your Settings class.

pydantic-settings - Modern, Type-Safe Configuration for Python Apps

This video covers several advanced Pydantic types that are extremely useful for configuration.

Watch from 05:45 to 09:33, and then from 13:26 to 18:16. You will learn about: EmailStr: For validating email formats. SecretStr: A special type for sensitive data like API keys or passwords, which prevents their values from being accidentally logged or printed. Using Annotated with Field for numeric validation (e.g., ensuring a port is within a valid range). Network DSN types like PostgresDsn for validating database connection strings.

Here is a more robust Settings class using these types:




# app/core/config.py

from pydantic import EmailStr, PostgresDsn, Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):



    # Application settings
    APP_NAME: str = "My Awesome FastAPI App"
    ADMIN_EMAIL: EmailStr
    DEBUG_MODE: bool = False




    # Database settings
    DATABASE_URL: PostgresDsn




    # API keys and secrets
    SECRET_KEY: str

    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

settings = Settings()

If you provided an invalid email for ADMIN_EMAIL or a malformed DATABASE_URL, your application would fail to start with a clear validation error—a huge benefit for catching configuration mistakes early.

The image below offers a few more advanced tips for organizing your environment variables, such as using prefixes for grouping related settings.

Pydantic and FastAPI Features for Application Lifecycle and Configuration
This diagram shows three useful tips for `pydantic-settings`: 1. Using `env_prefix` to group related variables (e.g., `API_V1_...`), 2. Using `nested_delimiter` to structure nested configurations, and 3. Using Pydantic's built-in network types like `PostgresDsn` for robust validation of connection strings.

An Alternative: Settings as a Dependency

So far, we've created a global settings object that is imported everywhere. This works perfectly well. However, FastAPI's Dependency Injection system offers an alternative pattern that can make testing even easier.

Instead of creating the instance in config.py, we can create a dependency that yields the settings object.

Settings and Environment Variables - FastAPI

The FastAPI documentation explains this alternative pattern, which is particularly useful for testing.

Please read the sections 'Settings in a dependency' and 'Creating the Settings only once with lru_cache'. Focus on: The concept of a get_settings() function that acts as a dependency. The use of Python's built-in @lru_cache decorator. This is a crucial optimization to ensure the .env file is read only once, not on every request. How this pattern makes it easy to override settings during tests.

Here is the pattern in practice.

First, your config.py would only define the class:




# app/core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    APP_NAME: str = "My Awesome FastAPI App"



    # ... other settings
    model_config = SettingsConfigDict(env_file=".env")

Then, you could create a dependencies.py file or add this to your main.py:




# app/dependencies.py
from functools import lru_cache
from typing import Annotated
from fastapi import Depends
from app.core.config import Settings

@lru_cache
def get_settings():
    return Settings()




# Type alias for the dependency
AppSettings = Annotated[Settings, Depends(get_settings)]

Now, in your path operations, you inject the settings instead of importing them:




# In a router file
from app.dependencies import AppSettings

@router.get("/info")
def get_app_info(settings: AppSettings):
    return {"app_name": settings.APP_NAME}

Django Analogy: This pattern makes testing much cleaner. In Django tests, you often have to use override_settings from django.test or a similar Pytest fixture to patch the global settings object. With FastAPI's DI, you can use app.dependency_overrides to provide a different Settings object for your tests, which is a more explicit and contained approach.


Conclusion

You now have a powerful and flexible system for managing your application's configuration. This approach is not only the standard for FastAPI but also promotes best practices for security and maintainability.

Key Takeaways:

  • Use pydantic-settings: Create a class inheriting from BaseSettings to define your application's configuration.
  • Leverage Type Hinting: Pydantic automatically validates and converts environment variables to the types you declare, catching errors at startup.
  • Use .env for Development: Configure your Settings class with model_config to read from a .env file for local development convenience. Remember to add .env to .gitignore.
  • Protect Secrets: Use special types like SecretStr to prevent sensitive data from being exposed in logs or tracebacks.
  • Consider Dependency Injection: For enhanced testability, consider providing your settings via a dependency decorated with @lru_cache.

Preview of the Next Lesson:

Now that we have a solid project structure and a robust configuration system, we can start building out the core logic of our application. In the next lesson, we will implement the repository pattern to decouple database logic from business logic. This is the next step in creating the clean, layered architecture we discussed previously.