Flask Interview Questions · 2026

Flask Interview Questions (2026): Most Asked, With Answers

A candidate at a Series B logistics startup once got asked to explain why a database connection opened in one view kept showing up, unclosed, three requests later under load. He'd used Flask for two years and had never once had to think about it, because his team's boilerplate handled it for them. The real answer lives in one object most tutorials skip past: the application context, and specifically what happens on teardown_appcontext when a worker is juggling more than one request. Flask sits at 13.2 percent usage among professional developers in the 2025 Stack Overflow survey, behind the newer FastAPI at 15.1 percent after a five-point jump this year (Stack Overflow, 2025). It's no longer the only Python web option candidates reach for, which means interviewers lean harder on the parts of Flask that are genuinely Flask-specific rather than generic REST trivia.

Here's a pattern worth naming: most Flask interview prep repeats the same five questions about decorators and routes, then stops right before the material that actually separates candidates, contexts, blueprints, and what happens to global-looking objects like g and current_app once a WSGI server spins up more than one worker. You can write a working CRUD app for a year using @app.route and never touch app_context() directly. You can't debug a production incident involving a stale database connection, a leaked secret key, or a blueprint that silently swallowed a 404 without understanding it.

This page covers Flask interview questions across nine areas: WSGI fundamentals, routing and request data, application and request context, Jinja2 templating, sessions and security, blueprints and the application factory pattern, extensions and the data layer, testing and debugging, and deployment with async support. Code examples are Python throughout, targeting current Flask 3.1.x behavior per the official Flask documentation.

53Questions
Application ContextCore Concept
13.2%2025 Pro Dev Usage
15.1%FastAPI 2025 Usage

Flask fundamentals and WSGI

Every loop starts here, and a wobbly answer on what Flask actually is under the hood sets a low ceiling for the rest of the conversation.

Easy questions

14

Flask is a WSGI micro-framework: it gives you routing, request and response objects, and Jinja2 templating, then gets out of the way. It doesn't ship an ORM, an admin panel, or a form library. Django is a full-stack framework with all of that built in and a stronger set of opinions about how a project is laid out.

The practical trade-off shows up in every real project. Flask apps end up composed from extensions, Flask-SQLAlchemy for the database, Flask-Migrate for schema changes, Flask-Login for auth, so the framework itself stays small but the dependency list grows. Django gives you more out of the box at the cost of more structure to learn up front. Neither is objectively better; the choice tends to come down to whether the team wants Django's conventions or wants to pick each piece itself.

WSGI (Web Server Gateway Interface) is the standard Python interface between a web server and a Python web application. It defines a simple callable, an app object that takes an environ dict and a start_response function and returns an iterable of bytes. Any WSGI server, gunicorn, uWSGI, the Flask dev server, can talk to any WSGI application.

Flask's Flask instance is a WSGI application. That's the whole reason `app = Flask(__name__)` can be run by gunicorn in production: `app` satisfies the WSGI callable contract underneath all the routing and decorators Flask adds on top.

The built-in dev server is single-threaded by default, isn't hardened against malformed requests, and ships with the interactive debugger available when debug mode is on, which lets anyone who reaches it execute arbitrary Python on your server. Flask's own docs are direct about this: it's for local development, not for anything internet-facing.

In production you put a real WSGI server, gunicorn or uWSGI, in front of the app, usually behind nginx handling TLS and static files. The Flask app object doesn't change; only what's serving it does.

You mark a dynamic segment with angle brackets, ``, and Flask passes it to the view function as a keyword argument. Adding a converter before the name, ``, tells Flask to both validate and coerce the segment to that type before the view ever sees it.

python
@app.route('/user/<username>')
def show_user_profile(username):
    return f'User {username}'

@app.route('/post/<int:post_id>')
def show_post(post_id):
    return f'Post {post_id}'  # post_id is already an int here

The built-in converters are string (default, no slashes), int, float, path (like string but allows slashes), and uuid. Requesting `/post/abc` against the int converter returns a 404 automatically, before your view code runs.

A route defined as `/projects/` treats the trailing slash as canonical, like a folder. Requesting `/projects` without the slash gets a redirect to `/projects/`. A route defined as `/about`, with no trailing slash, treats that as canonical like a filename, and requesting `/about/` returns a 404 instead of redirecting.

