My Flask tests pass one at a time, and fail together
Quick recapDecorators· 2 min
A decorator wraps a function in another. @login_required above def notes(): means notes = login_required(notes): calls go through the wrapper first. functools.wraps copies the name and docstring across,
so the wrapped function still looks like itself.
See the problem
Section titled “See the problem”The Flask Notes API keeps its settings in one module-level dict, CONFIG, and builds one app when the module is imported. Each test changes what it needs: one sets DATABASE_URL to an in-memory database, another sets PAGE_SIZE to 5.
Each test passes when run alone. Run the whole file and three fail: the page-size test sees 5 notes a page in a test that expects 20. Worse, a test once wrote to the real notes.sqlite3, because the setting it relied on had been changed back by another test. On the server, PAGE_SIZE=50 from the environment arrives as the text "50", and DEBUG=false turns debug on.
Define the task
Section titled “Define the task”Write make_config(environ, overrides): the settings for one new app.
- Input: the environment variables (a dict of text), and settings from the caller, like a test.
- Output: a new dict of settings, with the right Python types.
- The rules: start from the defaults, then the environment, then the overrides; later wins.
DEBUGis on only for1,true,yesoron. In production, refuse to start with the default secret key. - The hard part: every call must get its own settings. Nothing one app does may show up in the next.
Example: make_config({"PAGE_SIZE": "50"}, {})["PAGE_SIZE"] is the number 50.
Pause and think
Section titled “Pause and think”Hints open one at a time:
Hint 1 of 3
A module runs once per process. Its CONFIG dict is one object that every test shares.
Hint 2 of 3
If each app made its own settings dict, a change to one couldn’t reach the others.
Hint 3 of 3
That is what an app factory is: a function that builds a new app, with new settings, each time you call it.
Understand the idea
Section titled “Understand the idea”Flask is small on purpose. It gives you routing, a request and response, sessions, templates (Jinja2) and a test client. You choose the rest: SQLAlchemy for the database, Flask-Migrate for migrations, and so on. The runnable Notes API is in examples/flask/. Start it with ./run.sh examples flask, run.bat examples flask or docker compose up flask.
Routes map a URL and method to a view function. <int:pk> in the rule turns that part of the path into an argument:
@bp.route("/notes/<int:pk>/", methods=["GET", "PATCH", "DELETE"])@login_requireddef note_detail(pk: int): ...Contexts make request, session, g and current_app work without passing them around. For each request, Flask pushes an app context (current_app, and g, a scratch space for this request) and a request context (request, session). They are popped when the request ends. Outside a request, like in a script, open one with with app.app_context():.
Blueprints group routes, like a Django app. bp = Blueprint("api", __name__) holds the API’s routes and hooks; the app registers it with url_prefix="/api".
The app factory builds a new app each time. create_app(settings) makes the Flask object, loads config, sets up the database and registers blueprints. Tests call it with their own settings; run.py calls it with real ones. This is the fix for the leak above.
SQLAlchemy and Flask meet in g. get_db() opens a session on first use and keeps it in g.db; a teardown_appcontext function closes it, even if the view raised. The third practice problem builds this.
Jinja2 templates escape by default. render_template("note.html", note=note) fills {{ note.title }} with < turned into <. The Notes API returns JSON instead, with jsonify.
Test with the test client. app.test_client().post("/api/notes/", json={...}) runs the whole request in the same process, no server needed.
POST /api/notes/ in Flask
Parts:
- Browser (the request is here)
- WSGI server
- Flask app (from create_app)
- before_request: CSRF
- View: api.notes
- g.db session
Arrows:
- Browser sends requests to WSGI server
- WSGI server sends requests to Flask app (from create_app)
- Flask app (from create_app) sends requests to before_request: CSRF
- before_request: CSRF sends requests to View: api.notes
- View: api.notes sends requests to g.db session
Step 1 of 7. The browser sends POST /api/notes/ with JSON, the session cookie and an X-CSRFToken header.
Climb the ladder
Section titled “Climb the ladder”Three versions of make_config. The tests build several apps in a row, and read settings from the environment.
A fresh copy, converted by type
The idea: Copy the defaults for each call; convert environment text with the default’s type.
The waste in Okay: it shared one dict across every app.
Highlighted lines are new in this rung.
DEFAULTS = { "ENV": "development", "DEBUG": False, "SECRET_KEY": "dev-only-key", "DATABASE_URL": "sqlite:///notes.sqlite3", "PAGE_SIZE": 20,}
def make_config(environ: dict[str, str], overrides: dict) -> dict: config = dict(DEFAULTS) # a fresh copy for every app for name, default in DEFAULTS.items(): if name in environ: config[name] = type(default)(environ[name]) # "50" -> 50, "false" -> True! config.update(overrides) return config- Leaks between apps
Nowas Yes- PAGE_SIZE=50
The number 50was The text 50- Why
- Each call builds its own dict, so apps can’t see each other.
What changed from the previous rung
CONFIG = {DEFAULTS = { "ENV": "development", "DEBUG": False, "SECRET_KEY": "dev-only-key", "DATABASE_URL": "sqlite:///notes.sqlite3", "PAGE_SIZE": 20,}
def make_config(environ: dict[str, str], overrides: dict) -> dict: for name in CONFIG: # one shared dict, changed in place config = dict(DEFAULTS) # a fresh copy for every app for name, default in DEFAULTS.items(): if name in environ: CONFIG[name] = environ[name] CONFIG.update(overrides) return CONFIG config[name] = type(default)(environ[name]) # "50" -> 50, "false" -> True! config.update(overrides) return configWhere it breaks: bool("false") is True, because any non-empty text is. So DEBUG=false turns debug on, which shows code and settings to anyone in production. And nothing stops production from using the public default key.
Parse each type, and refuse unsafe production settings
The idea: Read booleans from a set of yes-words, ints with int(), and raise if production still has the default secret key.
The waste in Better: it trusted Python’s idea of “true”.
Highlighted lines are new in this rung.
DEFAULTS = { "ENV": "development", "DEBUG": False, "SECRET_KEY": "dev-only-key", "DATABASE_URL": "sqlite:///notes.sqlite3", "PAGE_SIZE": 20,}
def parse(text: str, default: object) -> object: if isinstance(default, bool): return text.strip().lower() in {"1", "true", "yes", "on"} if isinstance(default, int): return int(text) return text
def make_config(environ: dict[str, str], overrides: dict) -> dict: config = dict(DEFAULTS) for name, default in DEFAULTS.items(): if name in environ: config[name] = parse(environ[name], default) config.update(overrides) # tests and run.py have the last word if config["ENV"] == "production" and config["SECRET_KEY"] == DEFAULTS["SECRET_KEY"]: raise RuntimeError("Set SECRET_KEY in production: the default one is public.") return config- Leaks between apps
No- PAGE_SIZE=50
The number 50- Why
- Every value has the right type, and a dangerous setup stops at start, not after a leak.
What changed from the previous rung
DEFAULTS = { "ENV": "development", "DEBUG": False, "SECRET_KEY": "dev-only-key", "DATABASE_URL": "sqlite:///notes.sqlite3", "PAGE_SIZE": 20,}
def parse(text: str, default: object) -> object: if isinstance(default, bool): return text.strip().lower() in {"1", "true", "yes", "on"} if isinstance(default, int): return int(text) return text
def make_config(environ: dict[str, str], overrides: dict) -> dict: config = dict(DEFAULTS) # a fresh copy for every app config = dict(DEFAULTS) for name, default in DEFAULTS.items(): if name in environ: config[name] = type(default)(environ[name]) # "50" -> 50, "false" -> True! config.update(overrides) config[name] = parse(environ[name], default) config.update(overrides) # tests and run.py have the last word if config["ENV"] == "production" and config["SECRET_KEY"] == DEFAULTS["SECRET_KEY"]: raise RuntimeError("Set SECRET_KEY in production: the default one is public.") return configWhen Best isn’t best: Flask has helpers: app.config.from_prefixed_env() reads FLASK_* variables and parses their JSON types. For many settings, a library like pydantic-settings checks every type for you.
Write it yourself
Section titled “Write it yourself”Write make_config(environ, overrides) from the task. Press Run tests to check your code.
- Copy
DEFAULTSfor each call. - For each default the environment sets, parse the text: booleans from
1,true,yesoron; ints withint(); text as it is. - Apply the overrides last. Then raise
RuntimeErrorifENVis"production"andSECRET_KEYis still the default.
Settings for one app
Ctrl + Enter runs- Not run yet:
No environment, no overrides→{'ENV': 'development', 'DEBUG': False, 'SECRET_KEY': 'dev-only-key', 'DATABASE_URL': 'sqlite:///notes.sqlite3', 'PAGE_SIZE': 20} - Not run yet:
PAGE_SIZE=50 in the environment→{'ENV': 'development', 'DEBUG': False, 'SECRET_KEY': 'dev-only-key', 'DATABASE_URL': 'sqlite:///notes.sqlite3', 'PAGE_SIZE': 50} - Not run yet:
DEBUG=false→{'ENV': 'development', 'DEBUG': False, 'SECRET_KEY': 'dev-only-key', 'DATABASE_URL': 'sqlite:///notes.sqlite3', 'PAGE_SIZE': 20} - Not run yet:
A test’s override beats the environment→{'ENV': 'development', 'DEBUG': False, 'SECRET_KEY': 'dev-only-key', 'DATABASE_URL': 'sqlite://', 'PAGE_SIZE': 20} - Not run yet:
One app sets PAGE_SIZE=5, then a second app is made→20 - Not run yet:
Production with the default secret key→ raisesRuntimeError - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Way | Leaks between apps | Types | Good | Not so good | Use it when |
|---|---|---|---|---|---|
| Okay: one shared dict | Yes | Text from the environment | Shortest | Tests change each other; numbers are text | A one-file script |
| Better: copy, convert by type | No | Right, except booleans | Each app is separate | "false" is true; no production check | Never for booleans |
| Best: parse, then check | No | Right | Safe defaults; fails at start | A few more lines | Every Flask app with an app factory |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- Two views with the same function name. Flask names each endpoint after its function. A decorator without
functools.wrapsnames them allwrapper, and Flask refuses the second one. The first practice problem shows this. - Hard-coded URLs. Use
url_for("api.note_detail", pk=7), so a changed rule updates every link. The second practice problem builds a smallurl_for. - Debug mode in production. The debugger runs any Python typed into the error page. Never set
debug=Trueon a server. - One database session for the whole app. Sessions aren’t thread-safe, and errors leave them broken. Open one per request and close it in teardown.
flask runin production. It is a development server. Use Gunicorn:gunicorn "flask_notes:create_app()".
Say it in the interview
Section titled “Say it in the interview”Questions to ask first
- How big will the app grow, and who will add to it?
- Which pieces do we need beyond routing: database, migrations, auth, forms?
- Where do settings come from in each environment?
A 60-second walkthrough
“Flask gives me routing, request and session objects, Jinja2 and a test client; I add the rest. I’d use an app factory: create_app builds the app, loads config and registers blueprints, so each test gets its own app. Config goes defaults, then environment, then test overrides, parsed to the right types. And it refuses to start in production with a default secret. For the database I use SQLAlchemy with one session per request, opened lazily in g and closed in teardown_appcontext. Views are grouped in blueprints, protected by decorators that keep their names with functools.wraps. Tests use the test client against an in-memory database.”
Likely follow-ups
- What is
g? A per-request scratch space on the app context: good for the database session or the current user. It is empty at the start of each request. - App context vs request context? The app context holds
current_appandg; the request context holdsrequestandsession. A request pushes both; a CLI command pushes only the app one. - Flask or Django? Flask when you want to pick each piece, or for a small API; Django when you want the admin, ORM, auth and forms ready. The decision guide covers this.
- How do you make Flask async? Flask allows
async defviews, but still uses one worker per request. For many open connections, use an ASGI framework like FastAPI.
Practice
Section titled “Practice”Three problems, from easy to medium. Each has hints, tests you can run here, and its own Okay, Better and Best answers.
1. A login_required decorator (easy)
Section titled “1. A login_required decorator (easy)”Write login_required(view). The wrapper takes (session, *args, **kwargs). If "user_id" isn’t in the session, it returns ({"error": "Log in first."}, 401). Otherwise it calls the view. Keep the view’s __name__, because Flask names endpoints after it.
Example: a guarded show called with an empty session gives ({"error": "Log in first."}, 401).
Hint 1 of 3
A decorator is a function that takes a function and returns a new one.
Hint 2 of 3
An API answers 401 with JSON. A redirect to a log-in page is for HTML pages.
Hint 3 of 3
@functools.wraps(view) on the wrapper copies __name__ and the docstring.
A login_required decorator
Ctrl + Enter runs- Not run yet:
Logged in, note 7→[{'id': 7}, 200] - Not run yet:
Not logged in→[{'error': 'Log in first.'}, 401] - Not run yet:
The wrapped view keeps its name→'note_detail' - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
A plain wrapper
The idea: Check the session, then call the view; no wraps.
For one guarded view, it works.
def login_required(view): def wrapper(session, *args): if "user_id" not in session: return {"error": "Log in first."}, 401 return view(session, *args)
return wrapper- Endpoint name
wrapper- Not logged in
401 JSON- Why
- It guards the view correctly, but hides who it wraps.
Where it breaks: guard a second view and Flask sees two endpoints called wrapper, and refuses to start. Keyword arguments are lost too.
wraps, and a redirect
The idea: Use functools.wraps, and send the user to /login/ with 302.
The waste in Okay: it threw away the view’s name.
Highlighted lines are new in this rung.
from functools import wraps
def login_required(view): @wraps(view) def wrapper(session, *args): if "user_id" not in session: return "/login/", 302 # send the browser to the log-in page return view(session, *args)
return wrapper- Endpoint name
The view’swas wrapper- Not logged in
302 to /login/was 401 JSON- Why
- Each view keeps its name, so many can be guarded.
What changed from the previous rung
from functools import wraps
def login_required(view): @wraps(view) def wrapper(session, *args): if "user_id" not in session: return {"error": "Log in first."}, 401 return "/login/", 302 # send the browser to the log-in page return view(session, *args)
return wrapperWhere it breaks: a redirect suits a web page. A JSON client follows it and gets an HTML log-in page, with a 200. It should get a 401 it can act on.
wraps, 401 JSON, and keyword arguments
The idea: Keep the name with wraps, answer 401 with JSON, and pass *args and **kwargs through.
The waste in Better: it answered an API like a web page.
Highlighted lines are new in this rung.
from functools import wraps
def login_required(view): @wraps(view) # keeps view.__name__, which Flask uses as the endpoint name def wrapper(session, *args, **kwargs): if "user_id" not in session: return {"error": "Log in first."}, 401 # an API answers; it doesn't redirect return view(session, *args, **kwargs)
return wrapper- Endpoint name
The view’s- Not logged in
401 JSONwas 302 to /login/- Why
- It fits an API, and works with any view.
What changed from the previous rung
from functools import wraps
def login_required(view): @wraps(view) def wrapper(session, *args): @wraps(view) # keeps view.__name__, which Flask uses as the endpoint name def wrapper(session, *args, **kwargs): if "user_id" not in session: return "/login/", 302 # send the browser to the log-in page return view(session, *args) return {"error": "Log in first."}, 401 # an API answers; it doesn't redirect return view(session, *args, **kwargs)
return wrapperWhen Best isn’t best: Flask-Login gives you login_required, current_user and remember-me cookies. In real Flask, the view reads session itself; here it is passed in so the test can run without Flask.
2. Build a URL from its endpoint (medium)
Section titled “2. Build a URL from its endpoint (medium)”Write build_url(rules, endpoint, **values), a small url_for. Fill each <name> in the rule from values, quoting it so / and spaces are safe. Raise ValueError if one is missing. Any values left over go in the query string.
Example: build_url(RULES, "note", note_id=7, edit=1) gives "/notes/7/?edit=1".
Hint 1 of 3
re.findall(r"<(\w+)>", rule) lists the names a rule needs.
Hint 2 of 3
urllib.parse.quote(text, safe="") turns "/" into %2F and a space into %20.
Hint 3 of 3
urllib.parse.urlencode(extra) builds the query string from a dict.
Build a URL from its endpoint
Ctrl + Enter runs- Not run yet:
note, note_id=7→'/notes/7/' - Not run yet:
note, with no note_id→ raisesValueError - Not run yet:
notes, page=2→'/notes/?page=2' - Not run yet:
tag, name="to do/now"→'/tags/to%20do%2Fnow/' - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Replace each value
The idea: For each value given, replace its placeholder in the rule.
Right when every value is there and is a plain number.
def build_url(rules: dict[str, str], endpoint: str, **values) -> str: url = rules[endpoint] for name, value in values.items(): url = url.replace(f"<{name}>", str(value)) return url- Missing value
Left as <note_id>- Extra values
Dropped- Why
- It fills what it is given, and never checks what the rule needs.
Where it breaks: a forgotten note_id gives /notes/<note_id>/, a broken link nobody notices until a user clicks it.
Fill what the rule needs
The idea: Read the names from the rule, and raise if one is missing.
The waste in Okay: it went by the values, not the rule.
Highlighted lines are new in this rung.
import re
def build_url(rules: dict[str, str], endpoint: str, **values) -> str: url = rules[endpoint] for name in re.findall(r"<(\w+)>", url): if name not in values: raise ValueError(f"{endpoint} needs {name}") url = url.replace(f"<{name}>", str(values[name])) return url- Missing value
ValueErrorwas Left as <note_id>- Extra values
Dropped- Why
- A missing value fails at once, where the link is built.
What changed from the previous rung
import re
def build_url(rules: dict[str, str], endpoint: str, **values) -> str: url = rules[endpoint] for name, value in values.items(): url = url.replace(f"<{name}>", str(value)) for name in re.findall(r"<(\w+)>", url): if name not in values: raise ValueError(f"{endpoint} needs {name}") url = url.replace(f"<{name}>", str(values[name])) return urlWhere it breaks: page=2 is silently dropped, and a tag named to do/now puts a raw / in the path, which points at another route.
Quote path parts, put the rest in the query
The idea: Fill and quote each needed name; urlencode the values left over.
The waste in Better: it trusted values to be URL-safe, and lost the rest.
Highlighted lines are new in this rung.
import refrom urllib.parse import quote, urlencode
def build_url(rules: dict[str, str], endpoint: str, **values) -> str: url = rules[endpoint] used = set() for name in re.findall(r"<(\w+)>", url): if name not in values: raise ValueError(f"{endpoint} needs {name}") url = url.replace(f"<{name}>", quote(str(values[name]), safe="")) used.add(name) extra = {k: v for k, v in values.items() if k not in used} return f"{url}?{urlencode(extra)}" if extra else url- Missing value
ValueError- Extra values
Query stringwas Dropped- Why
- Every value ends up in the URL, safely, and in the right part.
What changed from the previous rung
import refrom urllib.parse import quote, urlencode
def build_url(rules: dict[str, str], endpoint: str, **values) -> str: url = rules[endpoint] used = set() for name in re.findall(r"<(\w+)>", url): if name not in values: raise ValueError(f"{endpoint} needs {name}") url = url.replace(f"<{name}>", str(values[name])) return url url = url.replace(f"<{name}>", quote(str(values[name]), safe="")) used.add(name) extra = {k: v for k, v in values.items() if k not in used} return f"{url}?{urlencode(extra)}" if extra else urlWhen Best isn’t best: in Flask, call url_for. It also checks converters like <int:pk>, adds the blueprint prefix, and can build full URLs for emails with _external=True.
3. A database session per request (medium)
Section titled “3. A database session per request (medium)”Write handle(view, connect). Call view(get_db) and return its result. get_db() must open a connection with connect() the first time it is called, and give back the same one after that. When the view ends, close the connection if one was opened, even if the view raised.
Example: a view that calls get_db() twice opens one connection and closes it once. A view that never calls it opens none.
Hint 1 of 3
Keep the connection in a variable outside get_db, like Flask keeps it in g.
Hint 2 of 3
A list or nonlocal lets the inner function change it.
Hint 3 of 3
try: return view(get_db) / finally: close if opened. finally runs even when the view raises.
A database session per request
Ctrl + Enter runs- Not run yet:
A view that uses the database twice→['ok', ['open', 'close']] - Not run yet:
A view that never uses it→['ok', []] - Not run yet:
A view that raises after opening it→['open', 'close', 'raised'] - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Open, run, close
The idea: Connect, run the view, then close.
Fine while every view succeeds.
def handle(view, connect): conn = connect() result = view(lambda: conn) conn.close() # skipped if the view raises return result- View raises
Never closed- View doesn’t need it
Opened anyway- Why
- The close line only runs if the view returns.
Where it breaks: each error leaks a connection. After enough errors, the pool is empty and every request waits.
try and finally
The idea: Connect first, then close in finally.
The waste in Okay: errors skipped the clean-up.
Highlighted lines are new in this rung.
def handle(view, connect): conn = connect() # opened even if the view never uses it try: return view(lambda: conn) finally: conn.close()- View raises
Closedwas Never closed- View doesn’t need it
Opened anyway- Why
- finally runs whether the view returns or raises.
What changed from the previous rung
def handle(view, connect): conn = connect() result = view(lambda: conn) conn.close() # skipped if the view raises return result conn = connect() # opened even if the view never uses it try: return view(lambda: conn) finally: conn.close()Where it breaks: a health check or a static page opens a connection for nothing. Under load, that is many idle connections.
Open on first use, close in finally
The idea: get_db opens once and reuses; finally closes only what was opened.
The waste in Better: it paid for a connection before knowing it was needed.
Highlighted lines are new in this rung.
def handle(view, connect): opened = []
def get_db(): if not opened: opened.append(connect()) # open on first use, then reuse return opened[0]
try: return view(get_db) finally: if opened: opened[0].close() # like Flask's teardown_appcontext- View raises
Closed- View doesn’t need it
Never openedwas Opened anyway- Why
- Exactly one connection per request that needs one, always returned.
What changed from the previous rung
def handle(view, connect): conn = connect() # opened even if the view never uses it opened = []
def get_db(): if not opened: opened.append(connect()) # open on first use, then reuse return opened[0]
try: return view(lambda: conn) return view(get_db) finally: conn.close() if opened: opened[0].close() # like Flask's teardown_appcontextWhen Best isn’t best: in Flask this is get_db() with g plus @app.teardown_appcontext, as in examples/flask/flask_notes/db.py. Flask-SQLAlchemy sets it up for you.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”create_app(settings)builds a new app: config, database, blueprints. Tests make their own.- Config: defaults, then environment (parsed), then overrides. No default secret in production.
- Contexts:
current_appandg(app),requestandsession(request), one set per request. - Database: one session per request, opened lazily in
g, closed inteardown_appcontext. - Decorators keep names with
functools.wraps; links come fromurl_for; test withapp.test_client().
Go deeper
Section titled “Go deeper”- Flask: the application factory, contexts and testing.
- SQLAlchemy: sessions.
- The runnable example:
examples/flask/in the repository, with 7 tests. - Next on the Frameworks roadmap: Django, which brings these pieces built in.
Can you do these now?
If you’re not sure of any of these, scroll back to that part before moving on.
Status: Not started