57.5 Project 5: Python FastAPI with MySQL, MongoDB and PostgreSQL (with Alembic migrations as a one-off job)
Why: the problem
Suppose we are at Zomato and a new team builds a small notes API for restaurant partners in Python. One team wants MySQL, another already uses MongoDB, and the data team wants PostgreSQL with proper schema migrations. Without Docker every developer installs Python 3.12, a virtual environment, three database servers and their drivers. Pip packages like mysqlclient need C compilers and break on Windows. With Docker we write one FastAPI image and run it against each database with only a change of environment variables and one small driver file. For Postgres we also learn a very important real-world pattern: database migrations with Alembic, run as a one-off docker compose run --rm job before the app starts. By the end we have one Python image, three databases, and a clear way to change the schema safely.
What: every term in plain English
- FastAPI — A modern Python web framework. It gives automatic API docs at /docs.
- Uvicorn — The server that runs a FastAPI app. We run it as the container command.
- PyMySQL — A pure Python MySQL driver. No C compiler needed, so the image stays simple.
- PyMongo — The official MongoDB driver for Python.
- SQLAlchemy — A Python library to talk to SQL databases. Alembic is built on it.
- Alembic — A migration tool. Each schema change is a small versioned Python file.
- One-off job — A container that runs one command and exits, like docker compose run --rm migrate.
- DB_KIND — Our own environment variable that chooses mysql, mongo or postgres.
How: step by step
- Write the FastAPI app with a small storage layer. One file per database kind.
- Write requirements.txt and a slim Dockerfile. python:3.12-slim, non-root, uvicorn.
- Run with MySQL. compose.mysql.yaml
- Run with MongoDB. compose.mongo.yaml
- Run with Postgres and Alembic. compose.pg.yaml with a migrate job.
- Test with curl and /docs. Same API on all three.
- Clean up. down -v for each file.
Architecture
Folder layout
py-notes/
app/
__init__.py
main.py
store_sql.py
store_mongo.py
migrations/
env.py
script.py.mako
versions/
0001_create_notes.py
0002_add_created_at.py
sql/mysql.sql
alembic.ini
requirements.txt
Dockerfile
compose.mysql.yaml
compose.mongo.yaml
compose.pg.yaml
Example 1: The app, the drivers and the Dockerfile
Goal. The app, the drivers and the Dockerfile.
requirements.txt
fastapi==0.115.2
uvicorn[standard]==0.32.0
PyMySQL==1.1.1
pymongo==4.10.1
SQLAlchemy==2.0.36
psycopg[binary]==3.2.3
alembic==1.13.3
app/main.py
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
KIND = os.environ.get("DB_KIND", "mysql")
if KIND == "mongo":
from app import store_mongo as store
else:
from app import store_sql as store
app = FastAPI(title="Partner notes API")
class NoteIn(BaseModel):
restaurant: str
text: str
@app.get("/healthz")
def health():
store.ping()
return {"status": "ok", "db": KIND}
@app.get("/notes")
def list_notes():
return store.list_notes()
@app.post("/notes", status_code=201)
def add_note(n: NoteIn):
if not n.text.strip():
raise HTTPException(400, "text is required")
return store.add_note(n.restaurant, n.text)
app/store_sql.py (MySQL and Postgres)
import os, time
from sqlalchemy import create_engine, text
URL = os.environ["DATABASE_URL"]
engine = create_engine(URL, pool_pre_ping=True)
def ping():
for attempt in range(10):
try:
with engine.connect() as c:
c.execute(text("SELECT 1"))
return
except Exception:
time.sleep(2)
raise RuntimeError("database not reachable")
def list_notes():
with engine.connect() as c:
rows = c.execute(text("SELECT id, restaurant, text FROM notes ORDER BY id"))
return [dict(r._mapping) for r in rows]
def add_note(restaurant, body):
with engine.begin() as c:
c.execute(text("INSERT INTO notes (restaurant, text) VALUES (:r, :t)"),
{"r": restaurant, "t": body})
return {"restaurant": restaurant, "text": body}
app/store_mongo.py
import os
from pymongo import MongoClient
client = MongoClient(os.environ["MONGO_URL"], serverSelectionTimeoutMS=20000)
col = client["partners"]["notes"]
def ping():
client.admin.command("ping")
def list_notes():
return list(col.find({}, {"_id": 0}))
def add_note(restaurant, body):
col.insert_one({"restaurant": restaurant, "text": body})
return {"restaurant": restaurant, "text": body}
app/init.py
# empty file: makes app a Python package
Dockerfile
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
COPY alembic.ini ./
COPY migrations/ ./migrations/
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
What each line does
DB_KIND— Chooses which storage file the app imports.pool_pre_ping=True— SQLAlchemy checks a connection before using it, so dead connections are replaced.ping() with retries— The app retries for 20 seconds. This is app-level wait, on top of Compose health checks.text(... :r, :t)— Named placeholders, safe against SQL injection.serverSelectionTimeoutMS— PyMongo waits up to 20 s for MongoDB.PYTHONUNBUFFERED=1— Logs appear at once in docker logs.--no-cache-dir— pip does not keep download cache, so the image is smaller.--host 0.0.0.0— Listen on all interfaces. 127.0.0.1 would not be reachable from outside the container.
Green output
$ docker build -t py-notes:1.0 .
=> [5/8] RUN pip install --no-cache-dir -r requirements.txt
=> naming to docker.io/library/py-notes:1.0
$ docker images py-notes
py-notes 1.0 0b7d1c2e3f4a 8 seconds ago 231MB
Expected result. One image works with three databases. Only environment variables change.
Example 2: Run with MySQL, then with MongoDB
Goal. Run with MySQL, then with MongoDB.
compose.mysql.yaml
services:
api:
build: .
image: py-notes:1.0
ports: ["8000:8000"]
environment:
DB_KIND: mysql
DATABASE_URL: mysql+pymysql://notes:change-me-notes@db:3306/notes
depends_on:
db: { condition: service_healthy }
db:
image: mysql:8.4
environment:
MYSQL_DATABASE: notes
MYSQL_USER: notes
MYSQL_PASSWORD: change-me-notes
MYSQL_ROOT_PASSWORD: change-me-root
volumes:
- mysql-data:/var/lib/mysql
- ./sql/mysql.sql:/docker-entrypoint-initdb.d/1.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-pchange-me-root"]
interval: 5s
retries: 20
volumes:
mysql-data:
sql/mysql.sql
CREATE TABLE notes (
id INT AUTO_INCREMENT PRIMARY KEY,
restaurant VARCHAR(100) NOT NULL,
text VARCHAR(500) NOT NULL
);
compose.mongo.yaml
services:
api:
build: .
image: py-notes:1.0
ports: ["8000:8000"]
environment:
DB_KIND: mongo
MONGO_URL: mongodb://root:change-me-mongo@mongo:27017/?authSource=admin
depends_on:
mongo: { condition: service_healthy }
mongo:
image: mongo:7
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: change-me-mongo
volumes: [mongo-data:/data/db]
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
retries: 10
volumes:
mongo-data:
Commands
docker compose -f compose.mysql.yaml -p notes-mysql up -d --build --wait
curl -s localhost:8000/healthz
curl -s -X POST localhost:8000/notes -H "Content-Type: application/json" -d '{"restaurant":"Vaishali","text":"Delivery door is at the back"}'
curl -s localhost:8000/notes
docker compose -f compose.mysql.yaml -p notes-mysql down
docker compose -f compose.mongo.yaml -p notes-mongo up -d --wait
curl -s localhost:8000/healthz
curl -s -X POST localhost:8000/notes -H "Content-Type: application/json" -d '{"restaurant":"Goodluck Cafe","text":"Closed on Monday"}'
curl -s localhost:8000/notes
What each line does
mysql+pymysql://— SQLAlchemy URL: use PyMySQL to reach MySQL at host db.-p notes-mysql— A project name. Two stacks with different names never mix containers or volumes.--wait— up returns only after every service is healthy.authSource=admin— The root user lives in the admin database in MongoDB.Same image py-notes:1.0— Built once, used by both stacks.
Green output
{"status":"ok","db":"mysql"}
{"restaurant":"Vaishali","text":"Delivery door is at the back"}
[{"id":1,"restaurant":"Vaishali","text":"Delivery door is at the back"}]
...
{"status":"ok","db":"mongo"}
{"restaurant":"Goodluck Cafe","text":"Closed on Monday"}
[{"restaurant":"Goodluck Cafe","text":"Closed on Monday"}]
Expected result. The same API works on MySQL and MongoDB. Open http://localhost:8000/docs to try it in the browser.
Example 3: Postgres with Alembic migrations as a one-off job
Goal. Postgres with Alembic migrations as a one-off job.
alembic.ini
[alembic]
script_location = migrations
migrations/env.py
import os
from alembic import context
from sqlalchemy import create_engine
def run():
engine = create_engine(os.environ["DATABASE_URL"])
with engine.connect() as conn:
context.configure(connection=conn, target_metadata=None)
with context.begin_transaction():
context.run_migrations()
run()
migrations/script.py.mako
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
from alembic import op
import sqlalchemy as sa
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}
migrations/versions/0001_create_notes.py
from alembic import op
import sqlalchemy as sa
revision = "0001"
down_revision = None
def upgrade():
op.create_table(
"notes",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("restaurant", sa.String(100), nullable=False),
sa.Column("text", sa.String(500), nullable=False),
)
def downgrade():
op.drop_table("notes")
migrations/versions/0002_add_created_at.py
from alembic import op
import sqlalchemy as sa
revision = "0002"
down_revision = "0001"
def upgrade():
op.add_column("notes", sa.Column("created_at", sa.DateTime, server_default=sa.func.now()))
def downgrade():
op.drop_column("notes", "created_at")
compose.pg.yaml
x-db-url: &dburl postgresql+psycopg://notes:change-me-notes@pg:5432/notes
services:
migrate:
build: .
image: py-notes:1.0
command: ["alembic", "upgrade", "head"]
environment:
DATABASE_URL: *dburl
depends_on:
pg: { condition: service_healthy }
restart: "no"
api:
image: py-notes:1.0
ports: ["8000:8000"]
environment:
DB_KIND: postgres
DATABASE_URL: *dburl
depends_on:
migrate: { condition: service_completed_successfully }
pg:
image: postgres:16-alpine
environment:
POSTGRES_DB: notes
POSTGRES_USER: notes
POSTGRES_PASSWORD: change-me-notes
volumes: [pg-data:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U notes -d notes"]
interval: 5s
retries: 20
volumes:
pg-data:
Commands
docker compose -f compose.pg.yaml -p notes-pg up -d --build --wait
docker compose -f compose.pg.yaml -p notes-pg logs migrate
docker compose -f compose.pg.yaml -p notes-pg run --rm migrate alembic current
docker compose -f compose.pg.yaml -p notes-pg exec pg psql -U notes -d notes -c "\d notes"
docker compose -f compose.pg.yaml -p notes-pg run --rm migrate alembic downgrade -1
docker compose -f compose.pg.yaml -p notes-pg run --rm migrate alembic upgrade head
What each line does
x-db-url: &dburl— A YAML anchor. We write the URL once and reuse it with *dburl.command: alembic upgrade head— Applies every migration that is not applied yet, then exits.service_completed_successfully— The API starts only after migrate exits with code 0.restart: "no"— The job must not restart after it finishes.run --rm migrate alembic current— A one-off container that prints the current schema version.downgrade -1— Undo the last migration. Good for testing; be careful in production.
Green output
notes-pg-migrate-1 | INFO [alembic.runtime.migration] Running upgrade -> 0001, create notes
notes-pg-migrate-1 | INFO [alembic.runtime.migration] Running upgrade 0001 -> 0002, add created_at
0002 (head)
Column | Type | Nullable | Default
id | integer | not null | nextval('notes_id_seq'::regclass)
restaurant | character varying(100) | not null |
text | character varying(500) | not null |
created_at | timestamp without time zone | | now()
INFO [alembic.runtime.migration] Running downgrade 0002 -> 0001, add created_at
INFO [alembic.runtime.migration] Running upgrade 0001 -> 0002, add created_at
Expected result. Schema changes are versioned files. The migrate job runs them before the API starts, every deploy, safely.
Every command, step by step (plain docker and Compose)
-
Make folders
mkdir -p py-notes/app py-notes/sql py-notes/migrations/versions && cd py-notesWhat it does: Creates the folders. Then add every file from the examples.
(no output) -
Build once
docker build -t py-notes:1.0 .What it does: Builds the Python image used by all three stacks.
naming to docker.io/library/py-notes:1.0 -
Check the image user
docker run --rm py-notes:1.0 whoamiWhat it does: Must print appuser, not root.
appuser -
Start the MySQL stack
docker compose -f compose.mysql.yaml -p notes-mysql up -d --waitWhat it does: API plus MySQL, waits for healthy.
Container notes-mysql-api-1 Healthy -
Test it
curl -s localhost:8000/healthzWhat it does: The API reports which database it uses.
{"status":"ok","db":"mysql"} -
Open the docs
curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/docsWhat it does: FastAPI builds a docs page automatically.
200 -
Stop the MySQL stack
docker compose -f compose.mysql.yaml -p notes-mysql downWhat it does: Frees port 8000 for the next stack.
Container notes-mysql-api-1 Removed -
Start the Mongo stack
docker compose -f compose.mongo.yaml -p notes-mongo up -d --waitWhat it does: Same image, DB_KIND=mongo.
Container notes-mongo-api-1 Healthy -
Stop it and start Postgres
docker compose -f compose.mongo.yaml -p notes-mongo down docker compose -f compose.pg.yaml -p notes-pg up -d --waitWhat it does: The migrate job runs first, then the API.
Container notes-pg-migrate-1 Exited (0) Container notes-pg-api-1 Healthy -
See all projects
docker compose ls -aWhat it does: Lists every Compose project and its state.
NAME STATUS notes-mysql exited(0) notes-mongo exited(0) notes-pg running(2) -
Clean up everything
for f in mysql mongo pg; do docker compose -f compose.$f.yaml -p notes-$f down -v; done docker image rm py-notes:1.0What it does: Removes all three stacks, their volumes and the image.
Volume notes-pg_pg-data Removed Untagged: py-notes:1.0
When it is red 1
sqlalchemy.exc.ProgrammingError: (psycopg.errors.UndefinedTable) relation "notes" does not exist
Cause. The API started before migrations ran, or migrate failed.
Fix, step by step
- Check docker compose logs migrate.
- Make sure api depends_on migrate with service_completed_successfully.
- Run docker compose run --rm migrate alembic upgrade head.
When it is red 2
pymongo.errors.OperationFailure: Authentication failed., full error: {'ok': 0.0, 'code': 18}
Cause. authSource=admin is missing, or the password changed after the first start.
Fix, step by step
- Add ?authSource=admin to MONGO_URL.
- Check the password matches MONGO_INITDB_ROOT_PASSWORD.
- Reset with down -v only on practice data.
When it is red 3
Bind for 0.0.0.0:8000 failed: port is already allocated
Cause. Another stack still runs on port 8000.
Fix, step by step
- Run docker compose ls to see running projects.
- Stop the other one with down, or change the port to 8001:8000.
When it is red 4
ModuleNotFoundError: No module named 'app'
Cause. app/init.py is missing or the COPY path is wrong.
Fix, step by step
- Create the empty app/init.py.
- Check the Dockerfile has COPY app/ ./app/ and WORKDIR /srv.
- Build again.
Practice tasks
Easy Task 1: Build
Why: Start.
What to do: Build py-notes:1.0 and check the user.
- Use a VM or laptop with Docker and Compose.
- Create the py-notes folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker build -t py-notes:1.0 .
docker run --rm py-notes:1.0 whoamiEasy Task 2: MySQL run
Why: First DB.
What to do: Run the MySQL stack and add a note.
- Use a VM or laptop with Docker and Compose.
- Create the py-notes folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.mysql.yaml -p notes-mysql up -d --wait
curl -s -X POST localhost:8000/notes -H 'Content-Type: application/json' -d '{"restaurant":"A","text":"B"}'Easy Task 3: Docs page
Why: Explore.
What to do: Open /docs and add a note from the browser.
- Use a VM or laptop with Docker and Compose.
- Create the py-notes folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
Open http://localhost:8000/docs, POST /notes, Try it outMedium Task 4: Mongo run
Why: Second DB.
What to do: Switch to the Mongo stack.
- Use a VM or laptop with Docker and Compose.
- Create the py-notes folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.mysql.yaml -p notes-mysql down
docker compose -f compose.mongo.yaml -p notes-mongo up -d --waitMedium Task 5: Migrations
Why: Schema.
What to do: Run the Postgres stack and print the Alembic version.
- Use a VM or laptop with Docker and Compose.
- Create the py-notes folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.pg.yaml -p notes-pg up -d --wait
docker compose -f compose.pg.yaml -p notes-pg run --rm migrate alembic currentMedium Task 6: Downgrade
Why: Safety.
What to do: Downgrade one step and upgrade again.
- Use a VM or laptop with Docker and Compose.
- Create the py-notes folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.pg.yaml -p notes-pg run --rm migrate alembic downgrade -1
docker compose -f compose.pg.yaml -p notes-pg run --rm migrate alembic upgrade headMedium Task 7: Bad input
Why: Validation.
What to do: Send an empty text and read the 400 error.
- Use a VM or laptop with Docker and Compose.
- Create the py-notes folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
curl -s -X POST localhost:8000/notes -H 'Content-Type: application/json' -d '{"restaurant":"A","text":" "}'Hard Task 8: 0003 rating
Why: New migration.
What to do: Write 0003_add_rating.py and apply it.
- Use a VM or laptop with Docker and Compose.
- Create the py-notes folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
op.add_column("notes", sa.Column("rating", sa.Integer, nullable=True))
docker compose -f compose.pg.yaml -p notes-pg run --rm migrateHard Task 9: Two replicas
Why: Scaling.
What to do: Remove the fixed port and scale api to 2; migrate still runs once.
- Use a VM or laptop with Docker and Compose.
- Create the py-notes folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.pg.yaml -p notes-pg up -d --scale api=2Easy Task 10: Clean
Why: Habit.
What to do: Remove all stacks and the image.
- Use a VM or laptop with Docker and Compose.
- Create the py-notes folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
for f in mysql mongo pg; do docker compose -f compose.$f.yaml -p notes-$f down -v; doneHomework: try at home
Add a 0003 migration that adds a rating column (1 to 5) to notes, expose it in the API for Postgres, run the migrate job, and prove downgrade and upgrade both work.
Recap
- One FastAPI image talks to MySQL, MongoDB or Postgres by env vars.
- Use pure Python drivers to keep the image simple.
- Retry in the app as well as health checks in Compose.
- Run Alembic as a one-off migrate job.
- service_completed_successfully makes the API wait for migrations.
- Project names (-p) keep stacks apart.
FAQ
Q. Why python:3.12-slim and not alpine?
Many Python wheels are built for glibc. Alpine uses musl and often needs compiling, which is slow and fragile.
Q. Do I need Alembic for MongoDB?
No. MongoDB has no fixed schema. Teams use small scripts or tools like mongock for data changes.
Q. Can I use Django instead of FastAPI?
Yes. Django has its own migrations: run python manage.py migrate as the one-off job.
Q. How do I see SQL errors?
docker compose logs api shows the full Python traceback because PYTHONUNBUFFERED is set.
Q. Is 231 MB too big?
It is fine to start. Multi-stage builds and removing unused drivers can cut it to about 150 MB.
Q. Should migrate run on every deploy?
Yes. alembic upgrade head does nothing when the schema is already current.
Interview questions
-
How do you run database migrations in a containerised app?
Model answer: As a one-off job (compose run --rm, a Kubernetes Job, or an ECS task) before the new app version starts, never per replica.
-
How do you handle the app starting before the database?
Model answer: Database healthcheck plus depends_on, and retries with pool_pre_ping in the app.
-
How do you make one image work in many environments?
Model answer: Configuration through environment variables (12-factor), no settings baked into the image.
-
Why bind uvicorn to 0.0.0.0?
Model answer: 127.0.0.1 inside a container is only the container itself; port publishing needs the app on all interfaces.
-
What is a YAML anchor in Compose?
Model answer: &name defines a value once and *name reuses it, to avoid repeating config.
Quick quiz
Q1. How does one image work with three databases?
DB_KIND and the URL environment variables choose the driver at start-up.
Q2. What does service_completed_successfully do?
It starts a service only after another service has exited with code 0.
Q3. Why run Alembic as a separate job and not inside the API start?
So migrations run once per deploy, not once per API replica, and failures stop the deploy.
Q4. Why --host 0.0.0.0 for uvicorn?
So the server is reachable from outside the container.
Q5. What does -p notes-mysql do?
Sets the Compose project name so stacks do not share containers or volumes.
Samajla ka?
Revision card
Interview card