Flask Config from Environment and .env Files

Flask’s configuration object is a dictionary with two rules that are easy to miss and expensive to break: only uppercase keys count, and extensions read it at the moment they initialise. A team that assigns configuration after init_app, or that writes a key in lowercase, gets an application that starts cleanly, passes its tests, and uses an extension’s default for something that was supposed to be configured. This page wires a validated settings model into a Flask factory so neither mistake is possible, extending the framework integration patterns.

Problem 1: configuration assigned after extensions initialise

# app.py — ANTI-PATTERN
def create_app():
    app = Flask(__name__)
    db.init_app(app)                                    # reads app.config right here
    app.config["SQLALCHEMY_DATABASE_URI"] = database_url  # too late
    return app

Nothing raises. The extension read app.config during init_app, found no database URI, and fell back to its own default — which for several popular extensions means an in-memory or file-backed SQLite database. The application starts, serves requests, and writes to a database nobody intended to exist. In development that looks like “the data disappears on restart”; in production it looks like a service that appears healthy while the real database receives nothing.

The ordering rule is absolute and worth stating as a review checkpoint: everything that populates app.config happens before the first init_app call in the factory. Because the settings model is already constructed at import, populating app.config is a handful of assignments at the top of the function, which makes the correct order the natural one to write.

Extension initialisation reads app.config once An extension initialised before configuration is assigned reads an empty config and falls back to its default, while configuration assigned first is picked up correctly. init_app first init_app reads {} config assigned extension default wins silently config first config assigned init_app reads values your values win as intended the same two lines, in the other order, change which database you write to
Extensions read configuration exactly once — order in the factory is a correctness property.

Problem 2: lowercase keys that do nothing

# ANTI-PATTERN
app.config.update(secret_key="…", sqlalchemy_database_uri="…")   # stored, never read

Flask’s config object treats uppercase names as configuration; lowercase keys are accepted and stored but no extension will look for them. The result is identical to the ordering bug — a default silently in force — but with an even smaller clue, because the value is visibly present in app.config when you inspect it. Somebody debugging will see secret_key sitting right there and conclude the configuration is fine.

The defence is to write assignments as explicit keyword arguments in uppercase, which is both readable and greppable, and to avoid programmatically deriving key names from a model’s field names. A loop like app.config.update({k.upper(): v for k, v in settings.model_dump().items()}) looks elegant and is a trap: it injects everything, including fields Flask has its own meaning for, and it will happily unwrap or fail to unwrap secrets depending on how the dump was configured.

Problem 3: .env loading that depends on how you start the app

# works under the Flask CLI, does nothing under gunicorn
flask run          # the CLI loads .env for you
gunicorn app:app   # nothing loads .env — different configuration, same code

The Flask CLI loads .env files for commands it runs, which is convenient and creates one of the most confusing failure modes in Flask deployment: the application behaves differently depending on which process manager started it. Local development uses the CLI and works; a container runs gunicorn and gets defaults.

Making the settings model own file loading — through its env_file setting — removes the dependency on the launcher entirely. The same code loads the same sources under flask run, under gunicorn, under a shell, and inside a test. That property is worth more than the small convenience of the CLI’s automatic loading, and it is the reason the model rather than the framework should be the thing that reads files.

Secure implementation

# config.py — one schema, constructed at import
from pydantic import SecretStr, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="forbid")

    environment: str = "local"
    secret_key: SecretStr                 # 1. required: no default that could ship
    database_url: PostgresDsn
    session_cookie_secure: bool = True    # 2. secure by default; local overrides it
    debug: bool = False


settings = Settings()
# app.py — the factory copies resolved values, in uppercase, before anything initialises
from flask import Flask
from config import settings
from extensions import db, migrate


