Request Lifecycle: How Web Frameworks Handle a Request · Python Frameworks: Django, Flask and FastAPI
Flask: Contexts, Hooks and Teardown
Why a Flask app ran out of database connections after adding a header hook, and when before_request, after_request and teardown functions run.
Scenario
Example app: a Flask API opens a database connection from a pool in before_request and returns it in after_request. Later someone adds another after_request hook that sets a security header from a config dictionary.
What you see in production
- After a deploy, requests to a few endpoints start failing with 500 errors.
- Minutes later all endpoints hang and time out: the connection pool is exhausted.
- Restarting the app fixes it until the failing endpoints are hit again.
The naive code
@app.before_request
def open_db():
g.db = pool.get()
@app.after_request
def close_db(response): # cleanup in after_request
pool.put(g.db)
return response
@app.after_request
def security_headers(response): # registered later, so it runs FIRST
response.headers["Content-Security-Policy"] = CSP_BY_TYPE[response.mimetype] # KeyError for some types
return response
Reproduce it
Flask calls after_request functions in reverse order of registration, and if one raises, the remaining ones are skipped. teardown_request functions run at the end of every request, even after errors. The simulation sends five requests to a pool of three connections.
class Pool:
def __init__(self, size):
self.free = size
def handle(pool, mimetype, cleanup_in_teardown):
if pool.free == 0:
return "timeout: no free DB connection"
pool.free -= 1 # before_request: take a connection
status = "200"
after_hooks = [] if cleanup_in_teardown else ["close_db"]
after_hooks.append("security_headers")
try:
for hook in reversed(after_hooks): # Flask runs after_request hooks in reverse order
if hook == "security_headers" and mimetype not in ("application/json", "text/html"):
raise KeyError(mimetype)
if hook == "close_db":
pool.free += 1
except KeyError:
status = "500" # remaining after_request hooks were skipped
if cleanup_in_teardown:
pool.free += 1 # teardown_request always runs
return status
for label, teardown in [("cleanup in after_request", False), ("cleanup in teardown_request", True)]:
pool = Pool(3)
results = [handle(pool, m, teardown) for m in ["text/csv", "image/png", "application/json", "text/csv", "application/json"]]
print(f"{label:<28} {results} free connections: {pool.free}")
Output
cleanup in after_request ['500', '500', '200', '500', 'timeout: no free DB connection'] free connections: 0 cleanup in teardown_request ['500', '500', '200', '500', '200'] free connections: 3
Root cause
after_request is for modifying the response, not for cleanup: it can be skipped when an earlier hook raises, and when exceptions propagate (debug or testing mode). The connection was never returned, so each failing request leaked one connection until the pool was empty.
The lifecycle, step by step
From Flask's application structure and lifecycle:
- The WSGI server calls the app. Flask pushes the app context and request context, making
current_app,g,requestandsessionavailable. - The session is opened; the
request_startedsignal fires. - URL matching finds the endpoint;
url_value_preprocessorfunctions run. before_requestfunctions run; if one returns a value, the view is skipped.- The view runs; its return value (or an error handler's) becomes a
Response. after_requestfunctions run (in reverse registration order) and can modify the response; the session is saved.- The response is returned to the WSGI server.
teardown_requestfunctions run, then the request context is popped;teardown_appcontextfunctions run, then the app context is popped. Teardown functions run even if an exception occurred.
The fix
- Release resources in
teardown_requestorteardown_appcontext(this is what Flask-SQLAlchemy does to remove sessions); keepafter_requestfor response changes. - Make response hooks robust (
dict.getwith a default). - Prefer extensions or context managers that manage resource lifetimes for you.
Trade-offs
- Teardown runs after the response is produced, so it can't change the response; that's by design.
gand context-locals are convenient but hide dependencies; pass explicit arguments in business logic.
Detect it in production
- Monitor pool usage (checked-out connections) and alert when it doesn't return to baseline after traffic drops.
- A rise in 500s followed by timeouts across all endpoints is a classic resource-leak pattern.
Key points
- Flask pushes app and request contexts, runs
before_request, the view,after_request(reverse order), then teardown functions. after_requestcan be skipped; teardown always runs: put cleanup there.- A failing hook that leaks a resource per request eventually takes down every endpoint.
Practice questions
Walk through the lifecycle of a request in Flask.
Answer
The WSGI server calls the app; Flask pushes the app and request contexts; URL matching; before_request functions (a returned value skips the view); the view; after_request functions in reverse registration order; session saved; response returned; teardown_request and teardown_appcontext functions run and the contexts are popped.
What is the difference between after_request and teardown_request in Flask?
Answer
after_request functions receive and can modify the response, but can be skipped if an exception is raised or an earlier hook fails. teardown_request functions run at the end of every request, even after errors, and are the right place to release resources.
What are the application context and the request context in Flask?
Answer
The app context makes current_app and g available; the request context makes request and session available. Flask pushes both for each request (and the app context for CLI commands or manual app.app_context()), and pops them at the end.
Open in the app to mark lessons complete, take notes and track your progress.