All tutorials

Tutorial

pydantic-settings Config: One Source of Truth for Django, FastAPI, and Next.js

Stop scattering os.getenv calls. Learn to unify config with pydantic-settings across Django, FastAPI, and Next.js so bad config fails fast, not silently.

pythonfastapidjango

Here’s the direct answer: define your configuration once as a pydantic-settings BaseSettings class, import that same class into both Django’s settings.py and your FastAPI app, validate it at process startup so bad config crashes the app before it serves a single request, and mirror the field names and required-ness (but not the values) into Next.js’s env handling so the frontend can’t silently drift from the backend contract.

That’s the whole pattern. The rest of this article is the implementation detail and the reasoning behind each decision.

Why scattered os.getenv calls are the actual problem

Most polyglot stacks don’t fail because someone picked the wrong framework. They fail because configuration is treated as an afterthought — a grab-bag of os.getenv("DATABASE_URL") calls sprinkled across modules, each with its own silent default, its own typo-prone key name, and no single place that says “these are the twelve things this system needs to run.”

Maintaining a single, consistent source of truth for environment variables across separate Django, FastAPI, and Next.js services is a distinct architectural concern from database schema ownership, feature flagging, and logging — each of those has its own established pattern in a combined stack, and conflating them is how teams end up with config logic bolted onto migration scripts or flag-evaluation code.

This article’s scope is deliberately narrow: typed config validation and cross-service parity. It is not a secrets-management tutorial — how you inject values (vault, KMS, CI secret store, .env file on a VPS) is a separate decision from how you define and validate the shape of your config, and conflating the two leads to settings classes that know too much about deployment mechanics.

The architecture: one backend contract, two Python consumers, one frontend mirror

A typical split might look like: Django handles admin, auth, and heavier ORM-driven CRUD, FastAPI handles a leaner async API surface, and Next.js is a fully separate codebase talking to both over HTTP. That separation is exactly why config drift is so easy: three independent process boots, three independent chances to misname a variable.

FastAPI itself is built around Python type hints for request and response validation, which is precisely the mental model pydantic-settings extends to configuration — you’re not reading raw strings and hoping, you’re declaring a typed schema and letting the library coerce and reject. Django’s settings.py, meanwhile, is just a plain Python module, which means a shared, validated settings object can feed it directly.

Defining the shared settings model

Put this in a package both services can import — a shared internal library, a Git submodule, or a path-mounted package in your monorepo. The specifics of packaging are yours to choose; the shape matters more than the plumbing.

# shared_config/settings.py
from pydantic import AnyUrl, Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        env_prefix="APP_",
        extra="forbid",
    )

    # Required — no default means startup fails if missing
    database_url: AnyUrl
    secret_key: str = Field(min_length=32)

    # Optional with sane defaults
    debug: bool = False
    allowed_hosts: list[str] = Field(default_factory=lambda: ["localhost"])

    # Required, typed as a URL — malformed values are rejected, not silently accepted
    api_base_url: AnyUrl

    @field_validator("secret_key")
    @classmethod
    def secret_key_not_placeholder(cls, v: str) -> str:
        if v.lower() in {"changeme", "secret"}:
            raise ValueError("secret_key looks like a placeholder, not a real value")
        return v

settings = AppSettings()

A few things worth calling out:

  • Required vs default fields. Any field without a default (database_url, secret_key, api_base_url) must be present in the environment or .env file, or instantiation raises a ValidationError immediately. Fields with defaults (debug, allowed_hosts) are optional.
  • .env loading is declarative. SettingsConfigDict(env_file=".env") tells pydantic-settings to read a dotenv file automatically — no manual python-dotenv call needed before you instantiate the class.
  • Precedence. Actual environment variables take priority over values loaded from the .env file, which is the behavior you want in production, where your orchestrator (Docker Compose, ECS task definitions, systemd) injects real env vars that should win over a leftover local .env.
  • env_prefix namespaces your variables (APP_DATABASE_URL, APP_SECRET_KEY) so they don’t collide with unrelated env vars already present in the container or shell.
  • extra="forbid" is a deliberate strictness choice — it rejects unrecognized APP_* variables instead of silently ignoring typos, which turns “I misspelled a var name” into a startup crash instead of a mysteriously-missing feature three weeks later.

Fail fast: validation at startup, not at request time

The entire value of this pattern collapses if you instantiate AppSettings() lazily inside a request handler. Instantiate it once, at import time, at the top of your entrypoint — so a missing DATABASE_URL throws before the process finishes booting, not after it’s already accepted traffic.

# fastapi_app/main.py
from fastapi import FastAPI
from shared_config.settings import settings  # raises here if invalid

app = FastAPI(title="API", debug=settings.debug)

@app.get("/health")
def health():
    return {"database_configured": bool(settings.database_url)}