This trips people up because it's asymmetric: dropping a slash from a slash-based route redirects, but adding one to a no-slash route doesn't. It's a small detail, but it explains a surprising number of "why does this URL 404 sometimes" bug reports.

Pass a `methods` list to `@app.route`, or use the method-specific shortcuts `@app.get()` and `@app.post()` added for readability. A route only answers `GET` by default if you don't specify anything.

python
@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        return do_the_login()
    return show_the_login_form()

If `GET` is in the list, Flask automatically adds support for `HEAD` and answers it per the HTTP spec without you writing a handler. `OPTIONS` is also implemented automatically for every route, which is why a stray `OPTIONS /login` returning 200 in a test isn't a bug, it's Flask doing its job.

g is a namespace object Flask gives you to stash data for the duration of a single application context, most commonly the lifetime of one request. It's meant for per-request scratch space, a cached database connection, the currently authenticated user resolved once and reused, not for anything that needs to persist between requests.

python
from flask import g

def get_db():
    if 'db' not in g:
        g.db = connect_to_database()
    return g.db

@app.teardown_appcontext
def teardown_db(exception):
    db = g.pop('db', None)
    if db is not None:
        db.close()

The "g" stands for global, but only global within that one context. Once the context ends, whatever was on `g` is gone. Reach for `session` or a real database if you need data to survive across requests.

Flask looks in a folder named `templates` next to your application module or package. Call `render_template('hello.html', **context)` and Flask hands the name and any keyword arguments to Jinja2, which loads the file from that search path and renders it with the given variables available.

python
from flask import render_template

@app.route('/hello/<name>')
def hello(name):
    return render_template('hello.html', person=name)

Inside the template you also automatically get access to `config`, `request`, `session`, and `g`, plus the `url_for()` and `get_flashed_messages()` functions, without passing any of them in explicitly.

A base template defines named blocks, `{% block content %}{% endblock %}`, for the sections that will vary. A child template declares `{% extends "base.html" %}` and then overrides just the blocks it wants to change, inheriting everything else, header, nav, footer, from the parent unchanged.

