Build an Incident API Data Layer
Build a Flask incident API with PostgreSQL persistence and Alembic migrations.
Introduction
30 Second Summary
Incident records matter most when a service is under pressure. A restart that wipes them out leaves the response team without its shared history.
In this project, you will build a Flask incident API that stores records in PostgreSQL through direct SQLAlchemy sessions. You will create reversible Alembic migrations for every schema change.
What You'll Build
After creating an incident from PowerShell, you will restart the API to retrieve the same title and severity from PostgreSQL.
By the end of this project, you'll have:
- A live health check you can call at /health to confirm the service is running.
- Persistent incident records that keep their titles and severities after the Flask process restarts.
- A reproducible migration history that builds the incidents schema from scratch. It applies the severity update as the next ordered revision.
- Secret Mission: Rehearse a one-revision database rollback to expose an application-schema mismatch. Restore the database to migration head.
What do I need before starting?
Basic Python syntax plus HTTP knowledge is enough.
Your Windows computer should already have Visual Studio Code plus Docker Desktop.
Before We Start
A production data layer needs a clear purpose before any code exists. This step defines the incident-tracking API, its users, and the database-change risk that explicit sessions and versioned migrations control.
Set Up a Reproducible Windows Workspace
Your incident API will depend on Flask code now. It will depend on PostgreSQL persistence and schema migrations later. A repeatable workspace keeps those layers consistent across every run.
You will verify Python and Docker Desktop before isolating the project inside a virtual environment. You will also pin every dependency used from Visual Studio Code so the service does not rely on packages installed globally.
In this step, get ready to:
- Verify Python 3.14, Docker, and Docker Compose in PowerShell.
- Create the incident-api workspace with an active .venv virtual environment.
- Install the pinned dependencies and confirm that every package imports successfully.
Verify Python and Docker
A reproducible service needs a known runtime plus working container tools. PowerShell gives you one place to verify both before creating project files.
- Press the Windows key to open the Start menu.
- Type Docker into the search field.
- Select Docker Desktop from the search results.
- Select Accept if the Docker Subscription Service Agreement appears.
- Wait for the Docker Desktop Dashboard to load. Startup can take about a minute while the engine becomes available.
The Docker Desktop Dashboard confirms that the application has started. The command-line checks below confirm that its engine and Compose integration can respond.
- Press the Windows key to open the Start menu.
- Type PowerShell into the search field.
- Select Windows PowerShell from the search results.
- Check the installed Python version by running this command:
python --version
What does this command check?
The command asks the active Python executable to report its version. The result determines whether this computer already has the required 3.14 runtime.
✔️ I see Python 3.14.7 or higher
Your Python runtime is ready. The version output confirms that PowerShell can find a compatible installation.
ⓧ I see an older version
The existing runtime is below the project's required Python 3.14 family. The Python Install Manager can install the required family alongside your current runtime.
- Install Python 3.14 by running this command:
py install 3.14
What does this command do?
The Python Install Manager downloads the requested runtime family. It keeps the requested version available for new PowerShell sessions.
The download may take a few minutes. Progress in the terminal confirms that the installer is still working.
- Close the current PowerShell window after the installation completes.
- Press the Windows key to open the Start menu.
- Type PowerShell into the search field.
- Select Windows PowerShell from the search results.
- Verify the newly available runtime by running this command:
python --version
What does the second check prove?
A fresh PowerShell session reloads the commands available on your system. The version check proves that python now resolves to the required runtime.
You should now see a Python version in the 3.14 family.
Still seeing the older version?
Close every PowerShell window before checking again. This forces Windows to reload the available Python commands.
Use Help me make PowerShell use my Python 3.14 installation. if the older runtime remains active.
ⓧ Command not found
PowerShell cannot find a Python runtime yet. Install the Python Install Manager before requesting the required runtime.
- Use the official Python for Windows page to install the Python Install Manager.
- Close the current PowerShell window after the manager is installed.
- Press the Windows key to open the Start menu.
- Type PowerShell into the search field.
- Select Windows PowerShell from the search results.
- Install Python 3.14 by running this command:
py install 3.14
What does this command do?
The Python Install Manager downloads the requested Python runtime family. The installed runtime becomes available to new terminal sessions.
Give the download a few minutes to finish. The terminal displays progress while the runtime is installed.
- Close PowerShell after the installation completes.
- Open a new PowerShell window through the Windows Start menu.
- Verify the installation by running this command:
python --version
What does this verification prove?
The command confirms that the new terminal can locate Python. A version in the 3.14 family satisfies the project requirement.
Python still unavailable?
Restart Windows if new PowerShell sessions still cannot find Python. A restart reloads system command paths after the installation.
Use Help me diagnose why Python 3.14 is unavailable in PowerShell. if the command remains unavailable.
- Verify the Docker engine and Docker Compose by running these commands:
docker version
docker compose version
What do these checks prove?
- The first command requests client and server information from the Docker engine.
- The second command confirms that Docker Compose is available for managing the PostgreSQL service later.
You should see Docker client and server details followed by a Docker Compose version. These results confirm that Docker Desktop is running and responding.
Docker commands not responding?
Return to Docker Desktop and wait for its Dashboard to finish loading. Run both checks again after the engine is ready.
Use Help me troubleshoot Docker Desktop on Windows. if either command still fails.
Create the isolated workspace
A virtual environment gives this project its own Python installation context. Packages installed in .venv stay separate from global packages used by other projects.
- Press the Windows key to open the Start menu.
- Type Visual Studio Code into the search field.
- Select Visual Studio Code from the search results.
- Select File from the top menu.
- Select Open Folder.
- Select your Desktop in the folder dialog.
- Select New Folder.
- Enter incident-api as the folder name.
The new incident-api folder now exists on your Desktop. The next actions make it the active Visual Studio Code workspace.
- Select the new incident-api folder.
- Select Select Folder.
- Select Yes, I trust the authors if the Workspace Trust dialog appears.
You should see INCIDENT-API at the top of the Explorer sidebar. That label confirms that Visual Studio Code opened the correct workspace.
- Select View from the top menu.
- Select Terminal to open a PowerShell terminal inside incident-api.
- Create the project virtual environment by running this command:
python -m venv .venv
What does this command create?
Python creates the .venv directory inside incident-api. That directory holds the isolated interpreter and the packages installed for this project.
- Confirm that .venv appears beneath incident-api in the Explorer sidebar.
Virtual environment missing?
Check that the terminal path ends with incident-api. Run the creation command again from that folder if the terminal opened somewhere else.
Use Help me create the .venv environment in my incident-api workspace. if the directory still does not appear.
- Activate the virtual environment by running this command:
.\.venv\Scripts\Activate.ps1
What does activation change?
Activation updates the current PowerShell session to use the interpreter and package installer inside .venv. Commands in this terminal now target the project environment.
You should see (.venv) at the start of the PowerShell prompt. Your project now has an active isolated runtime.
Activation not working?
Check that the PowerShell terminal is still inside incident-api. Confirm that .venv appears in the Explorer sidebar.
Use Help me activate my Python virtual environment in Windows PowerShell. if the prompt does not show (.venv).
Pin and install the dependencies
The project needs two small configuration files before packages are installed. .gitignore keeps generated or local-only files out of version control. requirements.txt records the exact package versions that reproduce this environment.
- Select the incident-api workspace in the Explorer sidebar.
- Use the new-file control in the Explorer sidebar to create .gitignore.
- Add the project ignore rules to .gitignore by pasting this content:
.venv/
.env
__pycache__/
*.py[cod]
What do these rules protect?
- The .venv/ rule excludes installed packages and the isolated interpreter.
- The .env rule excludes local environment configuration that may contain credentials later.
- The remaining rules exclude Python cache files generated while the API runs.
- Save .gitignore.
- Confirm that .gitignore appears beneath incident-api in the Explorer sidebar.
Gitignore file not visible?
Check that the filename begins with a dot and contains no .txt suffix. Create the file again inside incident-api if it was added elsewhere.
Use Help me verify my .gitignore file in Visual Studio Code. if the file remains hidden.
✔️ Awesome, I've got everything!
Great. Double-check that .gitignore is saved before creating the dependency manifest.
ⓧ I'd like to double check the full code
.venv/
.env
__pycache__/
*.py[cod]
How should I use this reference?
Compare this file with your saved .gitignore line by line. The order and spelling should match exactly.
- Select the incident-api workspace in the Explorer sidebar.
- Use the new-file control in the Explorer sidebar to create requirements.txt.
- Add the pinned dependencies to requirements.txt by pasting this content:
Flask==3.1.3
SQLAlchemy==2.0.52
alembic==1.19.2
psycopg[binary]==3.3.5
python-dotenv==1.2.3
What are these dependencies for?
- Flask provides the web application and HTTP routing layer.
- SQLAlchemy provides models plus direct engine and session management.
- Alembic records ordered database schema changes.
- Psycopg connects SQLAlchemy to PostgreSQL. python-dotenv loads local environment configuration.
- Save requirements.txt.
- Confirm that requirements.txt appears beneath incident-api in the Explorer sidebar.
Dependency file not matching?
Check every package name and version against the code block. A missing bracket or version digit can make installation fail.
Use Help me compare my requirements.txt with the required package pins. if the file still differs.
✔️ Awesome, I've got everything!
Your dependency manifest is ready. Double-check that requirements.txt is saved before installing it.
ⓧ I'd like to double check the full code
Flask==3.1.3
SQLAlchemy==2.0.52
alembic==1.19.2
psycopg[binary]==3.3.5
python-dotenv==1.2.3
How should I use this reference?
Compare this file with your saved requirements.txt line by line. Every package pin should match exactly.
- Install the pinned dependencies into the active virtual environment by running this command:
python -m pip install -r requirements.txt
What does this install command do?
Python runs the package installer from the active .venv environment. The installer reads each exact version from requirements.txt and installs it locally for this project.
This installation can take a few minutes while packages download. The terminal prints progress for each dependency.
When the command finishes, the PowerShell prompt returns without an installation failure. The pinned packages are now available inside .venv.
Dependency installation failed?
Confirm that the terminal prompt starts with (.venv). Check that the terminal path ends with incident-api.
Use Help me troubleshoot my pinned Python dependency installation. if the installer reports a package or network error.
Before you run the final check, consider whether every pinned package can import from the active virtual environment.
- Verify all required imports by running this command:
python -c "import flask, sqlalchemy, alembic, psycopg, dotenv; print('dependencies ready')"
What does the import check prove?
Python imports each library needed for the application and data layer. The final print runs only after every import succeeds.
You should see dependencies ready in PowerShell. That result confirms the isolated dependency set is installed and importable.
Import check not passing?
Read the final line of the terminal output to identify the missing package. Run the dependency installation command again from the active .venv environment.
Use Help me fix my dependency import check. if the check still fails.
Your Windows workspace now has a verified runtime, working container tools, and an isolated dependency set. Next up, you will ship the first runnable incident API and expose the storage flaw that PostgreSQL needs to solve.
Build an In-Memory Incident API
Your pinned workspace is ready. The next proof is a request that reaches a running Flask service from Windows PowerShell.
A service must protect incident history across restarts. This first version uses process memory so you can test whether it meets that requirement.
In this step, get ready to:
- Create a Flask application factory with a health route.
- Add in-memory incident handlers.
- Restart the server to test whether incident records survive.
Create the application factory and health route
An application factory creates the Flask application when the service starts. A Blueprint keeps the routes in a separate module as the API grows.
- Create an app folder inside incident-api with the new-folder button in the Visual Studio Code Explorer sidebar.
You should now see the app folder in the Explorer sidebar.
- Create app/__init__.py with the new-file button in the Explorer sidebar.
- Add the application factory to app/__init__.py by replacing its contents with this code:
from flask import Flask
def create_app() -> Flask:
app = Flask(__name__)
from app.routes import api
app.register_blueprint(api)
return app
How Does the Application Factory Work?
- The create_app() function constructs the Flask application when the service starts.
- The import inside create_app() loads the routes after the application exists.
- The app.register_blueprint(api) line attaches every route defined by the API blueprint.
- Save app/__init__.py.
- Confirm that __init__.py appears inside the app folder in the Explorer sidebar.
Can't See the New Package File?
- Check that __init__.py has two underscores before and after init.
- Check that the file sits inside incident-api/app.
Ask for help checking the package structure: Help me check why Flask cannot find my app package.
Use these tabs to confirm the application factory before you add routes.
✔️ Awesome, I've got everything!
Your application factory is saved inside the app package.
ⓧ I'd like to double check the full code
from flask import Flask
def create_app() -> Flask:
app = Flask(__name__)
from app.routes import api
app.register_blueprint(api)
return app
The blueprint needs one simple route before you test the application factory. The /health endpoint provides a quick response without depending on incident data.
- Create app/routes.py with the new-file button in the Explorer sidebar.
- Add the blueprint and health route to app/routes.py by pasting this code:
from flask import Blueprint, jsonify, request
api = Blueprint("api", __name__)
incidents: list[dict[str, int | str]] = []
@api.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok"})
What Does This Route Set Up?
- The api blueprint collects the routes registered by the application factory.
- The incidents list holds temporary records inside the running Python process.
- The health() function returns a JSON response with the status value ok.
- Save app/routes.py.
The development server stays attached to its PowerShell terminal. Keep that terminal running while you send requests from another terminal.
- Start the Flask development server from the activated PowerShell terminal by running:
flask --app app run --debug
What Does This Command Do?
- The --app app option points Flask at the app package.
- Flask detects the create_app() factory and uses it to build the application.
- The --debug option supports the local development loop while you edit routes.
- Create a second PowerShell terminal with the plus button in the Visual Studio Code terminal panel.
- Check the health endpoint from the second terminal by running:
Invoke-RestMethod -Uri http://127.0.0.1:5000/health
What Should You See?
PowerShell displays a response whose status value is ok. This proves that the application factory registered the blueprint successfully.
Health Request Not Working?
- Confirm that the first terminal still has the Flask server running.
- Check that app/routes.py defines the route as /health.
- Check that app/__init__.py registers the api blueprint.
Ask for help with the request: Help me debug why my Flask health endpoint is not responding.
That is your first live proof: the application factory is serving a Flask response.
Add the in-memory incident handlers
The incident collection starts as a module-level Python list. The first handler returns every record currently held by that process.
- Add the list handler below health() in app/routes.py by pasting:
@api.route("/incidents", methods=["GET"])
def list_incidents():
return jsonify(incidents)
How Does Listing Work?
The list_incidents() function serializes the current incidents list as JSON. Its first response represents an empty collection because no records have been created.
- Save app/routes.py.
- Wait for the running Flask server to finish reloading.
- Check the initial incident collection from the second PowerShell terminal by running:
Invoke-RestMethod -Uri http://127.0.0.1:5000/incidents
What Should You See?
PowerShell prints no incident rows because Flask returned an empty JSON list. This confirms that the new route is responding.
Creating an incident requires a title from the request body. The handler validates that value before assigning an identifier and storing the record.
- Add the create handler below list_incidents() in app/routes.py by pasting:
@api.route("/incidents", methods=["POST"])
def create_incident():
data = request.get_json(silent=True) or {}
title = data.get("title")
if not isinstance(title, str) or not title.strip():
return jsonify({"error": "title is required"}), 400
incident = {"id": len(incidents) + 1, "title": title.strip()}
incidents.append(incident)
return jsonify(incident), 201
How Does Incident Creation Work?
- The request.get_json() call reads the request body as JSON.
- The validation rejects missing titles and titles containing only whitespace.
- The identifier uses the current list length so each new record receives the next number.
- The incidents.append(incident) line places the record in process memory.
- Save app/routes.py.
- Wait for the running Flask server to finish reloading.
- Create an incident from the second PowerShell terminal by running:
Invoke-RestMethod -Uri http://127.0.0.1:5000/incidents -Method Post -ContentType "application/json" -Body '{"title":"Database latency"}'
What Should You See?
PowerShell displays an incident with the identifier 1 and the title Database latency. The POST handler has stored its first record.
- Retrieve the stored incident from the second PowerShell terminal by running:
Invoke-RestMethod -Uri http://127.0.0.1:5000/incidents
What Should You See Now?
PowerShell displays the incident with the identifier 1 and the title Database latency. The GET handler is reading the same list that the POST handler updated.
Incident Missing From the List?
- Check the Flask terminal for a Python syntax error after saving app/routes.py.
- Confirm that create_incident() calls incidents.append(incident).
- Confirm that both requests use the /incidents endpoint.
Ask for help tracing the record: Help me debug why my in-memory incident is not returned by the GET route.
You now have a complete request loop: PowerShell creates an incident and retrieves it from the running Flask process.
Use these tabs to compare your completed routes file with the expected in-memory version.
✔️ Awesome, I've got everything!
Your health route and both incident handlers are saved in app/routes.py.
ⓧ I'd like to double check the full code
from flask import Blueprint, jsonify, request
api = Blueprint("api", __name__)
incidents: list[dict[str, int | str]] = []
@api.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok"})
@api.route("/incidents", methods=["GET"])
def list_incidents():
return jsonify(incidents)
@api.route("/incidents", methods=["POST"])
def create_incident():
data = request.get_json(silent=True) or {}
title = data.get("title")
if not isinstance(title, str) or not title.strip():
return jsonify({"error": "title is required"}), 400
incident = {"id": len(incidents) + 1, "title": title.strip()}
incidents.append(incident)
return jsonify(incident), 201
Restart Flask and test the storage boundary
The API works while one Python process owns the incident list. A restart tests whether that storage can protect records across process lifecycles.
- Return to the first PowerShell terminal where Flask is running.
- Press Ctrl+C to stop the development server.
- Start a fresh Flask process by running:
flask --app app run --debug
What Does the Restart Change?
The command creates a new Python process and imports the application modules again. The next request reveals what happened to the record owned by the previous process.
Before you send the next request, make a prediction about whether the incident should still be present.
- Request the incident collection from the second PowerShell terminal by running:
Invoke-RestMethod -Uri http://127.0.0.1:5000/incidents
What Did the Restart Reveal?
PowerShell prints no incident rows because the API returned an empty JSON list. The fresh Python process created a fresh incidents list.
This is the intended storage flaw. Process memory cannot preserve incident history when the service restarts.
Still Seeing the Old Incident?
- Confirm that you stopped the original Flask process with Ctrl+C.
- Confirm that the new server process started after the original terminal returned to its prompt.
- Check that no second Flask server is still running in another terminal.
Ask for help checking the process lifecycle: Help me determine why my in-memory Flask incident survived a server restart.
You have exposed the exact durability gap this project needs to solve. Next, you will replace process memory with PostgreSQL and direct SQLAlchemy sessions.
Persist Incidents with PostgreSQL
Your Flask API can create incidents, but process memory loses every record when the server restarts. A production-style service needs durable storage with clear connection boundaries.
In this step, Docker Compose starts a local PostgreSQL 18.6 service. Direct SQLAlchemy sessions manage records while Alembic creates the first reproducible schema.
In this step, get ready to:
- Start PostgreSQL with local configuration plus persistent storage.
- Replace the in-memory list with direct SQLAlchemy models plus sessions.
- Apply migration 0001 and prove incidents survive a Flask restart.
Start PostgreSQL with Docker Compose
The database service needs repeatable configuration plus storage outside the container lifecycle. A named volume preserves incident data when the container stops.
These credentials are only for local development. Your existing .gitignore keeps .env out of Git.
- Create .env.example inside the incident-api workspace using the Visual Studio Code Explorer.
- Add the shared development configuration by pasting this code:
POSTGRES_DB=incidents
POSTGRES_USER=incident_app
POSTGRES_PASSWORD=incident_dev_password
DATABASE_URL=postgresql+psycopg://incident_app:incident_dev_password@localhost:5432/incidents
What does this configuration do?
- The three POSTGRES values initialize the local database plus its development user.
- The DATABASE_URL value tells SQLAlchemy to connect through the synchronous Psycopg driver.
- The host name localhost reaches PostgreSQL through the port published on your Windows computer.
- Save .env.example.
- Confirm the Visual Studio Code Explorer lists .env.example inside incident-api.
Configuration file missing?
Check that .env.example is inside incident-api instead of the app folder. Confirm that the filename begins with a full stop.
Help me check my PostgreSQL environment file.
- Create .env beside .env.example using the Visual Studio Code Explorer.
- Add the active local configuration by pasting the same values:
POSTGRES_DB=incidents
POSTGRES_USER=incident_app
POSTGRES_PASSWORD=incident_dev_password
DATABASE_URL=postgresql+psycopg://incident_app:incident_dev_password@localhost:5432/incidents
Why keep two environment files?
The .env.example file documents the required settings. The .env file supplies the values used by your local process.
The python-dotenv package loads those local values into the application environment. This keeps configuration outside the Python modules.
- Save .env.
- Confirm the Visual Studio Code Explorer lists both environment files beside requirements.txt.
- Create compose.yaml inside the incident-api workspace using the Visual Studio Code Explorer.
- Define the PostgreSQL service by pasting this configuration:
services:
db:
image: postgres:18.6
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "127.0.0.1:5432:5432"
volumes:
- postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
start_period: 5s
volumes:
postgres_data:
What does this service create?
- The postgres:18.6 image provides the pinned database server.
- The 127.0.0.1:5432:5432 binding exposes PostgreSQL only through your local computer.
- The postgres_data volume stores database files outside the container lifecycle.
- The pg_isready health check confirms that the configured database accepts connections.
- Save compose.yaml.
- Start PostgreSQL in detached mode by running this command in the activated PowerShell terminal:
docker compose up -d --wait
The first start may take longer while Docker downloads the PostgreSQL image. The command returns control after the database reaches its healthy state.
PostgreSQL did not become healthy?
- Confirm Docker Desktop is still running on your Windows computer.
- Check that the three variable names in compose.yaml match the names in .env.
- Check that another local database is not already using port 5432.
Help me diagnose my PostgreSQL container startup.
Build the direct SQLAlchemy data layer
A database connection should be shared through an engine while each operation receives a short-lived session. This creates an explicit boundary around every read plus write.
Why direct SQLAlchemy?
Direct SQLAlchemy keeps engine creation plus session behavior visible in your own modules. The same data layer can also run outside Flask.
A Flask database extension can reduce setup code. This project focuses on the lifecycle control used in larger backend services.
- Create app/database.py inside the existing app package using the Visual Studio Code Explorer.
- Add the engine plus session factory by pasting this code:
import os
from dotenv import load_dotenv
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
load_dotenv()
def get_database_url() -> str:
database_url = os.environ.get("DATABASE_URL")
if not database_url:
raise RuntimeError("DATABASE_URL is not set")
return database_url
engine = create_engine(get_database_url())
SessionLocal = sessionmaker(engine, expire_on_commit=False)
What does this database module do?
- The load_dotenv() call loads the local database URL before the engine is created.
- The get_database_url() function stops startup when required configuration is missing.
- The engine manages database connections plus their reuse through a connection pool.
- The SessionLocal factory creates sessions for individual API operations.
- Save app/database.py.
- Confirm the Visual Studio Code Explorer lists database.py inside the app package.
Database module showing an import warning?
Confirm the activated terminal still shows the .venv environment. Check that requirements.txt still contains the pinned SQLAlchemy plus python-dotenv dependencies.
Help me diagnose the imports in my database module.
- Create app/models.py inside the existing app package using the Visual Studio Code Explorer.
- Define the baseline incident model by pasting this code:
from sqlalchemy import String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Incident(Base):
__tablename__ = "incidents"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
def to_dict(self) -> dict[str, int | str]:
return {
"id": self.id,
"title": self.title,
}
What does this model represent?
- The Base class gives Alembic one metadata collection to compare with PostgreSQL.
- The Incident class maps Python objects to the incidents table.
- The id column supplies the primary key.
- The title column stores up to 200 characters.
- The to_dict() method converts a stored incident into JSON-ready data.
- Save app/models.py.
- Confirm the Visual Studio Code Explorer lists both data-layer modules inside app.
Model types showing warnings?
Check the capitalization of DeclarativeBase plus Mapped. Confirm every opening bracket has a matching closing bracket.
Help me compare my SQLAlchemy model with the expected structure.
Create the baseline migration and prove persistence
The model describes the schema your application expects. A migration records the database change as an ordered upgrade with a matching downgrade.
- Initialize the Alembic environment from the incident-api workspace by running this command:
alembic init alembic
You should see a new alembic folder plus alembic.ini in the Visual Studio Code Explorer.
Alembic command unavailable?
Confirm your PowerShell terminal still has .venv activated. Check that the command runs from the incident-api workspace containing requirements.txt.
Help me diagnose my Alembic initialization.
- Select all generated content in alembic/env.py.
- Replace it with the first configuration section below:
from alembic import context
from app.database import engine, get_database_url
from app.models import Base
config = context.config
config.set_main_option(
"sqlalchemy.url",
get_database_url().replace("%", "%%"),
)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
How does offline migration setup work?
- The application imports expose the shared engine plus model metadata to Alembic.
- The sqlalchemy.url setting comes from the same environment configuration used by Flask.
- The target_metadata value lets autogenerate compare the mapped models with the database.
- The offline function can render migration SQL without opening a live database connection.
- Add the online migration section directly below the offline function:
def run_migrations_online() -> None:
with engine.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
How does online migration setup work?
The online function borrows a connection from the shared engine. Alembic runs each migration inside a transaction through that connection.
The final condition selects the correct migration path. Normal local commands use the online path.
- Save alembic/env.py.
- Generate revision 0001 from the model metadata by running this command:
alembic revision --autogenerate --rev-id 0001 -m "create incidents"
You should see 0001_create_incidents.py inside alembic/versions.
Why review generated migrations?
Autogenerate creates a candidate migration from the difference between model metadata plus the live database. You remain responsible for checking that its upgrade plus downgrade match the intended schema change.
- Select all generated content in alembic/versions/0001_create_incidents.py.
- Replace it with this deterministic baseline migration:
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0001"
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"incidents",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("incidents")
What should you review?
- The revision value identifies this migration as 0001.
- The empty down_revision value marks this file as the first migration.
- The upgrade() function creates the table expected by the current model.
- The downgrade() function reverses that change by dropping the table.
- Save alembic/versions/0001_create_incidents.py.
- Apply the reviewed baseline migration by running this command:
alembic upgrade head
Alembic applies revision 0001 to PostgreSQL. The database now contains the incidents table.
Migration did not apply?
- Confirm the PostgreSQL service from earlier is still running.
- Check that DATABASE_URL in .env uses port 5432.
- Check that target_metadata points to Base.metadata in alembic/env.py.
Help me diagnose my baseline migration.
- Select all generated content in alembic.ini.
- Replace it with the project configuration below:
[alembic]
script_location = %(here)s/alembic
prepend_sys_path = .
path_separator = os
What does this Alembic configuration control?
- The script_location setting points Alembic to the migration environment.
- The prepend_sys_path setting makes the project modules importable from migration commands.
- The path_separator setting uses the operating system path convention.
- Save alembic.ini.
- Confirm the compact configuration still reaches migration head by running this command:
alembic upgrade head
The command should return without applying another migration. Revision 0001 is already active.
- Select all existing content in app/routes.py.
- Replace it with the imports plus read route below:
from flask import Blueprint, jsonify, request
from sqlalchemy import select
from app.database import SessionLocal
from app.models import Incident
api = Blueprint("api", __name__)
@api.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok"})
@api.route("/incidents", methods=["GET"])
def list_incidents():
with SessionLocal() as session:
statement = select(Incident).order_by(Incident.id)
incidents = session.scalars(statement).all()
return jsonify([incident.to_dict() for incident in incidents])
How does the read route use sessions?
The select(Incident) statement loads database rows in identifier order. The session closes as soon as the response data is prepared.
Short-lived read sessions avoid keeping database connections checked out after a request finishes. The engine can reuse the connection for later work.
- Add the database-backed write route directly below the read route:
@api.route("/incidents", methods=["POST"])
def create_incident():
data = request.get_json(silent=True) or {}
title = data.get("title")
if not isinstance(title, str) or not title.strip():
return jsonify({"error": "title is required"}), 400
with SessionLocal.begin() as session:
incident = Incident(title=title.strip())
session.add(incident)
session.flush()
response = incident.to_dict()
return jsonify(response), 201
How does the write transaction work?
- The existing validation still rejects missing plus blank titles before database work begins.
- The SessionLocal.begin() context commits successful writes.
- The same context rolls back automatically when an exception interrupts the operation.
- The session.flush() call sends the insert before the response dictionary reads the generated identifier.
- Save app/routes.py.
- Compare the completed project files using the tabs below.
✔️ Awesome, I've got everything!
Great. Double-check that every edited file is saved before you test persistence.
ⓧ I'd like to double check the full code
POSTGRES_DB=incidents
POSTGRES_USER=incident_app
POSTGRES_PASSWORD=incident_dev_password
DATABASE_URL=postgresql+psycopg://incident_app:incident_dev_password@localhost:5432/incidents
.venv/
.env
__pycache__/
*.py[cod]
[alembic]
script_location = %(here)s/alembic
prepend_sys_path = .
path_separator = os
from alembic import context
from app.database import engine, get_database_url
from app.models import Base
config = context.config
config.set_main_option(
"sqlalchemy.url",
get_database_url().replace("%", "%%"),
)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
with engine.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0001"
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"incidents",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("incidents")
from flask import Flask
def create_app() -> Flask:
app = Flask(__name__)
from app.routes import api
app.register_blueprint(api)
return app
import os
from dotenv import load_dotenv
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
load_dotenv()
def get_database_url() -> str:
database_url = os.environ.get("DATABASE_URL")
if not database_url:
raise RuntimeError("DATABASE_URL is not set")
return database_url
engine = create_engine(get_database_url())
SessionLocal = sessionmaker(engine, expire_on_commit=False)
from sqlalchemy import String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Incident(Base):
__tablename__ = "incidents"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
def to_dict(self) -> dict[str, int | str]:
return {
"id": self.id,
"title": self.title,
}
from flask import Blueprint, jsonify, request
from sqlalchemy import select
from app.database import SessionLocal
from app.models import Incident
api = Blueprint("api", __name__)
@api.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok"})
@api.route("/incidents", methods=["GET"])
def list_incidents():
with SessionLocal() as session:
statement = select(Incident).order_by(Incident.id)
incidents = session.scalars(statement).all()
return jsonify([incident.to_dict() for incident in incidents])
@api.route("/incidents", methods=["POST"])
def create_incident():
data = request.get_json(silent=True) or {}
title = data.get("title")
if not isinstance(title, str) or not title.strip():
return jsonify({"error": "title is required"}), 400
with SessionLocal.begin() as session:
incident = Incident(title=title.strip())
session.add(incident)
session.flush()
response = incident.to_dict()
return jsonify(response), 201
services:
db:
image: postgres:18.6
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "127.0.0.1:5432:5432"
volumes:
- postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
start_period: 5s
volumes:
postgres_data:
Flask==3.1.3
SQLAlchemy==2.0.52
alembic==1.19.2
psycopg[binary]==3.3.5
python-dotenv==1.2.3
Before you test the restart, do you expect a record stored through the new session to remain available after Flask stops?
- Start the Flask development server from the activated PowerShell terminal by running this command:
flask --app app run --debug
- Create an incident by repeating the PowerShell POST request from the previous step.
- List the stored incidents by repeating the PowerShell GET request from the previous step.
You should see the new incident with its database-generated identifier.
- Stop the Flask server by pressing Ctrl+C in its PowerShell terminal.
- Restart the Flask development server by running the same command:
flask --app app run --debug
- List the stored incidents again by repeating the PowerShell GET request from earlier.
You should see the same incident after the Flask restart. That record now lives in PostgreSQL instead of process memory.
Incident missing after restart?
- Confirm revision 0001 applied successfully before the API started.
- Check that both routes import SessionLocal instead of referencing the old incidents list.
- Check that the PostgreSQL service remains available through the named postgres_data volume.
Help me trace why my incident did not persist.
That is the storage flaw fixed. Your API now uses pooled connections plus explicit session boundaries to preserve incidents across application restarts.
Your database-backed API is running with a reproducible baseline schema. Next, you will change the model before changing PostgreSQL so you can diagnose schema drift directly.
Expose the Schema Drift
Your incident records now survive Flask restarts because PostgreSQL stores them outside process memory. The service can now grow beyond title-only incidents.
Changing a SQLAlchemy model does not alter an existing database table. This step lets application metadata move ahead of revision 0001 so you can witness schema drift directly.
In this step, get ready to:
- Add a severity field to the incident model.
- Validate severity before write transactions begin.
- Expose the mismatch by sending a valid incident request against revision 0001.
Add severity to the model
The ORM model is the Python-side contract for an incident. Adding severity changes the fields that SQLAlchemy expects to read from each row.
- In the Explorer sidebar in Visual Studio Code, select app/models.py.
- Replace the Incident class with the code below:
class Incident(Base):
__tablename__ = "incidents"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
severity: Mapped[str] = mapped_column(
String(20),
server_default="medium",
)
def to_dict(self) -> dict[str, int | str]:
return {
"id": self.id,
"title": self.title,
"severity": self.severity,
}
What does this model change do?
- The severity attribute tells SQLAlchemy to include a string column when it reads or writes incidents.
- The server_default value defines medium as the database-side default for future schema creation.
- The to_dict() method includes severity in every serialized incident.
- Save app/models.py.
- Return to the activated PowerShell terminal from earlier.
- Start the Flask development server by running:
flask --app app run --debug
What does this command prove?
Flask discovers the create_app() factory. A successful startup confirms that the expanded model imports without a Python syntax error.
You will see Flask report that the development server is running. Good work. The application can load the expanded model.
Server not starting?
- Confirm that the terminal prompt begins with (.venv).
- Check the indentation inside the Incident class.
- Confirm that severity sits above to_dict().
Help me diagnose why Flask cannot import my updated incident model.
✔️ Awesome, I've got everything!
Your app/models.py file now describes incidents with an identifier, title, and severity.
ⓧ I'd like to double check the full code
from sqlalchemy import String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Incident(Base):
__tablename__ = "incidents"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
severity: Mapped[str] = mapped_column(
String(20),
server_default="medium",
)
def to_dict(self) -> dict[str, int | str]:
return {
"id": self.id,
"title": self.title,
"severity": self.severity,
}
What should match?
This reference keeps Base unchanged. The Incident class now maps and serializes severity.
Validate severity in the API
Request validation protects the database from unsupported severity values. The check runs before the write transaction begins.
- In the Explorer sidebar, select app/routes.py.
- Replace the imports and API setup at the top of the file with this code:
from flask import Blueprint, jsonify, request
from sqlalchemy import select
from app.database import SessionLocal
from app.models import Incident
api = Blueprint("api", __name__)
VALID_SEVERITIES = {"low", "medium", "high"}
What does this setup define?
The VALID_SEVERITIES set contains the three accepted request values. Set membership gives the route one clear rule for severity validation.
- Save app/routes.py.
- Return to the Flask terminal from earlier.
You will see the debug server reload. The server continues running with the new validation set available.
Server stopped after the reload?
Check that VALID_SEVERITIES appears directly below the api assignment. Confirm that every severity value has matching quotation marks.
Help me fix the route setup after adding VALID_SEVERITIES.
- Find the create_incident() function in app/routes.py.
- Replace that function with the code below:
@api.route("/incidents", methods=["POST"])
def create_incident():
data = request.get_json(silent=True) or {}
title = data.get("title")
severity = data.get("severity", "medium")
if not isinstance(title, str) or not title.strip():
return jsonify({"error": "title is required"}), 400
if severity not in VALID_SEVERITIES:
return jsonify({"error": "severity must be low, medium, or high"}), 400
with SessionLocal.begin() as session:
incident = Incident(title=title.strip(), severity=severity)
session.add(incident)
session.flush()
response = incident.to_dict()
return jsonify(response), 201
How does the request flow work?
- The route reads severity from the request body. A missing value becomes medium.
- The membership check returns HTTP 400 for unsupported values.
- The transaction creates an Incident with the validated severity.
- The response is built after session.flush() assigns the incident identifier.
- Save app/routes.py.
- Return to the Flask terminal from earlier.
You will see another debug reload. The updated route is now serving requests.
- Leave the Flask server running.
- Create a second PowerShell terminal from the Terminal panel.
- Send an unsupported severity to test the validation branch by running:
Invoke-RestMethod -Uri http://127.0.0.1:5000/incidents -Method Post -ContentType "application/json" -Body '{"title":"Database connection lost","severity":"urgent"}'
What does this request test?
The JSON body supplies a valid title with an unsupported severity. The route rejects urgent before opening a database transaction.
PowerShell reports HTTP 400. This proves the request validation works while the database schema remains untouched.
Not seeing HTTP 400?
- Confirm that the Flask server is still running in the first terminal.
- Check that urgent is absent from VALID_SEVERITIES.
- Confirm that the severity check appears above SessionLocal.begin().
Help me diagnose why my invalid severity request does not return HTTP 400.
✔️ Awesome, I've got everything!
Your route now accepts low, medium, or high. Unsupported values stop before the transaction.
ⓧ I'd like to double check the full code
from flask import Blueprint, jsonify, request
from sqlalchemy import select
from app.database import SessionLocal
from app.models import Incident
api = Blueprint("api", __name__)
VALID_SEVERITIES = {"low", "medium", "high"}
@api.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok"})
@api.route("/incidents", methods=["GET"])
def list_incidents():
with SessionLocal() as session:
statement = select(Incident).order_by(Incident.id)
incidents = session.scalars(statement).all()
return jsonify([incident.to_dict() for incident in incidents])
@api.route("/incidents", methods=["POST"])
def create_incident():
data = request.get_json(silent=True) or {}
title = data.get("title")
severity = data.get("severity", "medium")
if not isinstance(title, str) or not title.strip():
return jsonify({"error": "title is required"}), 400
if severity not in VALID_SEVERITIES:
return jsonify({"error": "severity must be low, medium, or high"}), 400
with SessionLocal.begin() as session:
incident = Incident(title=title.strip(), severity=severity)
session.add(incident)
session.flush()
response = incident.to_dict()
return jsonify(response), 201
What should match?
This reference keeps the health and list routes in place. The create route now validates severity before using the existing transaction boundary.
Expose the drift
The application contract now expects three incident columns. PostgreSQL remains at revision 0001 with only id and title.
Before you send this request, predict whether the write succeeds against the table at revision 0001.
- Send a supported high-severity incident from the second PowerShell terminal by running:
Invoke-RestMethod -Uri http://127.0.0.1:5000/incidents -Method Post -ContentType "application/json" -Body '{"title":"Database connection lost","severity":"high"}'
The HTTP 500 Is Expected
PowerShell reports HTTP 500. In the Flask terminal, you will see a database error mentioning the missing severity column.
This is the intended schema-drift failure. SQLAlchemy issued a write for the three-field model while PostgreSQL still has the two-field table.
The failure occurs inside the transaction. No partial incident row is committed.
You have exposed the exact failure that Alembic migration history is designed to control. Next, you will review the migration candidate. You will apply it to bring PostgreSQL back in sync.
Apply and Verify the Severity Migration
Your API now exposes a real model-to-schema mismatch. The Python model expects severity, while PostgreSQL still has only id plus title.
You will turn the SQLAlchemy metadata change into a reviewed Alembic revision. That revision adds the missing column through a repeatable schema change.
A versioned migration can be deployed separately from application code. It can also replay the same change on a clean database.
In this step, get ready to:
- Detect the pending schema change.
- Create a reviewed 0002 migration.
- Prove severity records persist across a Flask restart.
Detect and generate the migration
Alembic compares Base.metadata with the live database schema. Running that comparison now establishes the current drift before you generate a migration.
- Switch back to the PowerShell terminal running Flask.
- Press Ctrl+C to stop the server.
Before you run the comparison, do you expect the model metadata to agree with the database schema?
- Confirm the current schema mismatch by running this command:
alembic check
The Failed Check Is Evidence
Alembic reports pending upgrade operations because the database lacks the severity column. This is the expected result while the database remains at revision 0001.
- Create the deterministic 0002_add_incident_severity.py candidate by running this command:
alembic revision --autogenerate --rev-id 0002 -m "add incident severity"
What Does This Command Do?
- The --autogenerate option compares the model metadata with revision 0001.
- The --rev-id 0002 option assigns a predictable migration identifier.
- The -m "add incident severity" option gives the migration a descriptive filename.
- In Visual Studio Code, expand alembic/versions in the Explorer sidebar.
- Confirm 0002_add_incident_severity.py is listed.
Migration File Missing?
- Confirm the .venv environment is active in your PowerShell prompt.
- Confirm PostgreSQL is still running through Docker Desktop.
- Review the first error shown in the terminal before rerunning the revision command.
Get help with the migration generation error.
Review and apply revision 0002
Autogenerate creates a candidate migration for human review. The review confirms that both the forward path and rollback path affect only the intended column.
- Open alembic/versions/0002_add_incident_severity.py from the Explorer sidebar.
- Compare its contents with the reviewed migration below.
- Replace any differences so the file matches this migration exactly:
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0002"
down_revision: Union[str, Sequence[str], None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"incidents",
sa.Column(
"severity",
sa.String(length=20),
server_default="medium",
nullable=False,
),
)
def downgrade() -> None:
op.drop_column("incidents", "severity")
What Does This Migration Do?
- The revision value identifies this migration as 0002.
- The down_revision value places it directly after revision 0001.
- The upgrade() function adds a non-null VARCHAR(20) column named severity.
- The server_default value gives existing rows the severity medium.
- The downgrade() function removes only the severity column.
- Save alembic/versions/0002_add_incident_severity.py.
- Apply the reviewed migration by running this command:
alembic upgrade head
What Does the Upgrade Change?
Alembic applies the upgrade() function to the live database. It records 0002 as the active revision.
The application model now has a matching database column for every mapped field.
Before you compare the model and schema again, do you expect Alembic to propose another upgrade?
- Check for any remaining schema changes by running this command:
alembic check
The Schema Now Matches
Good work. Alembic reports that no new upgrade operations are needed.
Your model metadata and live database schema now describe the same incidents table.
Check Still Finds a Change?
- Confirm the migration file matches the reviewed version above.
- Confirm the upgrade command completed before running the check again.
- Inspect the first reported operation to identify the remaining model difference.
Help me resolve the remaining Alembic schema difference.
- Double-check 0002_add_incident_severity.py with the tabs below.
✔️ Awesome, I've got everything!
Your 0002 migration is saved. Its upgrade has been applied.
ⓧ I'd like to double check the full code
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0002"
down_revision: Union[str, Sequence[str], None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"incidents",
sa.Column(
"severity",
sa.String(length=20),
server_default="medium",
nullable=False,
),
)
def downgrade() -> None:
op.drop_column("incidents", "severity")
What Should Match?
Check the revision link first. Then confirm the upgrade adds severity while the downgrade removes that same column.
Prove validation and persistence
The schema now matches the model. A valid JSON request should cross the transaction boundary without a database error.
The final proof comes from restarting the Flask process. A record stored in PostgreSQL remains available after that process memory is cleared.
- Switch to the PowerShell terminal reserved for the Flask server.
- Start the migrated API by running this command:
flask --app app run --debug
What Happens Now?
The development server loads the application factory against the migrated schema. It stays attached to this terminal while it handles requests.
You should see the server listening at http://127.0.0.1:5000/.
- Select Terminal from the top menu in Visual Studio Code.
- Select New Terminal to create a second PowerShell terminal.
Before you send the request, do you expect the same severity payload that failed earlier to succeed now?
- Create a valid high-severity incident by running this command in the second terminal:
Invoke-RestMethod -Uri "http://127.0.0.1:5000/incidents" -Method Post -ContentType "application/json" -Body '{"title":"Database latency","severity":"high"}'
What Should You See?
- PowerShell returns an object containing the title Database latency.
- The returned severity is high.
- The Flask terminal records an HTTP status of 201.
Request validation still protects the write boundary. The next request deliberately uses a severity outside the allowed set.
- Send an unsupported severity and expect the API to reject it by running this command:
Invoke-RestMethod -Uri "http://127.0.0.1:5000/incidents" -Method Post -ContentType "application/json" -Body '{"title":"Unknown impact","severity":"critical"}'
The Rejection Is Correct
PowerShell reports an HTTP 400 response. The response body explains that severity must be low, medium, or high.
The invalid request never reaches the database transaction.
- Return to the PowerShell terminal running Flask.
- Press Ctrl+C to stop the server.
- Restart the Flask process by running this command:
flask --app app run --debug
Why Restart the Process?
Restarting Flask clears its process memory. The PostgreSQL container and named volume keep the committed incident record.
- Return to the second PowerShell terminal.
Before you retrieve the incidents, do you expect Database latency to survive the Flask restart?
- Retrieve the persisted incidents by running this command:
Invoke-RestMethod -Uri "http://127.0.0.1:5000/incidents" -Method Get
Persistence Is Proven
You should see the Database latency record with severity high.
That record survived the Flask restart. Revision 0002 has closed the schema drift.
Incident Missing After Restart?
- Confirm Flask is still running in the server terminal.
- Return to the migration substep if the request reaches another missing-column failure.
- Run the upgrade command again before repeating the API request.
Help me diagnose why the migrated incident API is not returning my saved record.
Your incident API now validates severity values and stores them in a reviewed schema. Its migration history can reproduce the change without relying on application startup.
Secret mission
Rehearse a Database Rollback
Move the database back one revision. Witness the application-schema mismatch. Restore migration head to prove recovery.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep your local resources running, pause them for later, or remove them completely. This project creates no cloud charges, but the database volume continues to use space on your computer.
Resources you used:
- The Flask development server process that runs the API.
- The Docker Compose PostgreSQL 18.6 container.
- The Docker Compose project network.
- The postgres_data named volume that stores your incident records.
- The incident-api project folder.
Keep everything running
No action is needed. Choose this if you plan to keep testing the incident API.
- Leave the Flask development server running so the API continues accepting requests.
- Leave the PostgreSQL container running so database connections remain available.
- Keep the Docker Compose project network available.
- Keep postgres_data intact so your incident records remain available.
- Keep incident-api intact so your source code remains available.
Pause - I'll come back to this later
Stop the active processes to free local memory while preserving your workspace. Your database records remain in postgres_data.
- Return to the PowerShell terminal running Flask.
- Press Ctrl+C to stop the development server.
You should return to the PowerShell prompt. This confirms that Flask is no longer using local memory.
- Switch back to the PowerShell terminal in incident-api from earlier.
- Stop the PostgreSQL container by running this command:
docker compose stop
What Does This Command Stop?
This stops the db service without deleting its container.
The project network remains available. The postgres_data volume keeps your incidents.
- Check the db container status in Docker Desktop.
You should see that the PostgreSQL container has stopped. Your project files remain unchanged.
Container Still Running?
- Confirm Docker Desktop is running.
- Confirm PowerShell is inside incident-api, where compose.yaml lives.
- Help me diagnose why docker compose stop failed in my incident-api folder.
Delete - I don't want to use this again
Deleting the named volume permanently removes every incident in your local database. Your source code remains available until you remove the project folder.
- Return to the PowerShell terminal running Flask.
- Press Ctrl+C to stop the development server.
- Switch back to the PowerShell terminal in incident-api from earlier.
- Remove the Docker Compose resources by running this command:
docker compose down -v
What Does This Command Delete?
This removes the db container. It also removes the project network.
The -v option removes postgres_data. Every stored incident is permanently deleted.
- Check Docker Desktop for the project container.
- Check Docker Desktop for the postgres_data volume.
You should no longer see the project container. The postgres_data volume should also be gone.
Docker Resources Still Listed?
- Confirm PowerShell is inside incident-api.
- Confirm Docker Desktop is running.
- Help me diagnose why docker compose down did not remove my local resources.
The local workspace still contains your source code. It also contains the development-only .env configuration.
- Remove the entire incident-api folder by running these PowerShell commands:
Set-Location ..
Remove-Item -Recurse -Force .\incident-api
What Does This Remove?
The first command moves PowerShell to the folder above incident-api. The second command deletes the complete workspace.
This removes .venv. It also removes .env and your migration history.
- Check the parent folder where incident-api was stored.
You should no longer see the incident-api folder. Your local project resources are now fully removed.
Project Folder Still Present?
- Close Visual Studio Code if it still has incident-api open.
- Close any terminal whose current folder is incident-api.
- Help me remove a Windows project folder that PowerShell says is in use.
Nice Work!
Nice Work!
Mission complete! Your incident API now stores validated records across application restarts. You also built a reviewed schema migration history that can move safely between database revisions.
What you learned:
- Built a modular Flask service around an application factory. Registered its routes through a blueprint. The finished API reports health. It creates validated incidents. It lists stored records.
- Replaced volatile process memory with PostgreSQL persistence. Managed database work through a pooled SQLAlchemy engine. Used short-lived sessions for each operation. Protected writes with explicit transaction boundaries.
- Created two ordered Alembic migrations that reproduce the incident schema. Reviewed every upgrade path. Reviewed every downgrade path. Diagnosed schema drift through a real missing-column failure. Repaired the database by applying revision 0002.
- Secret Mission: Rehearsed a one-revision database rollback to 0001. Observed the expected application-schema mismatch. Restored 0002 at migration head. Confirmed the API could create incidents again.
Ready to quiz yourself?