Back to Blog

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

Roundexa Team 7 Oct 2026
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 / macOS

Install 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.txt

Keep 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-too

3. 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.py

The 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 query

app/__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 app

run.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 one
Always read a generated migration before applying it. Autogenerate compares models with the database and guesses. A renamed column shows up as drop + add, which deletes the data in it. Fix the file by hand (op.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()
Why 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

  1. Find the slow query: enable SQL logging or check database slow-query logs.
  2. Count queries per request. Dozens of near-identical ones means N+1.
  3. Run EXPLAIN ANALYZE on the slowest one.
  4. Check for a missing or unusable index on WHERE, JOIN and ORDER BY columns.
  5. Select fewer columns and fewer rows; paginate.
  6. Deep OFFSET pages get slower; switch to keyset pagination (WHERE id < last_seen_id).
  7. 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()
        raise

db.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}, 201

Consistent 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 response

Know 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_id from g.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
    raise

Never 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_api

Docker 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:

User question → LLM picks a tool → get_collections(start, end) → SQLAlchemy query → real number → LLM explains it
TOOLS = [{
    "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:

1.Practice
2.User + roles
3.Patient
4.Appointment
5.Payment
6.Invoice
7.Daily report
8.AI assistant

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

Week 1

Flask

Routes, blueprints, application factory, REST APIs, error handling

Week 2

SQLAlchemy

Models, CRUD, relationships, sessions, transactions

Week 3

SQL

Joins, GROUP BY, HAVING, subqueries, CTEs, window functions, indexes, EXPLAIN ANALYZE

Week 4

Production Flask

JWT, roles, validation, pagination, logging, tests, Alembic

Week 5

Advanced

Multi-tenancy, query optimization, background jobs, Docker, Gunicorn

Week 6

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 interview