html
{# base.html #}
<html><body>{% block content %}{% endblock %}</body></html>

{# page.html #}
{% extends "base.html" %}
{% block content %}<p>Hello!</p>{% endblock %}

This is the standard way to keep a shared layout, header, navigation, footer, in one place instead of copy-pasting it into every page template.

Read cookies from `request.cookies`, a dict-like object; set them on a `Response` with `.set_cookie()`, since cookies are attached to the outgoing response, not to the view function's return value directly.

python
@app.route('/')
def index():
    username = request.cookies.get('username')  # .get() avoids a KeyError
    resp = make_response(render_template('index.html'))
    resp.set_cookie('username', 'ada')
    return resp

If you want anything beyond a plain unsigned value, Flask's own `session` object already handles signing on top of cookies, so most apps use raw cookies only for genuinely non-sensitive preferences.

session persists across multiple requests from the same client, since it's stored in a cookie the browser sends back every time. g only lives for a single application context, typically one request, and is gone by the time the next request starts.

Use `session` for anything that needs to survive between page loads, a logged-in user's identity. Use `g` for scratch data you only need within the current request, like a lazily created database connection you don't want to reopen three times in the same view.

A Blueprint is a recorded set of operations, routes, templates, static files, error handlers, that gets applied to a real application when you call `app.register_blueprint()`. It behaves similarly to an app object but isn't one itself; it's a plan for how to extend an app, not something that can run on its own.

python
from flask import Blueprint

admin_bp = Blueprint('admin', __name__, url_prefix='/admin')

@admin_bp.route('/dashboard')
def dashboard():
    return 'Admin dashboard'

app.register_blueprint(admin_bp)

The payoff is modularity: a larger app can be split into blueprints per feature area, each with its own routes and templates, registered onto one application object at startup, instead of every route living in a single sprawling file.

Flask-SQLAlchemy wires SQLAlchemy's engine and session lifecycle into Flask's application context automatically, so you get a properly scoped session per request without configuring that plumbing by hand, plus config keys like `SQLALCHEMY_DATABASE_URI` that read naturally from `app.config`.

python
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(80))

You still get the full underlying SQLAlchemy query API, this isn't a separate simplified ORM, it's a thin Flask-aware wrapper around the real thing.

Flask's `app.test_client()` gives you an object that simulates HTTP requests directly against the app in-process, no socket, no running server, so tests are fast and don't depend on a port being free.

python
def test_index():
    client = app.test_client()
    response = client.get('/')
    assert response.status_code == 200
    assert b'Hello' in response.data

This is the standard entry point for almost all Flask test suites, and it works the same whether the view returns HTML, JSON, or a redirect.

Medium questions

27

Werkzeug is the WSGI utility library Flask is built on. It provides the actual Request and Response objects, the URL routing system (the Map and Rule classes that power @app.route), the development server, and the interactive debugger. Flask is, in large part, a thin and opinionated layer over Werkzeug plus Jinja2 for templates.

Knowing this matters practically because a lot of what looks like "Flask magic" is actually Werkzeug: secure_filename(), the ProxyFix middleware, and the converter types in route rules (`int`, `float`, `path`, `uuid`) all live in Werkzeug and get imported from there, not from `flask` itself.

request.args holds the query string parameters from the URL, `?key=value`, and is populated regardless of HTTP method. request.form holds data submitted in the body of a POST or PUT request with a form-encoded or multipart content type, it's empty for a GET request and empty for a JSON body too.

python
@app.route('/search')
def search():
    q = request.args.get('q', '')  # from ?q=...

@app.route('/login', methods=['POST'])
def login():
    username = request.form['username']  # from the submitted form body

For a JSON request body you'd use request.get_json() instead of either. Mixing these up is a common source of "the field is always empty" bugs, usually because someone read a query param off `request.form` or a POST body off `request.args`.

Flask's `request.form` is a `MultiDict`, and indexing a missing key raises a `KeyError` just like a plain dict would. If that error propagates out of your view unhandled, Flask converts it into a 400 Bad Request response automatically, rather than a 500.

That's Flask being forgiving on your behalf, but it's still worth using `request.form.get('field', default)` deliberately for optional fields, so the 400 you send back actually comes with a message you chose rather than a generic one. For query parameters the same logic applies to request.args.

`url_for()` builds a URL from the view function's name, not a string you have to keep in sync by hand. If a route's path changes, every `url_for()` call that references it by endpoint name updates automatically; every hardcoded string doesn't.

python
@app.route('/user/<username>')
def profile(username):
    return f"{username}'s profile"

url_for('profile', username='ada')  # '/user/ada'

It also handles escaping of special characters in the arguments, generates absolute paths so relative-path bugs in the browser don't bite you, and correctly accounts for a blueprint's url_prefix or an app mounted somewhere other than the domain root. Hardcoding a path string sidesteps all of that.

Uploaded files show up on `request.files`, keyed by the form field name, and each one behaves like a file object with a `.save()` method. The part people get wrong is trusting the client-submitted filename directly, since it can be forged to include path traversal characters like `../../etc/passwd`.

python
from werkzeug.utils import secure_filename

@app.route('/upload', methods=['POST'])
def upload_file():
    f = request.files['the_file']
    filename = secure_filename(f.filename)
    f.save(f"/var/www/uploads/{filename}")

secure_filename() strips out anything that isn't safe for a filesystem path, so it's the one line that keeps an upload endpoint from becoming a path traversal vulnerability.

The application context tracks app-level state, `current_app` and `g`, for the duration of a request, CLI command, or other unit of work. The request context tracks the actual incoming HTTP request, `request` and `session`, and only exists while a request is being handled.

Flask automatically pushes a request context when it starts handling a request, and pushing a request context always pushes a matching application context along with it. That's why `current_app` and `g` are available anywhere `request` is available, but the reverse doesn't hold: you can push an app context on its own, for a CLI command or a script, without a request ever existing.

It means something tried to use `current_app`, `g`, or anything built on top of them, outside of any active application context, most often a module-level import that runs before the app exists, or a background thread or script that never pushed one.

python
def create_app():
    app = Flask(__name__)

    with app.app_context():
        init_db()  # needs current_app internally, now it has one

    return app

The fix is to wrap the offending code in `with app.app_context():`, giving it direct access to the app object at that point. If the error shows up inside a real request instead, that usually means the code got pulled out into a background thread or a scheduled job that doesn't inherit Flask's automatic per-request context.

Any time code needs `current_app`, `g`, or extensions that depend on them, and there's no request driving it, a one-off script, a Flask CLI command, a background job, a REPL session for debugging. Flask does push an app context automatically for commands registered with `@app.cli.command()`, but a plain standalone script needs it done by hand.

python
from myapp import create_app

app = create_app()
with app.app_context():
    from myapp.models import User
    User.query.filter_by(active=False).delete()

Jinja2 autoescapes variables rendered inside templates with `.html`, `.htm`, `.xml`, or `.xhtml` extensions, converting characters like `<` and `>` to their HTML entity equivalents so user-supplied content can't inject a script tag or break the page structure. This is on by default and is the main reason Flask templates are safer than building HTML with plain f-strings.

python
from markupsafe import Markup

Markup('<strong>Hello %s!</strong>') % '<script>bad()</script>'
# renders the script tag as visible text, not executable markup

You'd mark content safe, with the `|safe` filter or by wrapping it in `Markup()`, only when the content genuinely already is trusted HTML, output from a markdown-to-HTML converter you control, for instance. Doing it to silence an escaping error on user input is the exact mistake that reopens the XSS hole autoescaping exists to close.

The application's own `templates` folder always takes priority over a blueprint's `template_folder`. If both define `index.html`, the app's copy is the one that actually gets rendered, and the blueprint's version is effectively shadowed.

The recommended way to avoid an accidental collision is to namespace a blueprint's templates under a subfolder matching the blueprint's name, `admin/templates/admin/index.html` rather than `admin/templates/index.html`, so it doesn't share a relative path with anything the main app or another blueprint might define.

By default, `session` data is serialized and stored entirely in a cookie on the client, cryptographically signed using the app's `SECRET_KEY` so the browser can't tamper with the contents without invalidating the signature. Flask reads that cookie back on the next request and verifies the signature before trusting the data inside it.

python
app.secret_key = b'_5#y2L"F4Q8znxec]/'

@app.route('/login', methods=['POST'])
def login():
    session['username'] = request.form['username']
    return redirect(url_for('index'))

The important consequence: a user can still read the contents of that cookie, since signing proves authenticity, not confidentiality. Anything genuinely secret, a password hash, a raw API token, has no business sitting in a Flask session cookie.

The secret key is what signs the session cookie, so Flask can detect if a client modified it. Without one set, calling `session[...]` raises a `RuntimeError`, since there'd be nothing to sign with.

If that key leaks, anyone who has it can forge a valid signature for arbitrary session content, session['is_admin'] = True included, and Flask will accept it as legitimate. That's the whole reason the docs recommend generating it with `secrets.token_hex()` and keeping it out of version control entirely, in an environment variable or a secrets manager, not hardcoded in `app.py`.

Flask itself doesn't ship CSRF protection out of the box, that's handled by the Flask-WTF extension, which generates a per-session token, embeds it as a hidden field in the form, and rejects any POST that doesn't include a matching token.

python
from flask_wtf import FlaskForm
from flask_wtf.csrf import CSRFProtect

csrf = CSRFProtect(app)

class LoginForm(FlaskForm):
    username = StringField('Username')

The token check works because it relies on something an attacker's cross-site request can't forge, a value tied to the victim's own session, which is exactly the gap plain cookie-based auth without CSRF protection leaves open.

Werkzeug's interactive debugger, shown when an unhandled exception occurs in debug mode, lets you execute arbitrary Python code directly in the browser at the point of the traceback. That's exactly as dangerous as it sounds if it's ever reachable from the public internet, since anyone who triggers an error can run code on your server.

Flask's mitigation is a PIN: the debugger console is locked until you enter a PIN value printed to the server's console log at startup, which an outside attacker can't see. The real mitigation isn't the PIN though, it's simply never enabling debug mode in a production deployment in the first place.

Every route defined inside the blueprint gets that prefix prepended automatically, and the blueprint's own name becomes a namespace on the endpoint, separated by a dot, so `url_for()` calls need to reference it as `blueprint_name.view_name`.

python
app.register_blueprint(admin_bp, url_prefix='/pages')
# a route defined as '/dashboard' inside admin_bp is now served at '/pages/dashboard'
url_for('admin.dashboard')  # not just 'dashboard'

You can also register the same blueprint more than once, at different prefixes, though not every blueprint is written to support that safely, it depends on whether it assumes it's mounted exactly once.

Instead of creating a single module-level `app = Flask(__name__)` that every other module imports, you wrap app creation in a function, `create_app()`, that builds and returns a fresh app instance each time it's called, with extensions initialized and blueprints registered inside.

python
def create_app(config_name='production'):
    app = Flask(__name__)
    app.config.from_object(configs[config_name])
    db.init_app(app)
    app.register_blueprint(admin_bp)
    return app

This solves two real problems. Tests can call `create_app('testing')` to get an isolated app instance with a test database config, without any risk of leftover state from a previous test bleeding into the next one. It also sidesteps circular imports, since extension and blueprint modules no longer need to import a concrete `app` object at module load time, only `current_app` when they're actually invoked.

Most well-behaved Flask extensions split object creation from app binding: you instantiate the extension object at module scope with no app passed in, then call `.init_app(app)` later, inside the factory function, to actually attach it. That lets a models module import the bare extension instance without ever needing to import the concrete `app` object.

python
# extensions.py
db = SQLAlchemy()

# app.py
from extensions import db

def create_app():
    app = Flask(__name__)
    db.init_app(app)
    return app

Any module in the project can safely `from extensions import db` without triggering a circular import back to `app.py`, since `db` doesn't depend on `app` existing yet at import time.

Flask-Migrate handles schema migrations, generating and applying versioned changes to your database tables as your models evolve, wrapping the Alembic migration library and exposing it through Flask's CLI as `flask db init`, `flask db migrate`, and `flask db upgrade`.

Without it, every schema change means writing raw SQL by hand or dropping and recreating tables, which isn't viable once there's production data you can't just wipe. Flask-Migrate's `migrate` command diffs your current models against the last known migration state and generates the change script for you to review before applying it.

Returning a plain dict or list from a view is a shortcut Flask added that internally calls `jsonify()` on your behalf, so for the common case they produce an identical response, correct content type, serialized body.

python
@app.route('/me')
def me():
    return {"username": "ada"}  # implicitly jsonify()'d

@app.route('/me2')
def me2():
    return jsonify({"username": "ada"})  # explicit, same result

You still need to call `jsonify()` explicitly when you want to combine the JSON body with a custom status code or headers using the tuple return form, since the plain-dict shortcut only covers the simple 200-with-default-headers case.

Marshmallow or Pydantic are the two common choices; you define a schema once, and it validates incoming data (raising a clear error listing every invalid field) and serializes outgoing model objects back to JSON-safe dicts, instead of writing `if 'field' not in data` checks by hand for every endpoint.

python
from marshmallow import Schema, fields, ValidationError

class UserSchema(Schema):
    username = fields.Str(required=True)
    email = fields.Email(required=True)

@app.route('/users', methods=['POST'])
def create_user():
    try:
        data = UserSchema().load(request.get_json())
    except ValidationError as err:
        return jsonify(err.messages), 400

This is also where Flask's lack of an opinion becomes visible in interviews: Django REST Framework bundles serializers, Flask leaves the choice to you, so being able to name a real option and explain why you'd reach for it matters more than it would in a Django-specific interview.

An extension exposing `init_app(app)` separates the extension's own object creation from binding it to a specific Flask instance. You create the extension once at module import time with no app, then call `.init_app(app)` inside `create_app()` once the actual app object exists.

python
mail = Mail()  # no app yet

def create_app():
    app = Flask(__name__)
    mail.init_app(app)  # binds it now
    return app

Without this split, an extension that only supports `Extension(app)` at construction time can't be used cleanly with the factory pattern, since the app doesn't exist yet when the module defining `mail` gets imported. Most mainstream Flask extensions support both styles for exactly this reason.

test_client() is for testing behavior through an actual request/response round trip, exactly what a real client would see. test_request_context() is for when you need a request context active so you can call code that depends on `request`, `session`, or `url_for()` directly, without going through the full view dispatch and response-building machinery.

python
with app.test_request_context('/hello', method='POST'):
    assert request.path == '/hello'
    assert request.method == 'POST'
    print(url_for('login'))

You'd reach for it when unit testing a helper function that uses `url_for()` or reads `request`, in isolation, rather than testing the full view that calls it.

Debug mode (`app.debug` or `flask run --debug`) enables the interactive debugger on unhandled exceptions and more verbose error output. `use_reloader` is the separate setting that restarts the server process automatically whenever it detects a source file change. Debug mode turns both on by default, but they're independently controllable.

python
app.run(debug=True, use_reloader=False)

You'd disable the reloader specifically while running the app under a debugger like `pdb` or an IDE's breakpoint tooling, since the reloader spawns a second child process, and stepping through code in the wrong one is a confusing way to lose an afternoon.

Patch the function or client that makes the actual network call, `requests.get`, or a wrapped client method, at the point where your view code imports it, using `unittest.mock.patch`, so the test never hits the network and gets a predictable response back instead.

python
from unittest.mock import patch

@patch('myapp.views.requests.get')
def test_weather_view(mock_get):
    mock_get.return_value.json.return_value = {"temp": 72}
    response = app.test_client().get('/weather')
    assert b'72' in response.data

The detail that trips people up is patching the wrong import path, you patch the name as it's looked up in the module under test (`myapp.views.requests.get`), not where `requests` itself is originally defined.

Calling a view function directly, `my_view()`, skips Flask's entire request dispatch: no URL routing, no request or application context pushed, no before/after-request hooks, and no conversion of the return value into an actual Response object. If the view depends on `request` or `g`, calling it bare raises the same "working outside of context" error you'd get anywhere else.

test_client() runs a full simulated request through that entire pipeline, contexts pushed, hooks fired, response object built, so what you're testing matches what a real client actually experiences, including status codes, headers, and any middleware in the chain.

Gunicorn manages a pool of worker processes (or threads, or async workers depending on the worker class) in front of your Flask app, handling concurrency, worker recycling on crashes, and graceful restarts, none of which the single-process development server does.

bash
gunicorn --workers 4 --bind 0.0.0.0:8000 myapp:app

Switching to multiple worker processes is also the point where any in-memory, per-process assumption in your app breaks: a plain Python dict used as a cache, a background counter, an in-memory rate limiter, all become per-worker instead of shared across the whole deployment, silently giving inconsistent results depending on which worker handled a given request.

When a Flask app sits behind a reverse proxy like nginx, the proxy terminates the actual client connection, so `request.remote_addr` and the scheme Flask sees default to the proxy's own address and connection details, not the real client's. `ProxyFix` reads the standard forwarded headers (`X-Forwarded-For`, `X-Forwarded-Proto`, and others) the proxy sets and rewrites the WSGI environ so Flask sees the real client IP and scheme instead.

python
from werkzeug.middleware.proxy_fix import ProxyFix

app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)