If settings = AppSettings() raises, the import fails and the process never finishes booting — you get a traceback pointing at the exact missing or malformed field, not a 500 error discovered by a user hours later. That’s the difference between a config bug caught in your deploy pipeline’s smoke test and one caught by a customer.

Wiring the same settings into Django without duplication

One approach is to import the shared instance in settings.py and assign its fields to the names Django expects — no second schema, no copy-pasted env var list.

# django_app/settings.py
from shared_config.settings import settings  # same validated instance

DEBUG = settings.debug
SECRET_KEY = settings.secret_key
ALLOWED_HOSTS = settings.allowed_hosts

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": settings.database_url.path.lstrip("/"),
        "HOST": settings.database_url.host,
        "PORT": settings.database_url.port,
        "USER": settings.database_url.username,
        "PASSWORD": settings.database_url.password,
    }
}

This is the core of “define once, consume twice.” Django and FastAPI import the identical AppSettings class and the identical validation logic runs regardless of which process boots first. There’s exactly one place that defines what DATABASE_URL means, what format it must take, and what happens when it’s missing.

This is worth contrasting with why Django and FastAPI coexist here at all: FastAPI auto-generates OpenAPI documentation and leans on type hints for request/response validation with comparatively little ceremony, while Django and Django REST Framework require more explicit, manual setup for serializers and routing — which is often exactly why teams keep Django around for admin-heavy CRUD and ORM convenience while routing latency-sensitive or AI-adjacent endpoints through FastAPI. Neither framework’s CRUD or routing philosophy has anything to do with how configuration should work — which is precisely why config deserves its own shared layer instead of being reinvented per framework.

Establishing parity with Next.js

Next.js can’t import a Python class, so “one source of truth” here means parity of contract, not a shared runtime object. Next.js is a React-based framework that supports both server-side rendering and API routes, meaning it can hold genuine server-only secrets inside its own process in addition to client-exposed values — which is exactly the distinction your config contract needs to preserve.

The rule: any variable the browser needs must be prefixed NEXT_PUBLIC_ and should be treated as public, non-sensitive data — a feature flag toggle, an analytics ID, a public API base URL. Anything else (API keys, internal service URLs, database credentials Next.js shouldn’t even have) stays unprefixed and is only readable in server-side code — getServerSideProps, API routes, or server components.

// lib/env.ts
import { z } from "zod";

const serverSchema = z.object({
  API_BASE_URL: z.string().url(),
  INTERNAL_API_KEY: z.string().min(1),
});

const publicSchema = z.object({
  NEXT_PUBLIC_API_BASE_URL: z.string().url(),
});

export const serverEnv = serverSchema.parse(process.env);
export const publicEnv = publicSchema.parse({
  NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
});

Using zod here mirrors the fail-fast behavior of pydantic-settings: .parse() throws immediately if a required var is missing or malformed, during build or at server boot, rather than letting undefined leak into a fetch call at runtime. The discipline that matters is naming parity — if your Python contract calls it api_base_url, your Next.js contract should call it API_BASE_URL / NEXT_PUBLIC_API_BASE_URL, with the same required-ness. A short comment or generated reference table listing every variable, its type, whether it’s required, and which services consume it is enough to keep three independently-deployed codebases honest without any shared runtime dependency.

Rendering choices don’t change the config contract — but they do change which vars you need

Your rendering strategy affects which environment variables Next.js needs at which phase, which is a useful lens for deciding public vs. server-only. As a general rule, values baked in at build time need to be public-safe, since they can end up in the shipped bundle, while server-only values should generally only be read in server-side code, where they’re never exposed to the browser.

What this pattern deliberately leaves out

This is config shape and validation, not secret delivery — how your DATABASE_URL or INTERNAL_API_KEY actually gets into the environment (a .env file for local dev, injected secrets in CI, a managed secret store in production) is an orthogonal decision layered on top of this contract, not part of it. It’s also not schema ownership — which service writes to which Postgres table is a migration-strategy question, not a config one. And it’s not feature flagging or logging, both of which have their own patterns in a combined Django + FastAPI + Next.js stack and deserve separate treatment rather than being folded into your settings class. Keeping those concerns apart is what keeps your AppSettings class small enough to actually read in one sitting.

FAQ

Can I use pydantic-settings inside Django if the rest of the project is pure Django (no FastAPI)? BaseSettings is a standalone library, so the same approach should work in a Django-only project too.

Does extra="forbid" ever cause problems in production? In general, using extra="forbid" is intended to make unrecognized fields raise a validation error rather than being silently ignored.

Should Next.js also use pydantic-settings? No — it’s a Python library. The goal with Next.js is contractual parity (same names, same required-ness, same public/server split), enforced with a TypeScript-side validator like zod, not literal code sharing.

Where do secrets like API keys actually live if this isn’t secrets management? Wherever your deployment pipeline puts them — a .env file locally, injected variables in your CI/CD or container orchestrator in production. This article’s settings classes validate the shape of that value once it arrives; they don’t dictate how it got there.