The 60 Flask questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced backend rounds.
60 questions with answersKey Takeaways
Flask is a lightweight web framework for Python, first released by Armin Ronacher in 2010, built on the Werkzeug WSGI toolkit and the Jinja2 template engine. It calls itself a microframework, which means the core gives you routing, request and response objects, and templating, while database layers, form validation, and authentication come from extensions you choose. That minimalism is the point: you decide the structure instead of the framework deciding for you. In interviews, Flask questions probe the request lifecycle, the application and request contexts, blueprints for larger apps, and the jump from a single-file demo to a production deployment behind a real WSGI server. This page collects the 60 questions that come up most, each with a direct answer and runnable code. If you're filling gaps, the official Flask documentation from Pallets Projects is the canonical reference. Increasingly the first backend round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Learn Flask for Python - Full Tutorial
Video: Learn Flask for Python - Full Tutorial (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Flask certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
Flask is a lightweight web framework for Python built on the Werkzeug WSGI toolkit and the Jinja2 template engine. It gives you routing, request and response handling, and templating in a small core, and leaves database access, forms, and auth to extensions you add.
You choose Flask when you want control over structure and a small footprint: APIs, microservices, and apps that don't need a full stack out of the box. It's minimal by design, so you assemble only what the project needs.
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return "Hello, Flask"
if __name__ == "__main__":
app.run(debug=True)Key point: A one-line definition plus the microframework trade-off (small core, add extensions) beats a memorized feature list. Interviewers open with this to hear how you organize an answer.
Watch a deeper explanation
Video: Python Flask Tutorial: Full-Featured Web App Part 1 - Getting Started (Corey Schafer, YouTube)
Flask is a microframework: a small core with routing and templating, and you add the rest through extensions. Django is batteries-included: it ships with an ORM, admin interface, auth, and forms, plus more convention about how you lay out a project.
Flask suits smaller services and APIs where you want control; Django suits full-stack apps where the built-ins save time. Neither is better in the abstract, and saying that is the answer the question needs.
| Flask | Django | |
|---|---|---|
| Philosophy | Microframework, minimal core | Batteries-included |
| ORM | Add one (SQLAlchemy) | Built-in Django ORM |
| Admin panel | None by default | Auto-generated admin |
| Best for | APIs, microservices, small apps | Full-stack, content-heavy apps |
Key point: Don't declare a winner. The signal is that you match the tool to the project, and can The each gives up.
Routing maps a URL to a view function with the @app.route decorator. When a request comes in, Werkzeug matches the request path against the registered URL rules and calls the first matching view, and whatever that view returns becomes the HTTP response body sent back to the client.
Routes can include dynamic parts like /user/<username>, restrict HTTP methods, and use converters like <int:id> to validate and cast segments.
@app.route("/user/<username>")
def profile(username):
return f"Profile of {username}"
@app.route("/post/<int:post_id>")
def show_post(post_id):
return f"Post {post_id}" # post_id is an int, not a stringConverters validate and cast a dynamic URL segment before your view runs. <int:id> matches only integers and passes a real int; <float:price> matches floats; <path:subpath> matches slashes too; and the default <name> with no converter matches any text without a slash. Pick the converter that fits the value you expect.
If the segment doesn't match the converter, Flask returns a 404 before your view runs, so you get free input validation on the URL.
@app.route("/files/<path:filepath>")
def serve(filepath):
return f"Serving {filepath}" # matches nested/paths/with/slashes
@app.route("/order/<int:order_id>")
def order(order_id):
return f"Order #{order_id}" # 404 if not an integerPass a methods list to the route: @app.route("/submit", methods=["GET", "POST"]). Without it, a route only accepts GET (plus HEAD and OPTIONS, which Flask adds for you). Inside the view, check request.method to branch between showing a form on GET and handling its submission on POST, which is a common single-URL pattern.
A single URL commonly serves both: GET renders the form, POST processes it.
from flask import request
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
user = request.form["username"]
return f"Welcome {user}"
return "<form method=post><input name=username></form>"Key point: Forgetting to add POST to methods and then wondering why the form 405s is a classic beginner slip. the 405 matters.
request is a global that gives the current view access to the incoming HTTP request: request.form for submitted form fields, request.args for query-string values, request.json for a parsed JSON body, plus request.headers, request.cookies, request.files for uploads, and request.method for the verb used.
It's request-scoped: Flask binds the right request for each call, so the same global name safely points to different data across concurrent requests.
from flask import request
@app.route("/search")
def search():
query = request.args.get("q", "") # ?q=flask
page = request.args.get("page", 1, type=int)
return f"Searching {query}, page {page}"Jinja2 is the template engine Flask uses to generate HTML. You call render_template with a file from the templates/ folder and pass variables; Jinja fills in {{ placeholders }}, runs {% for %} and {% if %} blocks, and returns the finished HTML.
Jinja auto-escapes variables by default, which protects against most cross-site scripting when you output user data.
from flask import render_template
@app.route("/users")
def users():
people = ["Asha", "Ben", "Cara"]
return render_template("users.html", people=people)
# templates/users.html:
# <ul>{% for p in people %}<li>{{ p }}</li>{% endfor %}</ul>Watch a deeper explanation
Video: Python Flask Tutorial: Full-Featured Web App Part 2 - Templates (Corey Schafer, YouTube)
Template inheritance lets a base template define the shared page shell with named {% block %} regions, and child templates extend it and fill only the blocks that change. You write the header, navigation, and footer once in the base, and every page reuses them, so a layout change happens in a single file.
The child starts with {% extends "base.html" %} and overrides blocks like {% block content %}. This keeps markup DRY across many pages.
<!-- base.html -->
<html><body>
<nav>My Site</nav>
{% block content %}{% endblock %}
</body></html>
<!-- page.html -->
{% extends "base.html" %}
{% block content %}<h1>Home</h1>{% endblock %}url_for builds a URL from an endpoint (view function) name rather than a literal path, so url_for("profile", username="asha") returns the URL for that view with the argument filled into the right dynamic segment. Extra keyword arguments that aren't route parameters become query-string values.
Use it because URLs then live in one place: change a route's path and every link updates automatically. It also handles the URL prefix, escaping, and static-file paths correctly.
from flask import url_for, redirect
@app.route("/go")
def go():
return redirect(url_for("profile", username="asha"))
# in a template: <a href="{{ url_for('home') }}">Home</a>Key point: Hardcoded hrefs that break on a route rename are a real-world smell. Reaching for url_for signals you've maintained a Flask app.
Flask serves files from a static/ folder mapped to the /static URL by default, so CSS, JavaScript, and images placed there are reachable without extra routing. You link to them with url_for("static", filename="style.css"), which produces the correct path even when the app runs under a URL prefix or a different static route.
In production you usually let a reverse proxy or CDN serve static files instead of Flask, because a web server does that job faster.
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<img src="{{ url_for('static', filename='logo.png') }}">Return a dict (or a list, since Flask 2.2) and Flask serializes it to JSON automatically with the application/json content type, a feature added in Flask 1.1. For explicit control, use jsonify, which sets the proper headers, handles the serialization, and pairs cleanly with a status code in a returned tuple.
This is the base of most Flask APIs: a view that returns data, and the client parses JSON.
from flask import jsonify
@app.route("/api/user")
def api_user():
return {"id": 1, "name": "Asha"} # auto JSON
@app.route("/api/status")
def status():
return jsonify(ok=True), 200 # explicitrequest.args reads the query string (the ?key=value part of the URL), used mostly on GET requests for filters and pagination. request.form reads form-encoded body fields from an HTML form POST. request.json parses a JSON request body into a Python dict, which is what an API client typically sends.
Picking the wrong one is a common bug: reading request.form on a JSON API returns nothing because the body isn't form-encoded.
| Source | Reads | Typical use |
|---|---|---|
| request.args | URL query string | GET filters, search, pagination |
| request.form | Form-encoded body | HTML form submissions |
| request.json | JSON body | API clients sending JSON |
Key point: The follow-up is 'what happens if the JSON body is malformed?'. request.json raises; request.get_json(silent=True) returns None instead.
session is a dict-like object for storing small amounts of data across requests from the same user, like a logged-in user's id. By default Flask serializes it into a signed cookie stored on the client, so the server keeps no session state and scaling out to many servers needs no shared session store.
Because it's a cookie, it needs SECRET_KEY set to sign it, the data is visible to the user (signed, not encrypted), and it's limited to about 4KB.
from flask import session
app.secret_key = "change-me-in-production"
@app.route("/set")
def set_user():
session["user_id"] = 42
return "stored"
@app.route("/get")
def get_user():
return str(session.get("user_id"))SECRET_KEY is a long random value Flask uses to cryptographically sign the session cookie and other security tokens like the CSRF token. The signature lets the server detect tampering, so a user can read their session data but can't alter it without invalidating the signature. Without a key set, anything that signs data raises an error.
It must be long, random, and kept out of source control, loaded from an environment variable or secret manager. If it leaks, an attacker can forge session cookies.
Key point: The trap is hardcoding a weak key in the repo. Say it comes from an environment variable and you've shown production awareness.
app.config is a dict-like object of settings. You can set keys directly, load them from an object or class with app.config.from_object, from a Python file, or from environment variables with from_prefixed_env. Common keys include SECRET_KEY, DEBUG, and extension settings like SQLALCHEMY_DATABASE_URI that extensions read at init time.
The clean pattern is config classes per environment (Development, Production) selected at startup, so secrets and debug flags differ by deployment without code changes.
class Config:
SECRET_KEY = os.environ["SECRET_KEY"]
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
class Dev(Config):
DEBUG = True
app.config.from_object(Dev)Debug mode (app.run(debug=True) or FLASK_DEBUG=1) turns on two things: the auto-reloader that restarts the server whenever you save a file, and the interactive Werkzeug debugger that renders a full traceback in the browser and lets you inspect frames when a view raises an error.
You turn it off in production because that interactive debugger can execute arbitrary code through the browser, which is a serious security hole, and the reloader wastes resources. Production runs with debug off behind a real WSGI server.
Key point: Saying 'the Werkzeug debugger lets you run code from the browser' is the specific reason the key point is, not just 'it's insecure'.
Returning a string sends it as the response body with a 200 status. render_template renders a Jinja template to HTML and returns that finished HTML. redirect returns a 3xx response with a Location header that tells the browser to request a different URL instead, which is a different kind of response entirely.
The common pattern is Post/Redirect/Get: handle a POST, then redirect to a GET page so a browser refresh doesn't resubmit the form.
from flask import redirect, url_for, render_template
@app.route("/save", methods=["POST"])
def save():
# ...persist the data...
return redirect(url_for("done")) # avoids double-submit on refresh
@app.route("/done")
def done():
return render_template("done.html")Register a handler with @app.errorhandler(status_or_exception). Flask calls that function when the matching error occurs anywhere in request handling, and you return your own response and status code from it. You can register handlers for HTTP codes like 404 and 500, or for your own custom exception classes, which keeps error output consistent.
Use abort(404) to trigger an error deliberately from a view. Handlers keep error responses consistent, which matters most for APIs returning JSON errors.
from flask import abort
@app.errorhandler(404)
def not_found(e):
return {"error": "not found"}, 404
@app.route("/item/<int:i>")
def item(i):
if i > 100:
abort(404)
return {"id": i}Extensions are third-party packages that add features Flask's small core deliberately leaves out, following a shared initialization pattern (often an init_app method) so they plug into the app cleanly and respect the factory pattern. This is how the microframework grows to a full app: you install and wire up only the pieces the project actually needs.
Common ones: Flask-SQLAlchemy (database ORM), Flask-Login (user sessions), Flask-WTF (forms and CSRF), Flask-Migrate (schema migrations), and Flask-RESTful or Flask-Smorest for APIs.
You can read raw fields from request.form, but most apps use Flask-WTF, which defines forms as Python classes with typed fields and validators, renders each field in templates, collects validation errors, and adds CSRF protection automatically so you don't wire that up by hand for every form.
The view checks form.validate_on_submit(), which returns True only on a valid POST, so one block handles both showing the form and processing a good submission.
from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms.validators import DataRequired
class NameForm(FlaskForm):
name = StringField("Name", validators=[DataRequired()])
@app.route("/name", methods=["GET", "POST"])
def name():
form = NameForm()
if form.validate_on_submit():
return f"Hi {form.name.data}"
return render_template("name.html", form=form)flash() stores a one-time message that survives a redirect and shows on the very next request, then disappears once read. It's how you show 'Saved successfully' or 'Login failed' after a Post/Redirect/Get cycle, where the message must outlive the redirect but not persist beyond the page the user lands on.
The template reads them with get_flashed_messages(). Messages live in the session, so they need SECRET_KEY like everything session-backed.
from flask import flash, redirect, url_for
@app.route("/save", methods=["POST"])
def save():
flash("Saved successfully", "success")
return redirect(url_for("home"))
# template: {% for msg in get_flashed_messages() %}{{ msg }}{% endfor %}Filters transform a value inside a template with a pipe: {{ name | upper }} uppercases it, {{ price | round(2) }} rounds it, and {{ items | length }} counts them. Jinja ships many built-ins for strings, numbers, lists, and dates, and you chain them left to right.
You can register your own filter on the app for project-specific formatting, like turning a raw timestamp into a friendly date, so templates stay readable and logic stays out of them.
@app.template_filter("money")
def money(value):
return f"${value:,.2f}"
# in a template:
# {{ total | money }} -> $1,299.00
# {{ name | default("guest") | title }}Most views return a string, dict, or template and let Flask build the response. When you need to set headers, cookies, a status code, or the content type explicitly, wrap the body with make_response to get a Response object you can mutate before returning it.
This is how you return non-HTML content, attach a Content-Disposition header for a download, or set a cookie alongside a rendered page, all in one place.
from flask import make_response
@app.route("/report.csv")
def report_csv():
resp = make_response("id,name\n1,Asha\n")
resp.headers["Content-Type"] = "text/csv"
resp.headers["Content-Disposition"] = "attachment; filename=report.csv"
return respInstead of creating the app object at module level, you write a create_app() function that constructs the Flask instance, loads config, binds extensions with init_app(app), registers blueprints, and returns the finished app. Nothing runs until you call the factory, so the app is built on demand rather than at import time.
This lets you build the app with different configs (testing vs production), avoids import-time side effects, and makes testing clean because each test can create a fresh app.
from flask import Flask
def create_app(config="config.Dev"):
app = Flask(__name__)
app.config.from_object(config)
db.init_app(app) # extension bound here
app.register_blueprint(main_bp)
return appKey point: Freshers who know the factory pattern stand out. It's the bridge to blueprints and testing, so expect a follow-up on why it helps tests.
For candidates with working experience: contexts, blueprints, databases, and the mechanics that separate users from understanders.
The request context tracks data for one incoming request and powers the request and session globals. The application context tracks the active application and powers current_app and g. Flask pushes both when a request comes in and pops them when it ends.
The split exists because some work (CLI commands, background jobs) needs the app but not a request. You can push an app context manually with app.app_context() for those.
| Context | Provides | Lives for |
|---|---|---|
| Application context | current_app, g | A request or a manual with-block |
| Request context | request, session | One HTTP request |
Key point: The 'working outside of application context' error is the practical hook here. If you can explain when it fires, you understand contexts.
That error means code touched current_app, g, or an extension bound to the app while no application context was active. It shows up most often in a standalone script, a background thread or task, or at import time before the app was created, anywhere outside the normal request cycle where Flask would have pushed a context for you.
The fix is to push a context explicitly with a with app.app_context(): block, or in the case of extensions, do the work inside a request or a Flask CLI command where a context already exists.
# fails: no context outside a request
# db.session.add(user)
with app.app_context():
db.session.add(user)
db.session.commit() # works: context is activeg is a per-request scratch space that lives on the application context, meant for stashing data you compute once and reuse across a single request, like the current user object or a database connection you opened in a before_request hook. Any view or helper running in that request can read what you put there.
It resets every request, so it's not for caching between requests or sharing across users. Confusing g with the session is a common mistake.
from flask import g
@app.before_request
def load_user():
g.user = get_user_from_session()
@app.route("/dashboard")
def dashboard():
return f"Hello {g.user.name}" # reused from gA blueprint is a group of routes, templates, and static files defined independently of the app, then registered onto it, optionally under a URL prefix. You split a large app into modules like auth, admin, and api, each its own blueprint.
Blueprints keep the codebase organized, let you mount features under prefixes, and make features reusable. They pair naturally with the application factory: create the app, register blueprints.
from flask import Blueprint
auth = Blueprint("auth", __name__, url_prefix="/auth")
@auth.route("/login")
def login():
return "login page" # served at /auth/login
# in create_app: app.register_blueprint(auth)Key point: Interviewers ask 'how would you structure a growing Flask app?'. Blueprints plus the factory pattern is the expected answer.
Watch a deeper explanation
Video: Python Flask Tutorial: Full-Featured Web App Part 5 - Package Structure (Corey Schafer, YouTube)
Flask-SQLAlchemy wraps SQLAlchemy for Flask. You set SQLALCHEMY_DATABASE_URI in config, create a db = SQLAlchemy() object, bind it to the app with db.init_app(app), define models as classes that subclass db.Model with columns as class attributes, and read or write rows through db.session and the model query interface.
It handles the session lifecycle per request, so you get a fresh session each request and it's cleaned up at the end.
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True)
# query
user = User.query.filter_by(email="a@b.com").first()An ORM maps database tables to Python classes, so you write queries in Python, get relationship handling and schema migrations, and stay mostly database-agnostic across engines. The cost is a learning curve and the risk of hidden inefficiency, most famously the N+1 query problem where lazy loading fires one extra query per row.
Raw SQL gives you full control and predictable performance for complex queries, at the cost of writing and maintaining the SQL yourself and losing portability. Many teams use the ORM for everyday CRUD and drop to raw SQL for hot, complex queries.
| Aspect | ORM (SQLAlchemy) | Raw SQL |
|---|---|---|
| Speed to write CRUD | Fast | Slower, more boilerplate |
| Complex query control | Can be awkward | Full control |
| Portability | Database-agnostic | Tied to the dialect |
| Performance risk | N+1 queries if careless | You own every query |
Key point: the N+1 problem is useful because is a strong signal you've debugged real ORM code.
Flask-Migrate wraps Alembic to version your schema. You generate a migration from model changes with flask db migrate, The generated script, and apply it with flask db upgrade matters. Each migration is a file in version control, so schema history is tracked and reversible.
The important discipline is reviewing autogenerated migrations before running them, because Alembic can miss or misread some changes.
flask db init # once, sets up migrations/
flask db migrate -m "add user table"
flask db upgrade # apply to the database
flask db downgrade # roll back one revisionbefore_request runs before every view and is where you load the current user, open a connection, or reject unauthenticated requests. after_request runs after the view and receives the response so you can add headers. teardown_request runs when the request context tears down, even on error, for cleanup.
These hooks centralize cross-cutting behavior instead of repeating it in every view.
@app.before_request
def require_login():
if "user_id" not in session and request.endpoint != "login":
return redirect(url_for("login"))
@app.after_request
def add_headers(resp):
resp.headers["X-Frame-Options"] = "DENY"
return respDefine routes that map HTTP methods to CRUD operations, read input from request.json, return dicts or jsonify output, and set correct status codes: 200 for reads, 201 for creates, 404 for missing, 400 for bad input. For structure, extensions like Flask-RESTful or Flask-Smorest add class-based resources and schema validation.
A clean API keeps status codes honest and separates the data layer from the view, so serialization (often with Marshmallow or Pydantic) lives in one place.
from flask import request, jsonify
@app.route("/api/tasks", methods=["POST"])
def create_task():
data = request.get_json()
if not data or "title" not in data:
return jsonify(error="title required"), 400
task = save_task(data["title"])
return jsonify(id=task.id, title=task.title), 201Watch a deeper explanation
Video: Python REST API Tutorial - Building a Flask REST API (Tech With Tim, YouTube)
Return a tuple of (body, status) or (body, status, headers) from a view, and Flask builds the response with those set for you. For finer control, use make_response to get a mutable response object, or return jsonify output paired with a status code. Either way you control exactly what code and headers the client receives.
Getting status codes right matters most for APIs: 201 on create, 204 on a successful delete with no body, 401 versus 403 for auth, and 422 for validation failures.
from flask import make_response
@app.route("/api/item", methods=["POST"])
def create():
resp = make_response({"created": True}, 201)
resp.headers["Location"] = "/api/item/1"
return respCSRF (cross-site request forgery) tricks a logged-in user's browser into silently sending a state-changing request they didn't intend, riding on the session cookie the browser attaches automatically. The defense is a secret token tied to the session that legitimate forms include in the request and an attacker on another site can't know or guess.
Flask-WTF adds CSRF protection automatically: it injects a hidden token into forms and rejects POSTs without a valid one. For pure JSON APIs you often rely on token auth instead, since CSRF targets cookie-based auth.
Key point: Knowing that CSRF specifically threatens cookie-session auth, and less so token-based APIs, is the nuance that separates a memorized answer.
Jinja auto-escapes variables in HTML templates by default, converting characters like <, >, and & into safe HTML entities, so user data rendered with {{ value }} shows up as text and can't inject markup or run a script. This handles the most common cross-site scripting vector without any effort from you.
The protection is off when you mark content safe with the |safe filter or the Markup class, which you should only do for content you fully trust. Rendering unescaped user input is the classic Flask XSS bug.
{{ comment }} <!-- escaped, safe -->
{{ comment | safe }} <!-- NOT escaped: only for trusted HTML -->WSGI (Web Server Gateway Interface) is the Python standard that defines how a web server talks to a Python web application: the server calls the app with the request environment and a start_response function, and the app returns the response body.
Flask is a WSGI application built on Werkzeug, which implements the WSGI plumbing. That's why you run Flask behind a WSGI server like Gunicorn: Gunicorn speaks HTTP to the world and WSGI to your Flask app.
Key point: The clean one-liner is 'Flask is the WSGI app, Gunicorn is the WSGI server'. Interviewers use this to check you understand the deployment stack.
Run the app under a production WSGI server like Gunicorn or uWSGI with several worker processes, put a reverse proxy like Nginx in front to terminate TLS, serve static files, and buffer slow clients, and turn debug off. Config and secrets come from environment variables, never from code committed to the repo.
Most teams package it as a container image with a pinned environment, so the app runs the same in CI and production. The flow is browser to Nginx to Gunicorn to your Flask app.
Flask production request path
Debug mode stays off in production. Workers scale with CPU cores for sync apps.
Watch a deeper explanation
Video: Build & Deploy A Python Web App | Flask, Postgres & Heroku (Traversy Media, YouTube)
Gunicorn runs several worker processes, each handling requests independently, which sidesteps Python's GIL for CPU work because separate processes don't share one interpreter lock. A common starting point for sync workers is roughly (2 x CPU cores) + 1, then you adjust up or down based on measured latency and memory use.
For I/O-heavy apps you can switch worker classes to gevent or use threads per worker to handle more concurrent connections. The right count depends on whether your workload is CPU-bound or I/O-bound, and you tune it by measuring.
gunicorn --workers 5 --bind 0.0.0.0:8000 'app:create_app()'
# 5 worker processes; formula: (2 * cores) + 1Flask uses Python's standard logging module and exposes app.logger as a ready-made logger. Configure handlers and formatters once at startup, log at appropriate levels (info, warning, error), and in production send logs to stdout or a file so the platform collects and ships them. Avoid print for anything beyond a quick local check.
For services, structured logging (JSON) with a request id makes logs searchable and lets you trace a single request across systems.
import logging
logging.basicConfig(level=logging.INFO)
@app.route("/pay", methods=["POST"])
def pay():
app.logger.info("payment started for user %s", g.user.id)
return {"ok": True}Flask provides a test client (app.test_client()) that sends requests to your app without a running server, so you assert on status codes and response bodies. Pair it with pytest fixtures that build an app with a test config and a fresh database.
The application factory makes this clean: each test creates its own app, so tests stay isolated. Test behavior through endpoints, and mock external boundaries like third-party APIs.
import pytest
from app import create_app
@pytest.fixture
def client():
app = create_app("config.Test")
return app.test_client()
def test_home(client):
resp = client.get("/")
assert resp.status_code == 200Flask integrates with Click, so you register commands with @app.cli.command() (or on a blueprint) and run them with flask <command>. These run inside an application context, which makes them right for admin tasks like seeding data or creating an admin user.
Custom commands keep operational scripts inside the app where they share config and database setup, instead of ad hoc standalone scripts.
@app.cli.command("seed")
def seed():
"""Populate the database with sample data."""
db.session.add(User(email="admin@site.com"))
db.session.commit()
print("seeded")
# run: flask seedDeployment-specific values (database URLs, API keys, SECRET_KEY) come from environment variables, read once at startup into the config object rather than scattered as os.environ calls through the code. Secrets come from the platform's secret manager and never live in the repo, so the same image runs in every environment with only the variables changing.
Locally, a .env file loaded by python-dotenv is convenient, but it must stay out of version control and out of production images. Config classes per environment select the right set at boot.
Key point: Naming the anti-pattern (secrets committed to the repo, or a .env leaking into a Docker image) shows you've owned a real deployment.
advanced rounds probe internals, design judgment, and production scars. Expect every answer here to draw a follow-up.
The WSGI server hands the request to Flask, which pushes the request and application contexts, then runs before_request hooks. The URL map matches the path to an endpoint, the view runs and returns a value, and Flask converts that value into a Response object.
Then after_request hooks run and can modify the response, the response goes back through WSGI to the client, and teardown handlers fire as the contexts pop, even if an error occurred. Errors along the way route to the matching error handler.
One request through Flask
Any exception short-circuits to the matching error handler; teardown still runs.
Key point: This is the canonical senior Flask question. Naming context push/pop and the before/after/teardown ordering is the bar.
request, session, current_app, and g are not real objects but thread-local (and coroutine-local) proxies from Werkzeug's LocalProxy over a context-local stack. Each worker thread or task gets its own bound context, so the same global name resolves to the correct per-request object.
This is why touching them outside a context raises: there's nothing bound on the stack. It's also why the pattern is safe under Gunicorn threads: the proxy dispatches to whatever context is active for the current execution.
Key point: If you can say 'they're LocalProxy objects over a context stack', you've shown you understand why the globals aren't a concurrency bug.
Since Flask 2.0 you can write async def views, and Flask runs them on an event loop per request. That helps when a view awaits I/O like an async HTTP call. But Flask is still a WSGI framework: it runs the coroutine to completion inside a worker, so you don't get the concurrency model of a native ASGI framework.
For genuinely high-concurrency async workloads, an ASGI framework like FastAPI or Quart (the async twin of Flask) fits better. Knowing that async views in Flask are a convenience, not a rearchitecture, is the honest answer.
import httpx
@app.route("/proxy")
async def proxy():
async with httpx.AsyncClient() as client:
r = await client.get("https://api.example.com/data")
return r.json()N+1 happens when you fetch N parent rows, then trigger one extra query per parent to load a relationship, so a page that shows 100 users with their teams runs 101 queries. It's the most common ORM performance bug and often invisible until traffic grows.
The fix is eager loading: joinedload does a JOIN, selectinload runs one extra IN query for all children at once. You pick based on the relationship shape. Query logging or a tool like Flask-DebugToolbar surfaces the problem.
from sqlalchemy.orm import selectinload
# N+1: one query per user's team
users = User.query.all()
for u in users:
print(u.team.name) # extra query each loop
# fixed: two queries total
users = User.query.options(selectinload(User.team)).all()Key point: This question separates people who've run Flask at scale. Naming joinedload vs selectinload and when each fits is the production signal.
Flask-SQLAlchemy scopes the session to the request: you get one session per request and it's removed at teardown. Within a request you add and query through db.session and commit once at the end, so the request is one logical unit of work.
It goes wrong when you use the session outside a request context (background threads, scripts) without managing it yourself, when a long-running request holds a connection too long, or when an uncaught exception leaves the session in a dirty state that the next query inherits. Explicit rollback on error and short transactions fix most of it.
SQLAlchemy keeps a pool of open database connections and hands one to each request, returning it to the pool afterward instead of opening a new TCP and auth handshake every time. Under Gunicorn, each worker process has its own pool, so total connections are workers times pool size.
It matters because databases cap connections. Multiply workers by pool size across all app instances and you can exhaust the database's limit, causing new requests to hang. Sizing the pool and worker count against the database's max connections is a real production concern, and pgbouncer sits in front when the math gets tight.
Key point: Doing the 'workers x pool size x instances vs database max connections' math out loud is exactly what this question is checking for.
Cache at the layers that hurt: expensive computed responses with Flask-Caching (memoize a view or function, backed by Redis or Memcached), repeated database reads behind a cache-aside pattern, and static assets at the CDN with cache headers. Set sensible TTLs and a clear invalidation story.
The hard part is invalidation, not caching. Decide upfront how a cached value gets refreshed or evicted when the underlying data changes, because stale caches cause subtler bugs than slow pages.
from flask_caching import Cache
cache = Cache(config={"CACHE_TYPE": "RedisCache"})
@app.route("/report")
@cache.cached(timeout=300)
def report():
return build_expensive_report() # cached 5 minutesYou don't run them in the request. A slow job (sending email, generating a report, calling a slow API) goes to a task queue like Celery or RQ, backed by Redis or RabbitMQ: the view enqueues the job and returns immediately, and a separate worker process runs it.
The worker needs its own application context to touch the database or config, so tasks push app_context() themselves. This keeps requests fast and lets you retry, schedule, and scale workers independently.
@celery.task
def send_report(user_id):
with app.app_context(): # worker needs its own context
user = User.query.get(user_id)
deliver(build_report(user))
@app.route("/report", methods=["POST"])
def trigger():
send_report.delay(g.user.id) # returns instantly
return {"queued": True}, 202A server-rendered app uses cookie sessions: Flask-Login manages a logged-in user in the signed session cookie, tracks who's authenticated across requests, and gives you a login_required decorator, while CSRF protection guards state-changing forms. It's simple because the browser attaches the session cookie to every request automatically, so you rarely touch the token yourself.
A stateless API uses tokens: the client sends a signed JWT or an opaque token in the Authorization header, the server verifies it per request, and there's no server session. CSRF isn't the same threat because you're not relying on cookies. The trade-off is token revocation, which stateless JWTs make harder without a denylist.
| Cookie session | Token (JWT/opaque) | |
|---|---|---|
| State | Server or signed cookie | Stateless (or token store) |
| Sent via | Cookie (automatic) | Authorization header |
| CSRF risk | Yes, needs protection | Not the same threat |
| Revocation | Easy (drop the session) | Harder for stateless JWT |
Because Flask is a WSGI app, you wrap app.wsgi_app with a WSGI middleware callable that sees the raw environ and response before and after Flask. ProxyFix is the everyday example: it corrects request.remote_addr and the scheme when the app sits behind a reverse proxy.
Middleware operates below Flask's request handling, so it's the right layer for things that must run for every request regardless of routing, like proxy header fixing or low-level request rewriting.
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
# now request.remote_addr and url_for scheme respect the proxyUse the application factory plus blueprints: create_app() builds and configures the app, extensions are module-level objects bound with init_app, and each feature area (auth, api, admin) is a blueprint package with its own routes, templates, and models. Config lives in classes selected by environment.
Layer the code too: keep views thin, push business logic into a service layer, and isolate database access, so views orchestrate rather than contain logic. This makes the app testable and keeps blueprints from becoming dumping grounds.
Key point: There's no single blessed layout, so the question needs your reasoning: factory, blueprints, thin views, config classes. Defend the choices, don't recite a folder tree.
Flask-Limiter handles the common case: decorate routes with limits like 100 per minute, keyed by client IP or authenticated user, backed by Redis so limits are shared across all workers and instances. It returns 429 with a Retry-After header when a client exceeds the limit.
The design choices to raise: the key (IP is spoofable and shared behind NAT, so prefer an authenticated user or API key), the storage (in-memory per-process limits don't hold across workers), and the algorithm (fixed window is simple but bursts at boundaries; sliding window or token bucket is smoother).
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(get_remote_address, app=app,
storage_uri="redis://localhost:6379")
@app.route("/api/search")
@limiter.limit("100 per minute")
def search():
return do_search()The essentials: a strong SECRET_KEY from the environment, debug turned off, HTTPS everywhere with secure and httponly session cookies, CSRF protection on cookie-session forms, and Jinja auto-escaping left on so templates don't leak XSS. Add security headers like Content-Security-Policy, X-Frame-Options, and HSTS, and validate every piece of input before you trust it.
Beyond that: parameterized queries or the ORM to block SQL injection, rate limiting on auth endpoints, dependency scanning for known vulnerabilities, and secrets kept out of the repo. Most Flask breaches trace to one of these being skipped, not to an exotic attack.
FastAPI fits async-first APIs at high concurrency where you want type-hint request validation, automatic OpenAPI and Swagger docs, and native ASGI performance out of the box without bolting on extra libraries. For a brand-new JSON API with heavy I/O and many concurrent clients, it's the modern default and saves a lot of wiring.
Flask fits when the ecosystem and team knowledge favor it, when you're rendering server-side HTML with Jinja, when the app is sync and CPU or template heavy, or when you want the mature, stable extension ecosystem. Both are excellent; the deciding factors are async needs, validation style, and existing code.
| Flask | FastAPI | |
|---|---|---|
| Server interface | WSGI (sync core) | ASGI (async native) |
| Input validation | Add-on (Marshmallow, Pydantic) | Built-in via type hints |
| API docs | Add an extension | Automatic OpenAPI/Swagger |
| Best fit | Server-rendered apps, sync services | High-concurrency async APIs |
Key point: The weak answer is 'FastAPI is faster'. The strong one weighs async needs, validation style, HTML rendering, and team context.
Observe first: application logs and request timing to find which endpoint and how slow, then check whether it's database (slow or N+1 queries), external calls (missing timeouts, retry storms), or CPU work blocking a sync worker. APM traces or query logging point at the hot layer fast.
Then fix at the right layer and confirm with a measurement: eager-load the N+1, add an index, cache the expensive read, set a timeout, or move slow work to a background task. Correlate with recent deploys and traffic. The structure, observe, hypothesize, verify, fix, confirm, matters more than any single tool.
Key point: The methodology is; naming N+1 and worker blocking as prime suspects is the Flask-specific credibility.
Read the file from request.files, run its name through secure_filename to strip path tricks, validate the extension and content type, cap the size with MAX_CONTENT_LENGTH so a huge upload can't exhaust memory, and store it outside the web root or in object storage rather than a public folder.
The security points that matter: never trust the client-supplied filename or content type alone, and don't serve uploaded files from a path where they could be executed. For scale, stream large uploads to storage like S3 rather than buffering in the app.
from werkzeug.utils import secure_filename
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024 # 5 MB cap
ALLOWED = {"png", "jpg", "pdf"}
@app.route("/upload", methods=["POST"])
def upload():
f = request.files["file"]
name = secure_filename(f.filename)
if name.rsplit(".", 1)[-1].lower() not in ALLOWED:
return {"error": "type not allowed"}, 400
f.save(f"/var/uploads/{name}")
return {"saved": name}, 201Flask wins when you want a small, explicit core and full control over structure, which suits APIs, microservices, and apps that don't need a full stack out of the box. It trades built-in batteries (admin, ORM, auth) for flexibility, so you assemble the pieces yourself. Django gives you those batteries by default at the cost of more convention. FastAPI targets async APIs with type-hint validation baked in. Express is the closest analogue in the Node.js world: same minimal, middleware-driven philosophy. Naming these trade-offs out loud is itself an interview signal: it shows you pick tools on merits, not habit.
| Framework | Language | Style | Best at |
|---|---|---|---|
| Flask | Python | Microframework, sync WSGI | APIs and apps where you choose the structure |
| Django | Python | Batteries-included, sync | Full-stack apps needing ORM, admin, auth by default |
| FastAPI | Python | Async ASGI, type-driven | High-concurrency APIs with automatic validation |
| Express | Node.js | Minimal, middleware-based | JavaScript services with the same explicit style |
Prepare in layers, and practice out loud. Most Flask rounds move from concept questions to live coding an endpoint to a design or debugging discussion, so rehearse each stage rather than only reading answers.
The typical Flask interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Flask questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works