Skipping this behind a proxy is a quiet source of bugs: IP-based rate limiting keyed on `remote_addr` ends up rate-limiting the proxy itself instead of individual clients, and `url_for(_external=True)` can generate `http://` links for a site that's actually served over `https://`.

Hard questions

12

You reassign app.wsgi_app to the wrapped version, not app itself: app.wsgi_app = ProxyFix(app.wsgi_app). Middleware operates purely at the WSGI callable level, taking an environ and start_response and returning a response, so it doesn't need to know anything about Flask's routing or Flask-specific attributes.

python
from werkzeug.middleware.proxy_fix import ProxyFix

app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)

Wrapping app.wsgi_app instead of replacing app keeps app pointing at your actual Flask instance, so `app.route()`, `app.config`, and every other Flask-level API you rely on elsewhere in the codebase keeps working unchanged. If you replaced app itself with the middleware-wrapped object, you'd lose access to all of that.

Flask checks the return value against a fixed set of cases, in order. A Response object of the right type is returned as-is. A plain string becomes a Response with that body, status 200, and content type text/html. An iterator or generator yielding strings or bytes is treated as a streaming response. A dict or list is passed through `jsonify()` to become a JSON response. A tuple is unpacked as `(body, status)`, `(body, headers)`, or `(body, status, headers)`. Anything else that doesn't match is assumed to be a WSGI application itself.

