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.
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.
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_objectandfrom_envvarreintroduce a second source of values; with a settings model in place, neither is needed.- The Flask CLI loads
.envand.flaskenvwhenpython-dotenvis installed, so behaviour differs betweenflask runand any other launcher — let the model own file loading instead. SECRET_KEYmust 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.configat request time — but extensions do not, which is why the ordering rule targetsinit_app. app.configis 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, callingcreate_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=Truebreaks 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.configassignment happens before the firstinit_appin the factory. - Every key in
app.config.updateis uppercase and written literally, never derived from a model dump. SECRET_KEYis aSecretStrwith oneget_secret_value()call in the codebase..envis loaded by the model, not by the CLI, so gunicorn andflask runbehave 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.
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.