def create_app() -> Flask:
    app = Flask(__name__)

    # 3. every configuration assignment happens first, and every key is uppercase
    app.config.update(
        SECRET_KEY=settings.secret_key.get_secret_value(),   # 4. the one unwrap point
        SQLALCHEMY_DATABASE_URI=str(settings.database_url),
        SQLALCHEMY_TRACK_MODIFICATIONS=False,
        SESSION_COOKIE_SECURE=settings.session_cookie_secure,
        SESSION_COOKIE_HTTPONLY=True,
        DEBUG=settings.debug,
    )

    # 5. only now do extensions read the config
    db.init_app(app)
    migrate.init_app(app, db)

    from views import bp
    app.register_blueprint(bp)
    return app

The factory contains no logic worth testing, which is the goal. It does not decide anything, it does not fall back, and it does not read the environment — it translates an already-validated object into Flask’s vocabulary. That means a test can call create_app() with a substituted settings object and get a fully configured application without setting a single environment variable, and it means the answer to “where does this value come from?” is always the model.

Note SESSION_COOKIE_SECURE defaulting to True in the model rather than in the factory. Security-relevant defaults belong in the schema, where they are visible alongside the field and where an environment that needs to relax one has to say so explicitly. A default buried in the factory is a decision nobody reviews.

The application factory as a translation step The validated settings model is translated into uppercase app.config keys at the top of the factory, after which extensions initialise and blueprints register. Settings validated at import app.config.update uppercase keys only init_app extensions read blueprints registered no step reads the environment; the model already did
A one-way pipeline: validate, translate, initialise, register.

Testing a factory that takes its configuration from a model

The factory pattern earns most of its reputation from testing, and moving configuration into a model sharpens the benefit. Because create_app() reads no environment of its own, a test can build an application with any configuration by substituting the settings object — no monkeypatch.setenv calls, no .env file in the test directory, no dependence on what the developer’s shell exports. The cleanest form takes the settings object as an optional argument that defaults to the module-level instance, which keeps production usage unchanged while giving tests an explicit seam.

# app.py — one optional argument turns the factory into a testable unit
def create_app(config: Settings | None = None) -> Flask:
    config = config or settings          # production passes nothing; tests pass an object
    app = Flask(__name__)
    app.config.update(
        SECRET_KEY=config.secret_key.get_secret_value(),
        SQLALCHEMY_DATABASE_URI=str(config.database_url),
        SESSION_COOKIE_SECURE=config.session_cookie_secure,
    )
    ...

With that seam in place, a test that needs a different database, a relaxed cookie policy or a feature flag flipped constructs a Settings with explicit values and passes it in. Each test gets a fresh application object, so nothing leaks between tests, and the assertions can target app.config directly — verifying, for instance, that a production-shaped configuration produces SESSION_COOKIE_SECURE=True without ever starting a server. That kind of assertion is genuinely useful: it catches a security default being weakened in a refactor, which is otherwise the sort of change that passes review because the diff looks innocuous.

The one thing worth testing about the factory itself is ordering, and it is easier than it sounds. Initialise an extension whose configured value differs visibly from its default, build the app, and assert the extension sees your value. If somebody later moves an init_app call above the configuration block, that test fails immediately and points at the exact regression, which is far better than discovering it when a staging environment turns out to have been writing to a local file for two weeks. One test of this kind per application is enough — it is the ordering rule you are protecting, not each individual extension.

Gotchas and version-specific behaviour

  • Only uppercase keys are configuration. Lowercase keys are stored and ignored, with no warning.
  • app.config.from_object and from_envvar reintroduce a second source of values; with a settings model in place, neither is needed.
  • The Flask CLI loads .env and .flaskenv when python-dotenv is installed, so behaviour differs between flask run and any other launcher — let the model own file loading instead.
  • SECRET_KEY must be set before the first session is signed; a missing key raises only when something touches the session, which can be well after startup.
  • Blueprints registered before configuration is assigned still work, because they read current_app.config at request time — but extensions do not, which is why the ordering rule targets init_app.
  • app.config is a plain dictionary at runtime, so a typo in a key name is not an error anywhere — the only protection is writing keys literally and keeping the set small.
  • Under pytest, calling create_app() per test is cheap and gives isolation; the settings model, being module-level, is shared, so substitute it explicitly rather than mutating it.
  • SESSION_COOKIE_SECURE=True breaks plain-HTTP local development, which is exactly why it should be a model field with an environment-specific value rather than a constant.