python
@app.route('/status')
def status():
    return {"ok": True}, 202  # dict body via jsonify, status overridden to 202

The tuple form is the one that trips people up in code review, since it's easy to misread `(body, status)` as `(status, body)` in a rushed diff. Knowing the exact order Flask checks these cases in also explains why returning a two-item tuple where the second item is a dict, not an int, behaves as `(body, headers)` instead of erroring.

current_app has to resolve to a different actual Flask object depending on which context is active at the moment it's accessed, which a plain module-level reference can't do. It's a `LocalProxy` that, on every attribute access, looks up whatever app is bound to the current context and forwards the call to it.

This matters specifically for the app factory pattern and for extensions. An extension module can't import a concrete `app` object at import time, since there might be several apps created by several factory calls in the same process, tests being the obvious case. By deferring resolution to the moment of use, `current_app` lets the same extension code work correctly no matter which app instance is actually handling the current request or command.

Objects like `request`, `session`, `g`, and `current_app` are context-local proxies backed by Python's `contextvars` (through Werkzeug's local storage). Each thread, or each async task, gets its own isolated slot for these values, so two requests handled concurrently by two threads never see each other's `request` object even though the code refers to the exact same name.

Flask pushes the context when it starts handling a request and pops it when the request finishes, binding the current thread or task to that specific request's data for the duration. You don't manage a lock yourself because the isolation happens at the storage layer, not by serializing access to a shared object.

