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.

Cleanup in after_request vs teardown_requestTry it yourself
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:

  1. The WSGI server calls the app. Flask pushes the app context and request context, making current_app, g, request and session available.
  2. The session is opened; the request_started signal fires.
  3. URL matching finds the endpoint; url_value_preprocessor functions run.
  4. before_request functions run; if one returns a value, the view is skipped.
  5. The view runs; its return value (or an error handler's) becomes a Response.
  6. after_request functions run (in reverse registration order) and can modify the response; the session is saved.
  7. The response is returned to the WSGI server.
  8. teardown_request functions run, then the request context is popped; teardown_appcontext functions run, then the app context is popped. Teardown functions run even if an exception occurred.

The fix

  • Release resources in teardown_request or teardown_appcontext (this is what Flask-SQLAlchemy does to remove sessions); keep after_request for response changes.
  • Make response hooks robust (dict.get with 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.
  • g and 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_request can 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.