Flask + SQLAlchemy Guide for Python Full-Stack Developer Jobs (2026)

Python full-stack job descriptions rarely ask for "Flask CRUD". They ask for someone who can take a feature from database schema → migration → model → API → frontend → tests → production, inside a codebase that real customers already use. They mention SQL joins and aggregates, indexes, Alembic, multi-tenant data, debugging from logs, and increasingly, LLM features.
This guide walks through exactly that, using Flask, SQLAlchemy 2.x and PostgreSQL. Every section has working code, and the end has the interview questions these roles ask. The running example is a clinic management API: several clinics (practices) share one app, and each must only ever see its own patients and payments.
1. The roadmap
Learn these in order. Each level builds on the one before it.
Level 1
Python + Flask basics
Routes, blueprints, the application factory
Level 2
SQLAlchemy models + CRUD
Models, sessions, select() queries
Level 3
Relationships + transactions
Foreign keys, joins, commit and rollback
Level 4
Advanced SQL
Aggregates, subqueries, CTEs, window functions
Level 5
Performance
Indexes, EXPLAIN ANALYZE, the N+1 problem
Level 6
Auth + roles
Password hashing, JWT, role checks
Level 7
Alembic migrations
Safe schema changes on live data
Level 8
Multi-tenancy
Isolation as a security boundary
Level 9
Production
Logging, debugging, Docker, Gunicorn
Level 10
Frontend + AI
Angular integration, LLM tool calling
2. Setup
Use Python 3.11 or newer and PostgreSQL. Create a virtual environment so the project's packages stay separate:
mkdir clinic-api && cd clinic-api
python -m venv venv
venv\Scripts\activate # Windows
source venv/bin/activate # Linux / macOSInstall everything in one line (works in Windows Command Prompt, PowerShell and bash):
pip install Flask Flask-SQLAlchemy Flask-Migrate Flask-JWT-Extended flask-cors marshmallow python-dotenv psycopg2-binary gunicorn
pip install pytest
pip freeze > requirements.txtKeep secrets out of code. Put them in a .env file, add .env to .gitignore, and commit a .env.example with placeholder values instead:
DATABASE_URL=postgresql+psycopg2://postgres:password@localhost:5432/clinic_api
SECRET_KEY=change-me
JWT_SECRET_KEY=change-me-too3. Project structure
A single app.py is fine for a tutorial, not for a team. Split by responsibility: routes handle HTTP, services hold business rules, models describe tables.
clinic-api/
├── app/
│ ├── __init__.py # create_app()
│ ├── config.py
│ ├── extensions.py # db, migrate, jwt
│ ├── tenancy.py # tenant scoping (section 14)
│ ├── errors.py
│ ├── models/
│ │ ├── __init__.py # imports every model
│ │ ├── practice.py
│ │ ├── user.py
│ │ ├── patient.py
│ │ └── payment.py
│ ├── schemas/ # request validation
│ ├── routes/ # blueprints: auth, patients, reports
│ └── services/ # business logic
├── migrations/ # created by Flask-Migrate
├── tests/
├── .env.example
├── requirements.txt
└── run.pyThe request flow is route → service → database. Routes parse input and return responses; services decide what should happen. Some teams add a repository layer for queries; it helps in large codebases, but don't add layers you don't need yet.
4. The application factory
Create extensions once, without an app, then attach them inside create_app(). This avoids circular imports and lets tests build an app with a test configuration.
app/extensions.py
from flask_jwt_extended import JWTManager
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
db = SQLAlchemy(model_class=Base)
migrate = Migrate()
jwt = JWTManager()app/config.py
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
SECRET_KEY = os.environ["SECRET_KEY"]
JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"]
SQLALCHEMY_ECHO = False # set True while learning: prints every SQL queryapp/__init__.py
from flask import Flask
from flask_cors import CORS
from .extensions import db, jwt, migrate
def create_app(config="app.config.Config"):
app = Flask(__name__)
app.config.from_object(config)
db.init_app(app)
migrate.init_app(app, db)
jwt.init_app(app)
# Your Angular dev server. List real origins in production, never "*".
CORS(app, origins=["http://localhost:4200"])
from . import models # noqa: F401 - Flask-Migrate must see every model
from .errors import register_error_handlers
from .routes.auth import auth_bp
from .routes.patients import patients_bp
from .tenancy import load_tenant
app.before_request(load_tenant)
register_error_handlers(app)
app.register_blueprint(auth_bp, url_prefix="/api/auth")
app.register_blueprint(patients_bp, url_prefix="/api/patients")
return apprun.py
from app import create_app
app = create_app()flask --app run run --debug # development server
gunicorn run:app # production (Linux / Docker)5. Models in SQLAlchemy 2.x
SQLAlchemy 2.x uses typed columns: Mapped[str] means NOT NULL, Mapped[str | None] means nullable. Older tutorials use db.Column(...); it still works, but new code uses this style.
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.extensions import db
def utcnow():
return datetime.now(timezone.utc)
class Practice(db.Model):
__tablename__ = "practices"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200))
users: Mapped[list["User"]] = relationship(back_populates="practice")
class User(db.Model):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
practice_id: Mapped[int] = mapped_column(ForeignKey("practices.id"), index=True)
name: Mapped[str] = mapped_column(String(100))
email: Mapped[str] = mapped_column(String(150), unique=True) # unique already creates an index
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(20), default="user")
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
practice: Mapped["Practice"] = relationship(back_populates="users")back_populates links both sides: practice.users gives a practice's users, and user.practice gives a user's practice. Always index foreign keys you filter or join on; PostgreSQL doesn't do it for you.
6. Alembic migrations
Flask-Migrate runs Alembic for you. The everyday loop:
flask --app run db init # once per project
flask --app run db migrate -m "add patients table" # generate from model changes
flask --app run db upgrade # apply
flask --app run db downgrade # undo the last oneop.alter_column(..., new_column_name=...)) and check that downgrade() really undoes upgrade().Adding a required column to a table that already has rows
ALTER TABLE ... ADD COLUMN ... NOT NULL fails if rows exist. Production migrations do it in three steps:
def upgrade():
# 1. add the column as nullable, so existing rows are allowed
op.add_column("patients", sa.Column("practice_id", sa.Integer(), nullable=True))
# 2. backfill it (use the right value for your data)
op.execute("UPDATE patients SET practice_id = 1 WHERE practice_id IS NULL")
# 3. only now make it required, and add the key and index
op.alter_column("patients", "practice_id", nullable=False)
op.create_foreign_key("fk_patients_practice", "patients", "practices", ["practice_id"], ["id"])
op.create_index("ix_patients_practice_id", "patients", ["practice_id"])
def downgrade():
op.drop_index("ix_patients_practice_id", table_name="patients")
op.drop_constraint("fk_patients_practice", "patients", type_="foreignkey")
op.drop_column("patients", "practice_id")Practise these scenarios until they're routine: add, rename and drop a column; add an index; add a foreign key; change nullability; and a data migration like the one above. On large tables, also learn CREATE INDEX CONCURRENTLY, which builds an index without blocking writes.
7. Query cheat sheet
Every query in SQLAlchemy 2.x starts with select(). db.session.scalars() returns model objects; db.session.execute() returns rows of columns.
from sqlalchemy import delete, func, or_, select, update
# CREATE
user = User(name="Asha", email="asha@example.com", password_hash=hashed, practice_id=1)
db.session.add(user)
db.session.commit()
# READ by primary key
user = db.session.get(User, 10)
# READ with filters: SELECT * FROM users WHERE role = 'admin' AND practice_id = 1
admins = db.session.scalars(
select(User).where(User.role == "admin", User.practice_id == 1)
).all()
# One row or None
user = db.session.scalars(select(User).where(User.email == "asha@example.com")).first()
# OR, case-insensitive search, IN, BETWEEN, NULL
select(User).where(or_(User.email == "a@x.com", User.email == "b@x.com"))
select(User).where(User.name.icontains("john", autoescape=True)) # ILIKE '%john%', escapes % and _
select(User).where(User.id.in_([1, 2, 3]))
select(Payment).where(Payment.amount.between(1000, 5000))
select(User).where(User.deleted_at.is_(None)) # IS NULL
# ORDER BY + pagination (page 3, 10 per page)
select(User).order_by(User.created_at.desc()).offset(20).limit(10)
# UPDATE one row: change the object, then commit
user.name = "New name"
db.session.commit()
# DELETE one row
db.session.delete(user)
db.session.commit()
# Bulk UPDATE / DELETE without loading rows into Python
db.session.execute(update(User).where(User.role == "user").values(role="member"))
db.session.execute(delete(User).where(User.deleted_at.is_not(None)))
db.session.commit()User.query.all() appears in older code. It still works in Flask-SQLAlchemy but is the legacy API; write new queries with select(). And never return every row from an endpoint: paginate.
8. Joins and aggregates
# INNER JOIN: each user with their practice's name
rows = db.session.execute(
select(User.name, Practice.name.label("practice")).join(User.practice)
).all()
# LEFT JOIN + GROUP BY: every practice with its user count, including practices with 0 users
rows = db.session.execute(
select(Practice.name, func.count(User.id).label("user_count"))
.outerjoin(Practice.users)
.group_by(Practice.id, Practice.name)
.order_by(func.count(User.id).desc())
).all()
# HAVING filters groups (WHERE filters rows): practices with more than 10 users
select(User.practice_id, func.count(User.id)).group_by(User.practice_id).having(func.count(User.id) > 10)
# SUM and AVG in one query
total, average = db.session.execute(
select(func.sum(Payment.amount), func.avg(Payment.amount)).where(Payment.practice_id == 1)
).one()
# DISTINCT
select(User.practice_id).distinct()Note the func.count(User.id) in the LEFT JOIN: COUNT(*) would count the empty joined row and report 1 user for a practice with none.
9. Advanced queries
Analytics screens and reports are built from these. Practise each until you can write it without looking.
from sqlalchemy import case, exists, literal_column
# Subquery: each patient with the total they've paid
totals = (
select(Payment.patient_id, func.sum(Payment.amount).label("total"))
.group_by(Payment.patient_id)
.subquery()
)
rows = db.session.execute(
select(Patient.name, totals.c.total).join(totals, Patient.id == totals.c.patient_id)
).all()
# EXISTS: patients with at least one appointment (stops at the first match)
has_appointment = exists().where(Appointment.patient_id == Patient.id)
patients = db.session.scalars(select(Patient).where(has_appointment)).all()
# CASE
role_name = case((User.role == "admin", "Administrator"), else_="User").label("role_name")
db.session.execute(select(User.name, role_name)).all()
# Window function: each patient's latest payment
ranked = select(
Payment.patient_id,
Payment.amount,
func.row_number().over(partition_by=Payment.patient_id, order_by=Payment.paid_at.desc()).label("rn"),
).subquery()
latest = db.session.execute(select(ranked).where(ranked.c.rn == 1)).all()
# Collections by month (PostgreSQL)
month = func.date_trunc(literal_column("'month'"), Payment.paid_at).label("month")
by_month = db.session.execute(
select(month, func.sum(Payment.amount).label("collected"))
.where(Payment.practice_id == 1)
.group_by(month)
.order_by(month)
).all()A real report: accounts-receivable aging with a CTE
Finance screens show unpaid balances grouped by how overdue they are: 0–30, 31–60, 61–90 and 90+ days. A CTE keeps the steps readable:
days_overdue = (func.current_date() - Invoice.due_date).label("days") # date - date = days in PostgreSQL
open_invoices = (
select(Invoice.balance, days_overdue)
.where(Invoice.practice_id == 1, Invoice.balance > 0)
.cte("open_invoices")
)
bucket = case(
(open_invoices.c.days <= 30, "0-30"),
(open_invoices.c.days <= 60, "31-60"),
(open_invoices.c.days <= 90, "61-90"),
else_="90+",
).label("bucket")
aging = db.session.execute(
select(bucket, func.sum(open_invoices.c.balance).label("amount"))
.group_by(literal_column("bucket"))
).all()literal_column above? When the same expression with a value in it (like 'month' or the bucket labels) appears in both SELECT and GROUP BY, some drivers (psycopg 3, asyncpg) send each copy as a separate parameter. PostgreSQL then can't tell they match and fails with "must appear in the GROUP BY clause". Writing the constant inline, or grouping by the alias, avoids it.10. Performance and indexes
The N+1 problem
Relationships load lazily: touching user.practice runs a query. In a loop, that's one query per row:
from sqlalchemy.orm import joinedload, load_only, selectinload
# N+1: 1 query for the users, then 1 more per user
for user in db.session.scalars(select(User)):
print(user.practice.name)
# Many-to-one: joinedload, one query with a JOIN
users = db.session.scalars(select(User).options(joinedload(User.practice))).all()
# One-to-many: selectinload, one extra query with IN (...)
practices = db.session.scalars(select(Practice).options(selectinload(Practice.users))).all()
# Load only the columns you need
users = db.session.scalars(select(User).options(load_only(User.id, User.name, User.email))).all()Rule of thumb: joinedload for a single related object, selectinload for collections (a JOIN on a collection repeats the parent row once per child).
Indexes
An index lets the database jump to matching rows instead of scanning the whole table. Index columns you filter, join and sort on. Every index also slows down writes and uses disk, so don't index everything. For a query that filters by practice and sorts by date, one composite index serves both:
class Appointment(db.Model):
__tablename__ = "appointments"
__table_args__ = (db.Index("ix_appointments_practice_starts", "practice_id", "starts_at"),)Column order matters: this index helps queries on practice_id alone, but not on starts_at alone.
Reading EXPLAIN ANALYZE
EXPLAIN ANALYZE
SELECT * FROM appointments
WHERE practice_id = 10 AND starts_at >= '2026-10-01'
ORDER BY starts_at;Look for Seq Scan on a big table (no usable index), Index Scan or Bitmap Index Scan (index used), and a large gap between estimated and actual rows (stale statistics; run ANALYZE). To get the SQL SQLAlchemy generates, print(stmt) or turn on SQLALCHEMY_ECHO.
Slow endpoint checklist
- Find the slow query: enable SQL logging or check database slow-query logs.
- Count queries per request. Dozens of near-identical ones means N+1.
- Run
EXPLAIN ANALYZEon the slowest one. - Check for a missing or unusable index on WHERE, JOIN and ORDER BY columns.
- Select fewer columns and fewer rows; paginate.
- Deep
OFFSETpages get slower; switch to keyset pagination (WHERE id < last_seen_id). - Measure again after each change.
"SQLAlchemy is slow" is almost never the answer. Find out why the database query is slow.
11. Transactions
When several changes must succeed or fail together, commit once at the end and roll back on any error:
def book_appointment(patient, starts_at, deposit):
try:
appointment = Appointment(practice_id=patient.practice_id, patient_id=patient.id, starts_at=starts_at)
db.session.add(appointment)
db.session.add(Payment(practice_id=patient.practice_id, patient_id=patient.id, amount=deposit))
patient.status = "active"
db.session.commit() # all three changes are saved together, or none are
return appointment
except Exception:
db.session.rollback()
raisedb.session.flush() sends changes to the database without committing, useful when you need a new row's ID before the transaction ends. Nothing is permanent until commit().
12. Building the API
Follow REST conventions: GET /api/patients, GET /api/patients/10, POST /api/patients, PATCH /api/patients/10, DELETE /api/patients/10. Validate every input; never trust request.json["email"] directly.
from flask import Blueprint, g, request
from flask_jwt_extended import jwt_required
from marshmallow import Schema, ValidationError, fields, validate
from sqlalchemy import select
from app.extensions import db
from app.models import Patient
patients_bp = Blueprint("patients", __name__)
class PatientIn(Schema):
name = fields.Str(required=True, validate=validate.Length(min=1, max=100))
email = fields.Email(load_default=None)
phone = fields.Str(load_default=None, validate=validate.Length(max=20))
@patients_bp.get("")
@jwt_required()
def list_patients():
# The practice filter is added centrally, see section 14
stmt = select(Patient).order_by(Patient.created_at.desc())
search = request.args.get("search", "").strip()
if search:
stmt = stmt.where(Patient.name.icontains(search, autoescape=True))
page = db.paginate(stmt, page=request.args.get("page", 1, type=int), per_page=20, max_per_page=100)
return {
"items": [{"id": p.id, "name": p.name, "email": p.email} for p in page.items],
"page": page.page,
"total": page.total,
}
@patients_bp.post("")
@jwt_required()
def create_patient():
try:
data = PatientIn().load(request.get_json() or {})
except ValidationError as err:
return {"errors": err.messages}, 422
patient = Patient(practice_id=g.practice_id, **data) # tenant from the token, never the body
db.session.add(patient)
db.session.commit()
return {"id": patient.id}, 201Consistent errors
# app/errors.py
from werkzeug.exceptions import HTTPException
def register_error_handlers(app):
@app.errorhandler(HTTPException)
def http_error(err):
return {"error": err.description}, err.code
@app.errorhandler(Exception)
def unexpected_error(err):
app.logger.exception("Unhandled error") # full traceback goes to the logs
return {"error": "Something went wrong"}, 500 # no internals in the responseKnow the status codes: 400 bad request, 401 not logged in, 403 logged in but not allowed, 404 not found, 409 conflict (e.g. duplicate email), 422 validation failed, 500 server bug.
13. Authentication and roles
Never store raw passwords. Werkzeug, which ships with Flask, hashes them. Put the user's practice and role in the token as claims so every request knows them without a database lookup:
from flask import Blueprint, request
from flask_jwt_extended import create_access_token
from sqlalchemy import select
from werkzeug.security import check_password_hash, generate_password_hash
auth_bp = Blueprint("auth", __name__)
# When creating a user: password_hash = generate_password_hash(password)
@auth_bp.post("/login")
def login():
data = request.get_json() or {}
email = str(data.get("email", "")).strip().lower()
user = db.session.scalars(select(User).where(User.email == email)).first()
if not user or not check_password_hash(user.password_hash, str(data.get("password", ""))):
return {"error": "Invalid email or password"}, 401 # same message either way
token = create_access_token(
identity=str(user.id), # must be a string: newer versions reject integers
additional_claims={"practice_id": user.practice_id, "role": user.role},
)
return {"access_token": token}A reusable role check:
from functools import wraps
from flask_jwt_extended import get_jwt, jwt_required
def role_required(*roles):
def decorator(fn):
@wraps(fn)
@jwt_required()
def wrapper(*args, **kwargs):
if get_jwt().get("role") not in roles:
return {"error": "You don't have permission to do this"}, 403
return fn(*args, **kwargs)
return wrapper
return decorator
@users_bp.delete("/<int:user_id>")
@role_required("admin")
def delete_user(user_id):
...Claims stay in the token until it expires, so a demoted admin keeps their role until then. Keep access tokens short-lived, and re-check the database before sensitive actions.
14. Multi-tenancy: isolation is a security boundary
In a multi-tenant SaaS, many customers share one database. A clinic must never see another clinic's patients. A missing WHERE practice_id = ... in one query is a data breach, not a bug. So don't rely on every developer remembering the filter in every query: apply it centrally.
# app/tenancy.py
from flask import g, has_app_context
from flask_jwt_extended import get_jwt, verify_jwt_in_request
from sqlalchemy import ForeignKey, event
from sqlalchemy.orm import Mapped, Session, declared_attr, mapped_column, with_loader_criteria
class TenantScoped:
"""Mixin for every table that belongs to one practice."""
@declared_attr
def practice_id(cls) -> Mapped[int]:
return mapped_column(ForeignKey("practices.id"), index=True)
def load_tenant():
"""Runs before every request: read the practice from the token once."""
verify_jwt_in_request(optional=True)
g.practice_id = (get_jwt() or {}).get("practice_id")
@event.listens_for(Session, "do_orm_execute")
def scope_to_tenant(state):
practice_id = g.get("practice_id") if has_app_context() else None
if (
practice_id is not None
and state.is_select
and not state.is_column_load
and not state.is_relationship_load
and not state.execution_options.get("all_tenants", False)
):
# Adds "practice_id = :id" to every TenantScoped table in the query, joins included
state.statement = state.statement.options(
with_loader_criteria(TenantScoped, lambda cls: cls.practice_id == practice_id, include_aliases=True)
)
# A tenant-owned model just inherits the mixin:
# class Patient(TenantScoped, db.Model): ...
#
# A platform admin report that must see every tenant opts out explicitly:
# db.session.scalars(select(Patient).execution_options(all_tenants=True))What the central filter does not cover, so handle these yourself:
- Writes: set
practice_idfromg.practice_id, never from the request body. - Bulk UPDATE/DELETE and raw SQL: add the practice filter by hand.
- Defense in depth: PostgreSQL Row-Level Security can enforce the same rule inside the database.
- Tests: create two practices and assert that practice A's token gets a 404 for practice B's patient. Return 404, not 403, so the response doesn't reveal the record exists.
15. Production debugging and deployment
Read tracebacks from the bottom up
sqlalchemy.exc.IntegrityError: (psycopg2.errors.UniqueViolation)
duplicate key value violates unique constraint "users_email_key"
DETAIL: Key (email)=(asha@example.com) already exists.The last line says what failed (a duplicate email). Walk up the traceback to the first file that is yours. That's where the bad call started. Here the fix is to check for an existing email first and return 409, or to catch IntegrityError, roll back, and return 409.
Log, don't print
import logging
logger = logging.getLogger(__name__)
logger.info("Importing appointments for practice %s", practice_id)
try:
run_import(practice_id)
except Exception:
logger.exception("Appointment import failed for practice %s", practice_id) # includes the traceback
raiseNever log passwords, tokens or patient details. In containers, you'll debug with commands like these:
docker compose logs -f api # follow the API logs
docker compose exec api bash # shell inside the running container
docker compose exec db psql -U postgres clinic_apiDocker and Gunicorn
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "run:app"]Bind to 0.0.0.0, or the app is unreachable from outside the container. Never run with debug=True in production: the debugger lets anyone run code on your server. Run flask db upgrade as a release step before the new code starts, and keep migrations backward-compatible so old and new code can run side by side during a deploy.
16. Angular integration
The frontend calls the API through a service, and an interceptor attaches the token to every request. If you know React or Vue, the concepts map directly: Angular services are like custom hooks or composables.
// patient.service.ts
@Injectable({ providedIn: "root" })
export class PatientService {
private http = inject(HttpClient);
list(page = 1, search = "") {
return this.http.get<PatientPage>("/api/patients", { params: { page, search } });
}
}
// auth.interceptor.ts: attach the token to every API call
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = localStorage.getItem("access_token");
return next(token ? req.clone({ setHeaders: { Authorization: "Bearer " + token } }) : req);
};Learn components, routing, route guards for logged-in pages, reactive forms, and how to show the 422 validation errors from the API next to the right form field.
17. LLM features: tool calling over your own data
Many products now have an assistant that answers questions like "How much did we collect this month?". The model must never invent that number. Instead, it calls a tool you wrote, your code runs a real query, and the model only explains the result:
get_collections(start, end) → SQLAlchemy query → real number → LLM explains itTOOLS = [{
"name": "get_collections",
"description": "Total payments collected by the user's practice between two dates",
"parameters": {
"type": "object",
"properties": {
"start": {"type": "string", "format": "date"},
"end": {"type": "string", "format": "date"},
},
"required": ["start", "end"],
},
}]
def get_collections(start, end):
# Be explicit about the tenant in anything a model can trigger
return db.session.scalar(
select(func.coalesce(func.sum(Payment.amount), 0)).where(
Payment.practice_id == g.practice_id,
Payment.paid_at >= start,
Payment.paid_at < end,
)
)Also learn structured output (the model returns JSON that matches a schema, which you validate), streaming responses to the browser, and the production concerns: token cost, timeouts and retries, and never letting the model write SQL that runs unchecked.
18. Build one complete project
Reading about these topics won't make them stick; building will. Build a clinic management API with these entities, adding them in order:
For every entity, implement create, read one, list with search, filter, sort and pagination, update and delete. Then add:
- Reports: collections by month, top patients by amount paid, AR aging buckets.
- A migration that adds a required column to a table with existing rows.
- Tests for success, validation errors, 401 and 403, duplicates, and tenant isolation.
- Docker Compose with the API, PostgreSQL and the Angular app.
Commit in small, readable steps ("Add patient model and migration", "Add patient search"), not "final2". Interviewers do read your GitHub.
19. A 6-week plan
Flask
Routes, blueprints, application factory, REST APIs, error handling
SQLAlchemy
Models, CRUD, relationships, sessions, transactions
SQL
Joins, GROUP BY, HAVING, subqueries, CTEs, window functions, indexes, EXPLAIN ANALYZE
Production Flask
JWT, roles, validation, pagination, logging, tests, Alembic
Advanced
Multi-tenancy, query optimization, background jobs, Docker, Gunicorn
Angular + AI
Services, routing, guards, interceptors, LLM tool calling, structured output
20. Interview questions you should answer without searching
Q1
What is the application factory pattern, and why use it?
A function, usually create_app(), that builds and configures the Flask app instead of creating it at import time. It avoids circular imports, lets you create apps with different configurations (development, testing, production), and lets tests spin up a fresh app each time.
Q2
What is a Blueprint?
A group of related routes, error handlers and templates that you register on the app with a URL prefix. It is how you split a large Flask app into modules such as auth, patients and reports.
Q3
What is the SQLAlchemy Session?
A unit of work. It tracks objects you load, add or change, and writes those changes in one transaction when you commit. Its identity map guarantees that loading the same row twice in one session gives you the same Python object.
Q4
What is the difference between flush() and commit()?
flush() sends pending changes to the database inside the current transaction, so you get generated IDs, but nothing is permanent and it can still be rolled back. commit() flushes and then ends the transaction, making changes permanent.
Q5
SQLAlchemy Core vs ORM?
Core works with tables and SQL expressions and returns rows. The ORM maps tables to Python classes and returns objects with relationships. In SQLAlchemy 2.x both use the same select() construct; you use the ORM for application logic and Core-style column queries for reports and bulk operations.
Q6
What is the N+1 problem, and how do you fix it?
Loading N parent rows and then lazily loading a relationship for each one, which runs N extra queries. Fix it with eager loading: joinedload for many-to-one relationships, selectinload for collections, or by selecting exactly the columns you need with a join.
Q7
WHERE vs HAVING?
WHERE filters rows before grouping, so it can't use aggregates. HAVING filters groups after GROUP BY, so it can, for example HAVING COUNT(*) > 10.
Q8
INNER JOIN vs LEFT JOIN?
INNER JOIN returns only rows that match in both tables. LEFT JOIN returns every row from the left table, with NULLs where the right table has no match. Use it for questions like every practice with its patient count, including practices with none.
Q9
When does an index help, and what does it cost?
It helps when a query filters, joins or sorts on the indexed columns and returns a small part of the table. It costs disk space and slows every INSERT, UPDATE and DELETE on that table. In a composite index, column order matters: (practice_id, created_at) helps queries on practice_id alone but not created_at alone.
Q10
How do you investigate a slow query?
Get the exact SQL, run EXPLAIN ANALYZE, look for sequential scans on large tables and big gaps between estimated and actual rows, check that WHERE, JOIN and ORDER BY columns are indexed, check for N+1 patterns in the code, reduce selected columns and rows, then measure again.
Q11
How would you add a NOT NULL column to a large production table?
In steps: add it as nullable, backfill existing rows (in batches if the table is large), then set NOT NULL and add constraints and indexes. Keep the change backward-compatible so the old code keeps working while the new code deploys.
Q12
How do you prevent tenant data leaking in a multi-tenant app?
Every tenant-owned table has a tenant ID. Read the tenant from the authenticated token once per request, apply the filter centrally (for example a do_orm_execute event with with_loader_criteria), never accept the tenant ID from the request body, handle bulk and raw SQL explicitly, and write tests that prove one tenant can't read another's data.
Q13
401 vs 403?
401 means the request isn't authenticated: no token, or an invalid or expired one. 403 means the user is authenticated but not allowed to do this, for example a non-admin calling an admin endpoint.
Q14
How would you debug a 500 error in production?
Find the request in the logs by time, route or request ID, read the traceback from the bottom up to the first line of your own code, reproduce it locally with the same input, fix it, and add a test so it can't come back. Never debug by turning on debug mode in production.
Q15
How do you make an LLM answer questions about your data reliably?
Don't let it guess. Give it tools (functions) that run real, tenant-scoped queries, validate the arguments it passes, return the results to the model to explain, and require structured output where your code consumes the response.
Final thoughts
Don't prepare as "I know Flask CRUD". Prepare as "I can take a feature from schema to migration, API, UI, tests and production, and debug it when it breaks". Build the project, then practise explaining your decisions out loud, because that's what the interview tests. You can rehearse with a free AI mock interview, and find openings on Roundexa's jobs list.
Practise your Python developer interview
Answer real interview questions out loud and get a score, feedback and an ideal answer for each. Free to try.
Try a free mock interviewRead Next
SQL Interview Questions and Answers for Freshers 2026
SQL questions come up in almost every technical interview. This guide covers the 10 most common ones — JOINs, GROUP BY, subqueries, indexes — with clear explanations you can actually say out loud.
Angular Scenario-Based Interview Questions for Experienced Developers (6+ Years) 2026
Angular interviews at the senior level test judgment, not syntax. This guide covers 18 scenario-based questions on architecture, performance, RxJS, change detection, forms, and migration.