Setting the `EXPLAIN_TEMPLATE_LOADING` config value to `True` makes Flask print out, on every single `render_template()` call, exactly which folders it searched and in what order, and whether each one had a matching file. It's a debugging aid, not something you'd leave on in any real environment because of how noisy the output is.

You'd turn it on specifically when a template is loading the wrong version of itself, usually a blueprint template shadowing issue, and you can't otherwise tell which of several template folders Jinja2 actually picked from.

Flask doesn't include server-side sessions itself; you'd add an extension like Flask-Session, which swaps the default cookie-based `SessionInterface` for one backed by Redis, a database, or the filesystem. The cookie the client holds then contains only an opaque session ID, and the actual data lives server-side, looked up by that ID on each request.

python
from flask_session import Session

app.config['SESSION_TYPE'] = 'redis'
Session(app)

The trade-off: server-side sessions can hold larger and more sensitive data safely, since nothing but an opaque ID ever leaves the server, but they add an infrastructure dependency (Redis, in this example) and a bit of latency per request that a signed cookie doesn't have.

A blueprint's `@blueprint.errorhandler(404)` only fires when a 404 is raised explicitly from inside one of that blueprint's own view functions, via `abort(404)` or a raised `NotFound`. It does not fire for a genuinely invalid URL that never matched any route in the first place, because a blueprint doesn't own a chunk of URL space the way an application does. Flask has no way to know which blueprint's handler should apply to a URL that matched nothing.