Production parity checklist

  • Every app.config assignment happens before the first init_app in the factory.
  • Every key in app.config.update is uppercase and written literally, never derived from a model dump.
  • SECRET_KEY is a SecretStr with one get_secret_value() call in the codebase.
  • .env is loaded by the model, not by the CLI, so gunicorn and flask run behave identically.
  • Security-relevant defaults live in the model, and relaxing one for local development is an explicit value.
  • CI constructs the settings model and calls create_app() against the target environment’s variables, so a missing key fails the pipeline rather than the container.
  • One test asserts an extension sees a configured value rather than its default, protecting the ordering rule against future refactors.
Two silent Flask configuration failures and their checks A lowercase key and a late assignment both leave an extension on its default; the checks are an uppercase literal key and configuration before init_app. lowercase key stored in app.config, read by nothing assignment after init_app extension already took its default write keys literally, uppercase greppable, reviewable, no derivation configure at the top of the factory one block, before any extension
Both failures are silent, and both are prevented by a habit rather than a test.

Frequently asked questions

Why does Flask ignore my lowercase config key?

Flask’s config object only exposes uppercase keys as configuration. A lowercase key is stored but no extension will ever look for it, so the extension quietly uses its own default and nothing warns you. This is particularly confusing during debugging because the value is visible in app.config — it simply is not the key anything reads. Writing keys as literal uppercase keyword arguments makes the mistake hard to commit, and it also means a grep for SQLALCHEMY_DATABASE_URI finds the one place it is set rather than a dynamic expression that happens to produce it.

Should I use app.config.from_object or a settings model?

Use a settings model and copy resolved values into app.config. from_object reads whatever attributes an object happens to have, with no schema, no types and no failure when something is missing — it is a convention rather than a contract. With a model you get required fields, parsed types, cross-field validation and a ValidationError naming the field, all before Flask is involved. Keeping from_object alongside a model is worse than either alone, because it gives you two ways for a value to arrive and no defined precedence between them.

Where in the factory should configuration go?

At the very top, before any extension’s init_app call. Extensions read app.config when they initialise, so a value assigned afterwards has no effect on the already-configured extension. The practical shape is: create the app, one app.config.update(...) block, then extensions, then blueprints. If a value genuinely cannot be known until later — something derived from a database, for instance — that is not configuration in the sense Flask means, and it belongs in application state rather than in app.config.

Does Flask load .env files automatically?

The Flask CLI loads them for commands run through it when python-dotenv is installed, which is why an app can work under flask run and fail under gunicorn. Let the settings model own file loading through its env_file setting so behaviour is identical under every process manager, in tests, and in a shell. This also keeps the file as a purely local convenience: deployed environments inject variables, the file is absent, and the model’s source order handles that case without any change in code.

Key takeaways

Flask’s two configuration rules — uppercase keys only, and extensions read once at init_app — are both silent when broken, which makes them worth encoding as habits rather than hoping to catch in review. Populating app.config from a validated settings model at the top of the factory satisfies both by construction, and reduces the factory to a translation step with no decisions of its own.

None of this requires a large refactor: the factory shrinks, the model absorbs what it used to do, and the tests get shorter because they stop configuring an environment. The .env question is the third piece. Letting the model rather than the Flask CLI load files means the application behaves the same under every launcher, which removes the “works locally, defaults in the container” class of surprise entirely. For the equivalent patterns in Django, FastAPI and worker processes, see the framework integration topic this page belongs to.