python
@app.errorhandler(404)
@app.errorhandler(405)
def handle_error(ex):
    if request.path.startswith('/api/'):
        return jsonify(error=str(ex)), ex.code
    return ex

The workaround is to register a single application-level handler and branch on `request.path` yourself, exactly the pattern shown above, rather than expecting per-blueprint 404 handlers to catch unmatched routes.

Registering one blueprint on another (`parent.register_blueprint(child)`) chains both the name and the URL prefix: the child's endpoint name gets the parent's name prepended, and the child's routes get the parent's `url_prefix` prepended too.

python
parent = Blueprint('parent', __name__, url_prefix='/parent')
child = Blueprint('child', __name__, url_prefix='/child')
parent.register_blueprint(child)
app.register_blueprint(parent)

url_for('parent.child.create')  # -> '/parent/child/...'

Blueprint-level `before_request` hooks registered on the parent also apply to the child automatically, and if the child doesn't have an error handler for a given exception, the parent's is tried next. This is genuinely useful for something like an `/api/v1` blueprint that nests per-resource child blueprints underneath it, sharing common setup without repeating it in each one.

Flask-Limiter is the standard choice: you attach it to the app, then apply a `@limiter.limit(...)` decorator to the specific view, or set an app-wide default and override it per route. It tracks request counts per client, usually keyed by IP address or an authenticated user ID, against a storage backend, in-memory for a single process, Redis for anything running more than one worker.

python
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(app, key_func=get_remote_address, storage_uri="redis://localhost:6379")

@app.route('/api/login', methods=['POST'])
@limiter.limit("5 per minute")
def login():
    ...

The Redis backend detail matters in a real interview: an in-memory limiter resets per process and doesn't share state across gunicorn workers, so under multi-worker deployment a "5 per minute" limit actually allows 5 times the worker count unless the storage is centralized.

Werkzeug generates a PIN at startup, derived partly from machine-specific values (like the machine ID and a hashed value from `/etc/machine-id` or similar) and printed to the server's console output, not sent anywhere over the network. Anyone trying to use the debugger console in a browser has to enter that PIN first.

The catch: whoever has shell access to the server console can read the PIN from the logs, and the PIN generation algorithm has had known weaknesses discussed publicly over the years that make it brute-forceable in some configurations. The debugger being reachable from the public internet at all is the actual vulnerability; the PIN is a speed bump on top of a door that shouldn't be unlocked in the first place. Debug mode has no place in a production deployment regardless of PIN protection.

Flask 2.0 lets you write `async def` view functions, and Flask runs the coroutine for you under the hood using an async adapter. The catch: Flask itself is still fundamentally a WSGI (synchronous) framework, not ASGI, so an async view doesn't get real concurrency for free the way it would in a framework built async-first, like FastAPI.

python
@app.route('/data')
async def get_data():
    result = await fetch_from_api()  # genuinely async I/O, fine
    return result

@app.route('/bad')
async def bad():
    time.sleep(2)  # blocking call inside an async view still blocks the worker
    return 'done'

Calling blocking code, `time.sleep()`, a synchronous database driver, a synchronous HTTP client, inside an `async def` view blocks that worker exactly as it would in a sync view, since there's no event loop underneath handling other requests while it waits. The async keyword doesn't change the deployment model; you still scale concurrency with more worker processes, not by writing `async def` everywhere.

Each gunicorn worker is a separate OS process with its own Python interpreter and its own memory space. A module-level dict used as a cache, a counter, or a simple in-process rate limiter exists independently in every worker, so writes made by a request handled by worker 1 are invisible to a request handled by worker 2.

The fix is moving that shared state somewhere all workers can reach, Redis for a cache or rate limiter, the actual database for anything durable, rather than a language-level data structure that only lives inside one process. This is the single most common "worked fine locally, broke in production" bug in Flask apps that were developed and load-tested against a single dev-server process the whole time.

How to prepare for a Flask interview

Don't just reread the quickstart. Build one small thing that forces you past the tutorial-level basics: an app factory with two blueprints registered on it, one Flask-SQLAlchemy model with a real Flask-Migrate migration applied, and one endpoint protected by Flask-Limiter backed by Redis, not the in-memory default. Then deliberately break it, run it under two gunicorn workers and watch an in-memory counter give you inconsistent results, so you've actually seen the failure mode an interviewer is really asking about when they bring up scaling.

Across mock interviews run through LastRoundAI, candidates who can recite what a Blueprint is rarely can explain why a blueprint-level 404 handler doesn't fire for an unmatched URL, that gap between reciting a definition and reasoning about the actual dispatch behavior is usually where a Flask interview turns from a pass to a maybe. It's not a precise statistic we track, just a pattern that shows up often enough in review to be worth flagging here.

One more thing worth knowing going into an interview in 2026: Flask sitting at 13.2 percent among professional developers while FastAPI climbs to 15.1 percent doesn't mean Flask is fading, both numbers come from the same survey and plenty of teams run both, Flask for a stable existing service, FastAPI for a new async-first one. An interviewer asking "why Flask over FastAPI" is usually checking whether you can articulate the actual trade-off, WSGI simplicity and a mature extension ecosystem versus native async and automatic OpenAPI docs, rather than looking for a winner.

Get the reps in before the real thing

Explaining the difference between application context and request context on paper is not the same as defending it out loud when an interviewer asks what breaks if you skip `app_context()` in a background script. LastRoundAI's mock interview mode runs live technical rounds with real-time follow-up questions in your browser, and the free plan includes 15 credits a month that reset monthly rather than piling up unused. Starter is $19/mo if a handful of sessions isn't enough runway.

Once your answers hold up under a follow-up, the slower part of the job hunt is usually just getting in front of enough backend and full-stack roles that actually test Flask instead of listing it as a buzzword. Auto-Apply queues tailored applications for your review, 10 a month on the free plan, up to 400 a month on the Ultimate plan, and nothing goes out until you approve it.

Questions about either product go to contact@lastroundai.com. That's the only inbox we check.

Leave a Reply

Your email address will not be published. Required fields are marked *