Build a Production-Ready FastAPI App

Build a layered FastAPI app with async SQLAlchemy, Alembic, and Docker.

Introduction

30 Second Summary

Every app you use daily relies on a backend API to store data, enforce rules, and serve responses. The difference between a hobby project and a production system comes down to architecture, testing, and deployment patterns.

In this project, you will build a complete Task Management API using FastAPI. You will set up a layered architecture with database migrations, automated tests, and Docker containerization.

What You'll Build

A production-ready Task Management API with layered architecture, async database access, Alembic migrations, custom error handling, middleware, automated tests, and Docker deployment.

By the end of this project, you'll have:

  • A fully structured FastAPI project with separated layers for routers, schemas, services, and models.
  • A working async database layer with SQLAlchemy, Alembic migrations, and full CRUD operations.
  • Automated tests with pytest and a Dockerfile for containerized deployment.
  • Secret Mission: Add a complete users module to prove you can extend the architecture on your own.

Are there any prerequisites?

No prior experience with FastAPI or SQLAlchemy is required. This guide includes full setup instructions for all tools. Basic familiarity with Python will help you move faster, but every concept is explained as you go.

Set Up Your Development Environment

Before writing any code, you need a solid development environment. This project uses three core tools: Python as the programming language, VS Code as your code editor, and Docker for containerization later.

In this step, you will install these tools, create your project folder, set up a virtual environment, and install all the Python packages your API needs. By the end, your terminal will be ready for building.

In this step, get ready to:
  • Install Python 3.13, VS Code, and Docker Desktop.
  • Create a project folder and virtual environment.
  • Install all project dependencies from a requirements file.
Install Python 3.13, VS Code, and Docker Desktop

Your API is built with Python, so you need Python 3.13 installed. You also need VS Code for editing and Docker Desktop for the containerization step later in the project.

Check whether Python is already installed by opening your terminal.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Check your Python version by running this command:

macOS

python3 --version

Windows

python --version

✔️ I see version 3.13 or higher

Python is ready to go. Move on to the VS Code check below.

ⓧ I see an older version

You have Python installed but it is older than 3.13. You need to install the newer version.

  • Go to https://www.python.org/downloads and download the latest Python 3.13 installer for your operating system.
  • Run the installer. On Windows, check the box that says Add python.exe to PATH before clicking Install Now.
  • Close and reopen your terminal, then run the version check again to confirm it shows 3.13.

Still seeing the old version on macOS?

macOS may ship with an older system Python. After installing Python 3.13, use python3.13 --version to confirm the new version is available. You can use python3.13 explicitly throughout this project if python3 still points to the older version.

help me set up Python 3.13 on macOS when an older version is already installed

ⓧ Command not found

Python is not installed yet. Follow these steps to install it.

  • Go to https://www.python.org/downloads and download the latest Python 3.13 installer for your operating system.
  • Run the installer. On Windows, check the box that says Add python.exe to PATH before clicking Install Now.
  • Close and reopen your terminal, then run the version check command above to confirm it shows 3.13.

Next, make sure VS Code is installed. If you already have it, skip to the Docker Desktop check.

Finally, install Docker Desktop. You will use it in the last step to containerize your API.

Why install Docker now?

Docker Desktop can take a while to download and install. Getting it out of the way now means you will not hit a surprise wait in Step 10 when you containerize the application.

Create the project folder and virtual environment

A virtual environment isolates your project's packages from the rest of your system. This prevents version conflicts between projects.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir taskapi
cd taskapi
  • Create a virtual environment inside your project folder by running:

macOS

python3 -m venv .venv

Windows

python -m venv .venv
  • Activate the virtual environment by running:

macOS

source .venv/bin/activate

Windows

.venv\Scripts\activate
  • Confirm the virtual environment is active. You should see (.venv) at the beginning of your terminal prompt.

What does (.venv) mean?

The (.venv) prefix in your terminal prompt tells you the virtual environment is active. Any pip install commands you run now will install packages inside this isolated environment, not globally on your system.

Don't see (.venv) in your prompt?

On Windows, you may need to allow script execution first. Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned in PowerShell, then try the activate command again.

help me activate my Python virtual environment

Install project dependencies

Production projects list their dependencies in a requirements.txt file with pinned versions. This ensures every developer and deployment uses the same package versions.

  • Create a file called requirements.txt in the root of your taskapi folder. Open it in VS Code and paste the following content:
fastapi[standard]>=0.136.1,<0.137.0
sqlalchemy[asyncio]>=2.0.30
aiosqlite>=0.20.0
alembic>=1.18.4
pydantic-settings>=2.14.0
pytest>=8.2.0
pytest-asyncio>=0.23.0
httpx>=0.27.0

What are these packages?

The first five are your core application dependencies:

  • fastapi[standard] is the web framework for building your API. The [standard] extra includes uvicorn (the server) and other production utilities.
  • sqlalchemy[asyncio] is the database toolkit with async support.
  • aiosqlite is the async driver for SQLite, your local database.
  • alembic manages database migrations (version-controlled schema changes).
  • pydantic-settings handles configuration from environment variables and .env files.

The last three are dev dependencies for automated testing:

  • pytest is the test framework.
  • pytest-asyncio adds support for async test functions.
  • httpx is an async HTTP client used to make test requests against your API.
  • Save requirements.txt.
  • Install all the dependencies by running this command in your terminal (make sure you see (.venv) in your prompt):
pip install -r requirements.txt
  • Wait for the installation to complete. You should see a success message ending with Successfully installed followed by a list of package names.

Seeing installation errors?

Make sure your virtual environment is active (you see (.venv) in your prompt). If you closed your terminal, navigate back to your taskapi folder and run the activate command again before installing.

If you see permission errors, do not use sudo. Reactivate the virtual environment instead.

help me fix my pip install errors for this FastAPI project

  • Verify FastAPI installed correctly by running:
pip show fastapi
  • You should see package details including Name: fastapi and a version number starting with 0.136.

✔️ Awesome, I've got everything!

Great. Double check you have saved requirements.txt in the root of your taskapi folder.

ⓧ I'd like to double check the full code

Here is the complete requirements.txt file. Make sure yours matches exactly:

fastapi[standard]>=0.136.1,<0.137.0
sqlalchemy[asyncio]>=2.0.30
aiosqlite>=0.20.0
alembic>=1.18.4
pydantic-settings>=2.14.0
pytest>=8.2.0
pytest-asyncio>=0.23.0
httpx>=0.27.0

Your development environment is ready. Next up, you will create the full project folder structure that organizes your code into clean, separated layers.

Create the Project Folder Structure

Your development environment is ready with all dependencies installed. Now it's time to create the folder structure that organizes your API into clean, separated layers.

Production FastAPI projects split code into directories by responsibility. Routes handle HTTP concerns, services handle business logic, models handle data, and config handles settings.

In this step, get ready to:
  • Create the production-ready directory structure.
  • Add package files and placeholder modules.
  • Create root configuration files.
Create the directories and package files

A layered architecture keeps each part of your application isolated. Each folder maps to a specific responsibility in the system.

Why separate into layers?

When everything lives in one file, small changes ripple unpredictably. Separating routes from services from models means you can change your database logic without touching HTTP code, swap your database without rewriting business rules, or add new endpoints without risking existing ones.

This is the same pattern used in production FastAPI projects at companies. Once you learn it here, you can reuse it for any future API.

  • Create all the project directories by running this command:

macOS

mkdir -p app/api/v1 app/core app/db app/models app/schemas app/services app/middleware migrations tests

Windows

mkdir app/api/v1, app/core, app/db, app/models, app/schemas, app/services, app/middleware, migrations, tests

What do these folders do?

  • app/api/v1/ holds your versioned API routes (HTTP layer).
  • app/core/ holds configuration and shared exceptions.
  • app/db/ holds database connection and base model setup.
  • app/models/ holds ORM model definitions (your database tables as Python classes).
  • app/schemas/ holds Pydantic schemas for request/response validation.
  • app/services/ holds business logic, separate from HTTP concerns.
  • app/middleware/ holds request/response processing layers like logging.
  • migrations/ holds Alembic database migration scripts.
  • tests/ holds your automated test files.
  • Python needs __init__.py files to recognize directories as importable packages. Create them by running:

macOS

touch app/__init__.py app/api/__init__.py app/api/v1/__init__.py app/core/__init__.py app/db/__init__.py app/models/__init__.py app/schemas/__init__.py app/services/__init__.py app/middleware/__init__.py tests/__init__.py

Windows

New-Item -ItemType File app/__init__.py, app/api/__init__.py, app/api/v1/__init__.py, app/core/__init__.py, app/db/__init__.py, app/models/__init__.py, app/schemas/__init__.py, app/services/__init__.py, app/middleware/__init__.py, tests/__init__.py
  • Confirm the directories exist by running:
ls app

You should see api, core, db, middleware, models, schemas, services, and __init__.py listed in the output.

Don't see the expected folders?

  • Make sure your terminal is inside the taskapi directory before running the commands.
  • On Windows, check that you're using PowerShell (not Command Prompt). Command Prompt does not support the mkdir syntax with commas.
  • help me fix my directory structure
Create the placeholder application files

Each of these files will hold a specific piece of your application. For now, you are creating them empty so the project structure is complete before you start writing code.

  • Create the placeholder module files by running:

macOS

touch app/main.py app/core/config.py app/core/exceptions.py app/db/session.py app/db/base.py app/api/v1/router.py

Windows

New-Item -ItemType File app/main.py, app/core/config.py, app/core/exceptions.py, app/db/session.py, app/db/base.py, app/api/v1/router.py

What are these files for?

  • app/main.py is the application entry point where FastAPI starts.
  • app/core/config.py manages settings like database URLs and environment variables.
  • app/core/exceptions.py defines custom error types for clean error handling.
  • app/db/session.py sets up the async database connection.
  • app/db/base.py defines the base class all database models inherit from.
  • app/api/v1/router.py aggregates all API route files into one versioned router.
  • Confirm the placeholder files exist by running:
ls app/core

You should see __init__.py, config.py, and exceptions.py listed.

Create the root configuration files

The project root needs a few files that control environment settings and version control behavior.

  • Create the root configuration files by running:

macOS

touch .env .env.example .gitignore

Windows

New-Item -ItemType File .env, .env.example, .gitignore
  • Open .gitignore in VS Code and paste the following content:
.venv/
__pycache__/
*.pyc
.env
taskapi.db
.pytest_cache/
htmlcov/

What does this .gitignore do?

  • .venv/ keeps your virtual environment out of version control (it's large and machine-specific).
  • __pycache__/ and *.pyc exclude compiled Python bytecode files.
  • .env prevents your environment variables (which may contain secrets) from being committed.
  • taskapi.db excludes the SQLite database file since it's generated locally.
  • Save .gitignore.
  • Confirm all root files exist by running:
ls -a

You should see .env, .env.example, .gitignore, app, migrations, and tests in the output.

Missing .env or .gitignore in the listing?

  • Files starting with a dot are hidden by default. On macOS/Linux, use ls -a (not just ls) to show hidden files. On Windows PowerShell, ls shows all files including dotfiles by default.
  • help me find my dotfiles

✔️ Awesome, I've got everything!

Great. Double check you've saved .gitignore and move on to the next step.

ⓧ I'd like to double check the full code

Here is the only file with content at this point. All other files (__init__.py files, main.py, config.py, etc.) should be empty.

.venv/
__pycache__/
*.pyc
.env
taskapi.db
.pytest_cache/
htmlcov/

Your project skeleton is complete. Next up, you'll bring it to life by configuring the application with Pydantic Settings and creating the FastAPI entry point.

Configure the Application with Pydantic Settings

Your folder structure is ready. Now you need a way to manage all the configuration values your app depends on, like the database URL, project name, and allowed origins for CORS.

Hardcoding these values throughout your codebase leads to painful bugs when you deploy to production. In this step, you will create a centralized configuration system using Pydantic Settings, wire up environment files, and build the application factory that ties everything together.

In this step, get ready to:
  • Create a typed Settings class that reads configuration from environment variables.
  • Populate the .env and .env.example files with your project configuration.
  • Build the FastAPI application factory with middleware, error handling, and router registration.
Create the settings module with Pydantic

Pydantic Settings lets you define a Python class where each field maps to an environment variable. It automatically reads from a .env file, validates types, and provides defaults.

  • Open app/core/config.py in VS Code and paste the following code:
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    # Application metadata
    PROJECT_NAME: str = "Task Management API"
    VERSION: str = "1.0.0"
    ENVIRONMENT: str = "development"

    # Database connection string
    DATABASE_URL: str = "sqlite+aiosqlite:///./taskapi.db"

    # Origins allowed to make cross-origin requests
    ALLOWED_ORIGINS: list[str] = ["http://localhost:3000"]

    # Read values from the .env file, match case exactly
    model_config = {"env_file": ".env", "case_sensitive": True}


# Create a single shared instance used across the app
settings = Settings()

What does this code do?

The Settings class inherits from BaseSettings, which gives it the power to read environment variables automatically.

  • Each class field (like DATABASE_URL) maps to an environment variable of the same name.
  • The model_config dictionary tells Pydantic to also look for values in a .env file.
  • The module-level settings instance is created once and imported wherever configuration is needed.
  • Save app/core/config.py.

You won't see a visible change yet. The next chunk creates the .env file that feeds values into this settings class.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    PROJECT_NAME: str = "Task Management API"
    VERSION: str = "1.0.0"
    ENVIRONMENT: str = "development"

    DATABASE_URL: str = "sqlite+aiosqlite:///./taskapi.db"

    ALLOWED_ORIGINS: list[str] = ["http://localhost:3000"]

    model_config = {"env_file": ".env", "case_sensitive": True}


settings = Settings()
Set up environment and dependency files

The .env file holds the actual values your app reads at runtime. The .env.example file is a safe-to-commit template that tells collaborators which variables they need to set.

  • Open .env in your project root and paste the following:
PROJECT_NAME=Task Management API
VERSION=1.0.0
ENVIRONMENT=development
DATABASE_URL=sqlite+aiosqlite:///./taskapi.db
ALLOWED_ORIGINS=["http://localhost:3000"]
  • Open .env.example and paste the same content:
PROJECT_NAME=Task Management API
VERSION=1.0.0
ENVIRONMENT=development
DATABASE_URL=sqlite+aiosqlite:///./taskapi.db
ALLOWED_ORIGINS=["http://localhost:3000"]

Why keep both .env and .env.example?

Your .gitignore already excludes .env from version control. That keeps secrets out of your repository.

The .env.example IS committed. It serves as documentation so anyone cloning the project knows which variables to configure.

You also need a requirements.txt that pins your dependency versions. This ensures consistent installs across machines and inside Docker containers.

  • Create a file called requirements.txt in your project root and paste the following:
fastapi[standard]>=0.136.1,<0.137.0
sqlalchemy[asyncio]>=2.0.30
aiosqlite>=0.20.0
alembic>=1.18.4
pydantic-settings>=2.14.0
pytest>=8.2.0
pytest-asyncio>=0.23.0
httpx>=0.27.0

What does this file do?

Each line pins a minimum version for a package you already installed in Step 1. The >= syntax means "this version or newer." The <0.137.0 on FastAPI adds a ceiling to avoid accidental breaking changes.

  • Save all three files: .env, .env.example, and requirements.txt.
  • Confirm the files exist by running:
ls .env .env.example requirements.txt

You should see all three filenames listed in the output.

File not found?

Make sure your terminal is in the taskapi project folder. If you see an error, run cd ~/Desktop/taskapi to navigate back.

Also check that your files are saved in the project root, not inside the app/ folder.

Help me find my .env file in the taskapi project

Build the application factory

The application factory is a function that constructs and returns your FastAPI app instance. This pattern keeps configuration, middleware, and route registration in one predictable place.

  • Open app/main.py and paste the imports and lifespan function:
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware

from app.api.v1.router import api_router
from app.core.config import settings
from app.core.exceptions import AppException
from app.db.session import engine
from app.db.base import Base
from app.middleware.logging import log_requests


# Lifespan context manager handles startup and shutdown events
@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    # Clean up the database engine when the app shuts down
    await engine.dispose()

What is a lifespan context manager?

FastAPI uses lifespan context managers to run code at startup (before yield) and shutdown (after yield). Here, the shutdown logic disposes the database engine to release connections cleanly.

This replaces the older @app.on_event("startup") and @app.on_event("shutdown") decorators, which are deprecated.

  • Below the lifespan function in the same file, paste the application factory:
def create_application() -> FastAPI:
    application = FastAPI(
        title=settings.PROJECT_NAME,
        version=settings.VERSION,
        docs_url="/docs",
        redoc_url="/redoc",
        lifespan=lifespan,
    )

    # Allow cross-origin requests from the configured origins
    application.add_middleware(
        CORSMiddleware,
        allow_origins=settings.ALLOWED_ORIGINS,
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )

    # Add request logging middleware
    application.add_middleware(BaseHTTPMiddleware, dispatch=log_requests)

    # Convert custom exceptions into structured JSON responses
    @application.exception_handler(AppException)
    async def app_exception_handler(request: Request, exc: AppException):
        return JSONResponse(
            status_code=exc.status_code,
            content={"detail": exc.detail, "type": type(exc).__name__},
        )

    # Mount all API v1 routes under /api/v1
    application.include_router(api_router, prefix="/api/v1")

    return application


# Create the app instance that the server will use
app = create_application()

What does the factory function do?

  • Creates a FastAPI instance and pulls its title and version from settings (your Pydantic config).
  • Adds CORS middleware so frontend apps can call the API without browser security errors.
  • Registers a global exception handler that converts AppException errors into clean JSON with a status code, detail message, and error type.
  • Includes all versioned routes under the /api/v1 prefix.

Why a factory function instead of a bare app?

Wrapping creation in a function gives you one place to change middleware, routes, or config. It also makes testing easier because you can create fresh app instances with different settings.

  • Save app/main.py.

You won't be able to run the app yet. The imports reference modules (like app.db.session and app.middleware.logging) that are still empty. The next step fills in the database layer so the app can start.

Seeing a red underline in VS Code?

Your editor may show import errors for app.db.session, app.core.exceptions, or app.middleware.logging. This is expected because those modules are empty or do not exist yet.

The errors disappear as you build each module in the following steps.

Help me understand the import errors in my main.py

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware

from app.api.v1.router import api_router
from app.core.config import settings
from app.core.exceptions import AppException
from app.db.session import engine
from app.db.base import Base
from app.middleware.logging import log_requests


@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    await engine.dispose()


def create_application() -> FastAPI:
    application = FastAPI(
        title=settings.PROJECT_NAME,
        version=settings.VERSION,
        docs_url="/docs",
        redoc_url="/redoc",
        lifespan=lifespan,
    )

    application.add_middleware(
        CORSMiddleware,
        allow_origins=settings.ALLOWED_ORIGINS,
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )

    application.add_middleware(BaseHTTPMiddleware, dispatch=log_requests)

    @application.exception_handler(AppException)
    async def app_exception_handler(request: Request, exc: AppException):
        return JSONResponse(
            status_code=exc.status_code,
            content={"detail": exc.detail, "type": type(exc).__name__},
        )

    application.include_router(api_router, prefix="/api/v1")

    return application


app = create_application()

Your configuration layer is locked in. Every part of the app can now import settings and get validated, type-safe values from the environment. Next up, you will build the database layer with async SQLAlchemy so the app can store and retrieve tasks.

Set Up the Database Layer

Your application has a configuration that points to a SQLite database, but no actual code to connect to it.

In this step, you will build the async database layer using SQLAlchemy. This includes an engine that manages connections, a session factory that hands out database sessions, and a Task model that maps Python objects to database rows.

In this step, get ready to:
  • Set up the async database engine and session factory.
  • Create the Task ORM model.
  • Verify the database layer works by running the app.
Create the database base class

Every SQLAlchemy model inherits from a shared base class. This base class holds metadata about all your tables, which SQLAlchemy uses to create and manage the database schema.

  • Open app/db/base.py and add the following code:
from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
    pass

What does this code do?

DeclarativeBase is SQLAlchemy's modern way to define a base class for ORM models. Every model you create will inherit from Base, which lets SQLAlchemy track all your tables in one place.

  • Save app/db/base.py.
Create the async session factory

The session factory is responsible for creating database connections on demand. Each API request gets its own session, uses it to read or write data, and then returns it when done.

  • Open app/db/session.py and add the following code:
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker

from app.core.config import settings

# Create the async engine that manages database connections
engine = create_async_engine(
    settings.DATABASE_URL,
    echo=settings.ENVIRONMENT == "development",
)

# Factory that produces new AsyncSession instances
AsyncSessionFactory = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,
)


# Dependency that FastAPI endpoints will use to get a database session
async def get_db():
    async with AsyncSessionFactory() as session:
        try:
            yield session
        except Exception:
            await session.rollback()
            raise

What does this code do?

create_async_engine builds the connection engine using the DATABASE_URL from your settings. The echo parameter logs all SQL queries to the console in development mode.

async_sessionmaker is a factory that creates fresh database sessions. Setting expire_on_commit=False means you can still access object attributes after committing without triggering another database query.

get_db() is a FastAPI dependency. Each request calls it, receives a session, and the try/except ensures the session rolls back if anything goes wrong.

  • Save app/db/session.py.

Seeing an import error?

Make sure your virtual environment is activated. If sqlalchemy is not found, run pip install sqlalchemy[asyncio] aiosqlite again.

help me fix an import error with SQLAlchemy in my FastAPI project

Create the Task model

An ORM model is a Python class that maps directly to a database table. Each attribute on the class becomes a column in the table. When you create an instance of the class, you are creating a row.

  • Open app/models/task.py and add the following code:
from datetime import datetime

from sqlalchemy import Boolean, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func

from app.db.base import Base


class Task(Base):
    __tablename__ = "tasks"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
    title: Mapped[str] = mapped_column(String(200), nullable=False)
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    completed: Mapped[bool] = mapped_column(Boolean, default=False)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now()
    )
    updated_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), onupdate=func.now()
    )

What does this code do?

The Task class maps to a tasks table in the database. Each attribute uses Mapped type hints paired with mapped_column to define both the Python type and the database column type.

server_default=func.now() tells the database to automatically set the creation timestamp. onupdate=func.now() automatically updates the timestamp whenever the row changes.

  • Save app/models/task.py.
  • Open app/models/__init__.py and add this import so that other parts of the app can discover the Task model:
from app.models.task import Task  # noqa: F401

Why import the model here?

SQLAlchemy needs to know about all your models before it can create or manage tables. Importing Task in __init__.py ensures the model is registered with Base.metadata whenever the app.models package is imported.

  • Save app/models/__init__.py.

Seeing a circular import error?

Make sure app/db/base.py only imports from sqlalchemy.orm and nothing else from your app. The Base class must be defined before any model imports it.

help me debug a circular import in my SQLAlchemy models

Run the app and verify the database layer

Everything is wired together. Your app/main.py already imports engine from app.db.session and Base from app.db.base. Now that these files have real code, the app can start successfully.

  • Start the development server by running:
fastapi dev app/main.py
  • Confirm the server starts without errors. You should see output indicating the app is running at http://127.0.0.1:8000.
  • Check that taskapi.db has been created in your taskapi project folder.
  • Stop the server with Ctrl+C.

Server won't start?

Check the error message carefully. A ModuleNotFoundError means one of your imports is pointing to the wrong path. Make sure all __init__.py files exist in app/, app/db/, app/models/, app/core/, and app/middleware/.

help me fix my FastAPI app that won't start due to import errors

✔️ Awesome, I've got everything!

Great. Double check you have saved all files before continuing.

ⓧ I'd like to double check the full code

Here is the complete code for the three files you created or updated in this step.

from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
    pass
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker

from app.core.config import settings

engine = create_async_engine(
    settings.DATABASE_URL,
    echo=settings.ENVIRONMENT == "development",
)

AsyncSessionFactory = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,
)


async def get_db():
    async with AsyncSessionFactory() as session:
        try:
            yield session
        except Exception:
            await session.rollback()
            raise
from datetime import datetime

from sqlalchemy import Boolean, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func

from app.db.base import Base


class Task(Base):
    __tablename__ = "tasks"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
    title: Mapped[str] = mapped_column(String(200), nullable=False)
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    completed: Mapped[bool] = mapped_column(Boolean, default=False)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now()
    )
    updated_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), onupdate=func.now()
    )
from app.models.task import Task  # noqa: F401

Your database layer is ready. Next up, you will build the Pydantic schemas and service layer that sit between your API endpoints and the database.

Build the Schemas and Service Layer

You have a Task model that tells SQLAlchemy how to store data in the database. But there is nothing yet to validate incoming data from API requests, or to contain the business logic for creating, reading, updating, and deleting tasks.

In this step, you will build two new layers. Pydantic schemas define the shape of data flowing in and out of your API. A service class contains all the business logic for working with tasks.

In this step, get ready to:
  • Create Pydantic schemas for validating task data.
  • Build a service class with full CRUD operations.
Create the task schemas

Schemas define the contract between your API and its clients. You need separate schemas for creating a task, updating a task, and returning a task in responses.

  • In VS Code, open app/schemas/task.py and add the imports and input schemas by pasting the following code:
from datetime import datetime
from typing import Optional

from pydantic import BaseModel, Field


# Schema for creating a new task
class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    description: Optional[str] = None
    completed: bool = False


# Schema for updating an existing task (all fields optional)
class TaskUpdate(BaseModel):
    title: Optional[str] = Field(default=None, min_length=1, max_length=200)
    description: Optional[str] = None
    completed: Optional[bool] = None

What does this code do?

TaskCreate defines what data a client must send to create a task. The Field(min_length=1, max_length=200) constraint means the title cannot be empty or exceed 200 characters. Pydantic rejects invalid data before it ever reaches your database.

TaskUpdate makes every field optional. This way, a client can send only the fields they want to change instead of the entire task object.

  • Below the TaskUpdate class, add the response schema:

# Schema for returning task data in API responses
class TaskResponse(BaseModel):
    id: int
    title: str
    description: Optional[str]
    completed: bool
    created_at: datetime
    updated_at: Optional[datetime]

    # Allows Pydantic to read data from SQLAlchemy model attributes
    model_config = {"from_attributes": True}

What does from_attributes do?

By default, Pydantic models expect dictionary input. Setting from_attributes to True tells Pydantic to read data directly from object attributes. This lets you pass a SQLAlchemy Task instance and have it automatically converted into a JSON-friendly response.

  • Save app/schemas/task.py.

Seeing a red underline on the import?

If VS Code shows a warning on from pydantic import BaseModel, Field, make sure your virtual environment is active and selected as the Python interpreter. Press Ctrl+Shift+P, type Python: Select Interpreter, and choose the .venv interpreter.

help me fix import errors in VS Code for my FastAPI project

✔️ Awesome, I've got everything!

Great. Double check you have saved app/schemas/task.py.

ⓧ I'd like to double check the full code

from datetime import datetime
from typing import Optional

from pydantic import BaseModel, Field


class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    description: Optional[str] = None
    completed: bool = False


class TaskUpdate(BaseModel):
    title: Optional[str] = Field(default=None, min_length=1, max_length=200)
    description: Optional[str] = None
    completed: Optional[bool] = None


class TaskResponse(BaseModel):
    id: int
    title: str
    description: Optional[str]
    completed: bool
    created_at: datetime
    updated_at: Optional[datetime]

    model_config = {"from_attributes": True}
Build the task service

The service layer is where all your business logic lives. It sits between the API router and the database, keeping each layer focused on a single job.

  • Open app/services/task_service.py and paste the following code:
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.exceptions import NotFoundException
from app.models.task import Task
from app.schemas.task import TaskCreate, TaskUpdate


class TaskService:
    def __init__(self, db: AsyncSession):
        self.db = db

    async def create_task(self, task_in: TaskCreate) -> Task:
        # Convert the Pydantic schema to a SQLAlchemy model instance
        task = Task(**task_in.model_dump())
        self.db.add(task)
        await self.db.commit()
        await self.db.refresh(task)
        return task

    async def get_tasks(self, limit: int = 10, offset: int = 0) -> list[Task]:
        # Fetch a paginated list of tasks
        result = await self.db.execute(
            select(Task).offset(offset).limit(limit)
        )
        return list(result.scalars().all())

    async def get_by_id(self, task_id: int) -> Task:
        # Fetch a single task or raise NotFoundException
        result = await self.db.execute(
            select(Task).where(Task.id == task_id)
        )
        task = result.scalars().first()
        if not task:
            raise NotFoundException("Task")
        return task

    async def update_task(self, task_id: int, task_in: TaskUpdate) -> Task:
        task = await self.get_by_id(task_id)
        # Only apply fields the client actually sent
        update_data = task_in.model_dump(exclude_unset=True)
        for field, value in update_data.items():
            setattr(task, field, value)
        await self.db.commit()
        await self.db.refresh(task)
        return task

    async def delete_task(self, task_id: int) -> None:
        task = await self.get_by_id(task_id)
        await self.db.delete(task)
        await self.db.commit()

What does this code do?

TaskService receives a database session in its constructor. Each method performs one CRUD operation using async SQLAlchemy queries.

create_task converts the Pydantic schema into a SQLAlchemy model with model_dump(), saves it, and returns the refreshed object (now with an id and timestamps).

update_task uses exclude_unset=True so only the fields the client actually sent get changed. If a client sends just {"completed": true}, the title and description stay untouched.

Why does the service raise Python exceptions instead of HTTP exceptions?

The service layer knows nothing about HTTP. When a task is not found, it raises NotFoundException instead of FastAPI's HTTPException. This keeps the service reusable in non-HTTP contexts like background tasks, CLI scripts, or tests.

A global exception handler (which you will create in a later step) translates these Python exceptions into proper HTTP responses. The router stays thin and the service stays portable.

  • Save app/services/task_service.py.
  • You will not see a visible change yet. The service depends on NotFoundException which you will define in a later step. For now, the code is in place and ready to be connected.

Seeing an import error for NotFoundException?

This is expected. The app/core/exceptions.py file is still empty. You will create the exception classes in a later step. The import will resolve then.

help me understand why my service import is failing

✔️ Awesome, I've got everything!

Great. Double check you have saved app/services/task_service.py.

ⓧ I'd like to double check the full code

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.exceptions import NotFoundException
from app.models.task import Task
from app.schemas.task import TaskCreate, TaskUpdate


class TaskService:
    def __init__(self, db: AsyncSession):
        self.db = db

    async def create_task(self, task_in: TaskCreate) -> Task:
        task = Task(**task_in.model_dump())
        self.db.add(task)
        await self.db.commit()
        await self.db.refresh(task)
        return task

    async def get_tasks(self, limit: int = 10, offset: int = 0) -> list[Task]:
        result = await self.db.execute(
            select(Task).offset(offset).limit(limit)
        )
        return list(result.scalars().all())

    async def get_by_id(self, task_id: int) -> Task:
        result = await self.db.execute(
            select(Task).where(Task.id == task_id)
        )
        task = result.scalars().first()
        if not task:
            raise NotFoundException("Task")
        return task

    async def update_task(self, task_id: int, task_in: TaskUpdate) -> Task:
        task = await self.get_by_id(task_id)
        update_data = task_in.model_dump(exclude_unset=True)
        for field, value in update_data.items():
            setattr(task, field, value)
        await self.db.commit()
        await self.db.refresh(task)
        return task

    async def delete_task(self, task_id: int) -> None:
        task = await self.get_by_id(task_id)
        await self.db.delete(task)
        await self.db.commit()

Your schemas and service layer are ready. Next up, you will build the API router that connects incoming HTTP requests to your service methods.

Build the API Router

You have schemas that validate data and a service that handles business logic. The missing piece is the HTTP layer that connects incoming requests to your service.

In this step, you will build the API router layer. Each endpoint function is deliberately thin. It receives the request, hands it to the service, and returns the result.

In this step, get ready to:
  • Define custom exception classes so the service layer can raise meaningful errors.
  • Create CRUD endpoints for tasks (create, list, get, update, delete).
  • Add a health check endpoint and test the full API in Swagger UI.
Define custom exception classes

Your TaskService already imports NotFoundException from app/core/exceptions.py. That file is currently empty, so the app cannot start yet.

  • Open app/core/exceptions.py in VS Code and add the following code:
class AppException(Exception):
    def __init__(self, status_code: int, detail: str):
        self.status_code = status_code
        self.detail = detail


class NotFoundException(AppException):
    def __init__(self, resource: str):
        super().__init__(404, f"{resource} not found")


class ConflictException(AppException):
    def __init__(self, detail: str):
        super().__init__(409, detail)

What does this code do?

AppException is the base class that carries a status_code and a detail message. The global exception handler in main.py catches any AppException and converts it to a JSON response automatically.

NotFoundException and ConflictException are specific error types your services will raise. The service layer never imports HTTPException from FastAPI. It stays decoupled from HTTP.

  • Save app/core/exceptions.py.
Create the task endpoints

Each endpoint function follows the same pattern: receive the request, get a database session via dependency injection, instantiate the service, call a method, and return the result.

  • Create a new file app/api/v1/tasks.py and add the imports plus the create and list endpoints:
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.db.session import get_db
from app.schemas.task import TaskCreate, TaskUpdate, TaskResponse
from app.services.task_service import TaskService

router = APIRouter()


@router.post("/", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
async def create_task(
    task_in: TaskCreate,
    db: AsyncSession = Depends(get_db),
):
    service = TaskService(db)
    return await service.create_task(task_in)


@router.get("/", response_model=list[TaskResponse])
async def list_tasks(
    limit: int = Query(default=10, le=100),
    offset: int = Query(default=0, ge=0),
    db: AsyncSession = Depends(get_db),
):
    service = TaskService(db)
    return await service.get_tasks(limit=limit, offset=offset)

What does this code do?

Depends(get_db) tells FastAPI to call the get_db generator and inject the resulting session into each endpoint. This keeps your endpoints free of database setup boilerplate.

The create_task endpoint returns HTTP 201 (Created). The list_tasks endpoint uses Query parameters with validation constraints so clients can paginate results.

  • Below the list_tasks function, add the get, update, and delete endpoints:
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(
    task_id: int,
    db: AsyncSession = Depends(get_db),
):
    service = TaskService(db)
    return await service.get_by_id(task_id)


@router.put("/{task_id}", response_model=TaskResponse)
async def update_task(
    task_id: int,
    task_in: TaskUpdate,
    db: AsyncSession = Depends(get_db),
):
    service = TaskService(db)
    return await service.update_task(task_id, task_in)


@router.delete("/{task_id}", status_code=status.HTTP_200_OK)
async def delete_task(
    task_id: int,
    db: AsyncSession = Depends(get_db),
):
    service = TaskService(db)
    await service.delete_task(task_id)
    return {"message": f"Task {task_id} deleted successfully"}

What does this code do?

Each endpoint extracts task_id from the URL path. If the task does not exist, TaskService raises NotFoundException. The global exception handler in main.py catches it and returns a 404 JSON response.

Notice how the router functions contain zero business logic. They translate HTTP into service calls and return results. This keeps every layer independently testable.

  • Save app/api/v1/tasks.py.

Seeing a red underline on the imports?

Make sure your virtual environment is active (.venv\Scripts\activate) and that VS Code is using the correct Python interpreter from your .venv folder.

help me fix import errors in my FastAPI project

Create the health check and wire up the router

A health check endpoint lets container orchestrators verify your app is running and can reach the database.

  • Create a new file app/api/v1/health.py and add this code:
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession

from app.db.session import get_db

router = APIRouter()


@router.get("/")
async def health_check(db: AsyncSession = Depends(get_db)):
    try:
        await db.execute(text("SELECT 1"))
        return {"status": "healthy", "database": "ok"}
    except Exception:
        return {"status": "unhealthy", "database": "error"}

What does this code do?

The endpoint runs SELECT 1 against the database. If the query succeeds, the response reports healthy. If the database is unreachable, it catches the exception and reports unhealthy.

  • Save app/api/v1/health.py.
  • Open app/api/v1/router.py and add the aggregating router that pulls in both sub-routers:
from fastapi import APIRouter

from app.api.v1 import tasks, health

api_router = APIRouter()
api_router.include_router(health.router, prefix="/health", tags=["Health"])
api_router.include_router(tasks.router, prefix="/tasks", tags=["Tasks"])

What does this code do?

This file creates a single api_router that gathers all your endpoint modules. The main.py file already mounts this router at /api/v1. So the health endpoint becomes /api/v1/health/ and task endpoints live under /api/v1/tasks/.

  • Save app/api/v1/router.py.

Your main.py also imports a request logging middleware. You need to create that file before the app can start.

  • Create a new file app/middleware/logging.py and add this code:
import logging
import time

from fastapi import Request

logger = logging.getLogger(__name__)


async def log_requests(request: Request, call_next):
    start_time = time.time()
    response = await call_next(request)
    duration = time.time() - start_time
    logger.info(
        f"{request.method} {request.url.path} - {response.status_code} - {round(duration * 1000, 2)}ms"
    )
    return response

What does this code do?

This middleware runs on every request. It records the start time, lets the request proceed, then logs the HTTP method, path, status code, and how many milliseconds it took. You will revisit this in a later step.

  • Save app/middleware/logging.py.
  • Start the dev server by running this command in your terminal:
fastapi dev app/main.py
  • Open your browser and go to http://127.0.0.1:8000/docs.

You should see the Swagger UI with two sections: Health and Tasks. Each task endpoint (POST, GET, PUT, DELETE) is listed with its path and parameters.

  • Click the POST /api/v1/tasks/ row to expand it.
  • Click Try it out.
  • Replace the request body with this JSON:
{
  "title": "My first task",
  "description": "Testing the API",
  "completed": false
}
  • Click Execute.

You should see a 201 response with the task data including a generated id and created_at timestamp.

Seeing a server error or the app won't start?

Check your terminal for the error message. A common cause is a typo in an import path. Make sure app/api/v1/tasks.py imports from app.db.session and app.schemas.task exactly as shown.

help me debug my FastAPI app that won't start

✔️ Awesome, I've got everything!

Great. Make sure you have saved all your files before moving on.

ⓧ I'd like to double check the full code

class AppException(Exception):
    def __init__(self, status_code: int, detail: str):
        self.status_code = status_code
        self.detail = detail


class NotFoundException(AppException):
    def __init__(self, resource: str):
        super().__init__(404, f"{resource} not found")


class ConflictException(AppException):
    def __init__(self, detail: str):
        super().__init__(409, detail)
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.db.session import get_db
from app.schemas.task import TaskCreate, TaskUpdate, TaskResponse
from app.services.task_service import TaskService

router = APIRouter()


@router.post("/", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
async def create_task(
    task_in: TaskCreate,
    db: AsyncSession = Depends(get_db),
):
    service = TaskService(db)
    return await service.create_task(task_in)


@router.get("/", response_model=list[TaskResponse])
async def list_tasks(
    limit: int = Query(default=10, le=100),
    offset: int = Query(default=0, ge=0),
    db: AsyncSession = Depends(get_db),
):
    service = TaskService(db)
    return await service.get_tasks(limit=limit, offset=offset)


@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(
    task_id: int,
    db: AsyncSession = Depends(get_db),
):
    service = TaskService(db)
    return await service.get_by_id(task_id)


@router.put("/{task_id}", response_model=TaskResponse)
async def update_task(
    task_id: int,
    task_in: TaskUpdate,
    db: AsyncSession = Depends(get_db),
):
    service = TaskService(db)
    return await service.update_task(task_id, task_in)


@router.delete("/{task_id}", status_code=status.HTTP_200_OK)
async def delete_task(
    task_id: int,
    db: AsyncSession = Depends(get_db),
):
    service = TaskService(db)
    await service.delete_task(task_id)
    return {"message": f"Task {task_id} deleted successfully"}
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession

from app.db.session import get_db

router = APIRouter()


@router.get("/")
async def health_check(db: AsyncSession = Depends(get_db)):
    try:
        await db.execute(text("SELECT 1"))
        return {"status": "healthy", "database": "ok"}
    except Exception:
        return {"status": "unhealthy", "database": "error"}
from fastapi import APIRouter

from app.api.v1 import tasks, health

api_router = APIRouter()
api_router.include_router(health.router, prefix="/health", tags=["Health"])
api_router.include_router(tasks.router, prefix="/tasks", tags=["Tasks"])
import logging
import time

from fastapi import Request

logger = logging.getLogger(__name__)


async def log_requests(request: Request, call_next):
    start_time = time.time()
    response = await call_next(request)
    duration = time.time() - start_time
    logger.info(
        f"{request.method} {request.url.path} - {response.status_code} - {round(duration * 1000, 2)}ms"
    )
    return response

Your API is live and accepting requests. Next up, you will improve the error handling and add request logging middleware so every request is tracked.

Add Custom Error Handling and Middleware

Production APIs need two things that beginners often skip: structured error responses and visibility into what is happening at runtime. Without these, debugging a failed request means guessing.

Your app already has custom exception classes and a global error handler wired into app/main.py. In this step, you will complete the picture by writing a request logging middleware that records every API call to your terminal.

In this step, get ready to:
  • Explore how custom exceptions produce clean JSON error responses.
  • Write the request logging middleware in app/middleware/logging.py.
  • Verify that both features work by testing API endpoints.
Explore the custom error handling pattern

Before writing new code, take a moment to understand the error handling architecture already in place. Three files work together to give your API clean, predictable error responses.

  • Open app/core/exceptions.py in VS Code.

You will see three exception classes already defined:

class AppException(Exception):
    def __init__(self, status_code: int, detail: str):
        self.status_code = status_code
        self.detail = detail


class NotFoundException(AppException):
    def __init__(self, resource: str):
        super().__init__(404, f"{resource} not found")


class ConflictException(AppException):
    def __init__(self, detail: str):
        super().__init__(409, detail)

Why custom exceptions instead of HTTPException?

FastAPI provides HTTPException, but it ties your business logic to HTTP. Custom exceptions like NotFoundException keep your service layer reusable outside HTTP contexts (background tasks, CLI scripts, tests).

The global exception handler in main.py catches any AppException and converts it to a clean JSON response with the right status code. Your routers never need to import HTTPException at all.

  • Now open app/services/task_service.py and find the get_by_id method.

Notice how the service raises NotFoundException("Task") when a task is not found. It knows nothing about HTTP status codes or JSON responses.

  • Open app/main.py and find the exception handler registration.

The handler catches AppException and returns a JSONResponse with the status code, detail message, and exception type name. This is the bridge between your service layer and the HTTP world.

How the pieces connect

  • Service raises NotFoundException("Task").
  • Global handler catches it because NotFoundException inherits from AppException.
  • Handler returns {"detail": "Task not found", "type": "NotFoundException"} with a 404 status.
Write the request logging middleware

Middleware sits between the client and your route handlers. Every request passes through it on the way in, and every response passes through on the way out. This makes it the perfect place to log timing data.

  • Open app/middleware/logging.py and add the following code:
import logging
import time

from fastapi import Request

# Create a logger scoped to this module
logger = logging.getLogger(__name__)


async def log_requests(request: Request, call_next):
    # Record the start time before the request is processed
    start_time = time.time()
    response = await call_next(request)
    # Calculate how long the request took in milliseconds
    duration = time.time() - start_time
    logger.info(
        f"{request.method} {request.url.path} - {response.status_code} - {round(duration * 1000, 2)}ms"
    )
    return response

What does this code do?

  • logging.getLogger(__name__) creates a logger that identifies itself by module name in log output.
  • time.time() captures a timestamp before the request hits your route handler.
  • await call_next(request) passes the request to the next middleware or the route handler, then returns the response.
  • The log line records the HTTP method, path, status code, and response time in milliseconds.
  • Save app/middleware/logging.py.

Where is this middleware registered?

If you look at app/main.py, you will see application.add_middleware(BaseHTTPMiddleware, dispatch=log_requests). This was added in a previous step, wiring up the middleware before you wrote its implementation.

Seeing an ImportError?

If you see an error about importing log_requests, make sure you saved app/middleware/logging.py and that the function name is exactly log_requests. Also confirm that app/middleware/__init__.py exists (it can be empty).

Help me debug this ImportError.

Test error handling and logging together

With the middleware in place, every request to your API will be logged to the terminal. You can also see the error handler in action by requesting a task that does not exist.

  • Start the development server by running:
fastapi dev app/main.py
  • Open your browser and go to http://localhost:8000/docs.
  • Expand the GET /api/v1/tasks/ endpoint, click Try it out, then click Execute.
  • Switch back to your terminal.

You should see a log line that looks like: GET /api/v1/tasks/ - 200 - 12.5ms. The exact duration will vary on your machine.

  • Now test the error handler. In Swagger UI, expand GET /api/v1/tasks/{task_id}, click Try it out, enter 99999 as the task ID, and click Execute.

You should see a 404 response body with your custom error format:

{
  "detail": "Task not found",
  "type": "NotFoundException"
}

In your terminal, you will also see the middleware logged this request with a 404 status code.

Why this pattern matters

The router code for the get_task endpoint does not have any error handling logic. It calls the service and returns the result. The exception flows up automatically, and the global handler converts it to the right HTTP response.

This keeps your routers thin and your error responses consistent across every endpoint in the app.

Not seeing log output?

Python's logging module defaults to WARNING level. FastAPI's dev server configures INFO-level logging, so you should see the output. If logs are missing, check that your function uses logger.info() (not logger.debug()).

Help me debug missing log output in my FastAPI middleware.

✔️ Awesome, I've got everything!

Great. Double check you have saved app/middleware/logging.py and that logs appear in your terminal when you hit any endpoint.

ⓧ I'd like to double check the full code

Here is the complete app/middleware/logging.py file:

import logging
import time

from fastapi import Request

logger = logging.getLogger(__name__)


async def log_requests(request: Request, call_next):
    start_time = time.time()
    response = await call_next(request)
    duration = time.time() - start_time
    logger.info(
        f"{request.method} {request.url.path} - {response.status_code} - {round(duration * 1000, 2)}ms"
    )
    return response

Your API now has structured error responses and request-level observability. Every request is logged with its method, path, status code, and duration. Next up, you will set up Alembic to manage database schema changes with version-controlled migrations.

Set Up Alembic Migrations

Your API is fully functional, but right now the database schema is managed informally. If you add a column or rename a table in production, you have no version history and no way to roll back.

Alembic is the database migration tool built for SQLAlchemy. It tracks every schema change as a versioned script, so you can upgrade, downgrade, and share database changes across environments. In this step, you will initialize Alembic, configure it for your async database, and generate your first migration.

In this step, get ready to:
  • Initialize Alembic with its async template.
  • Configure the migration environment to work with your async SQLAlchemy setup.
  • Generate and apply your first database migration.
Initialize Alembic

Before generating migrations, you need to clean up any existing database file. If taskapi.db already exists from running the app in earlier steps, Alembic's autogenerate will see the tables are already there and produce an empty migration.

  • Delete the existing database file (if it exists) by running this command:
del taskapi.db 2>nul

What does this command do?

This deletes taskapi.db if it exists. The 2>nul suppresses the error message if the file is not found. On macOS or Linux, use rm -f taskapi.db instead.

  • Initialize Alembic with the async template by running:
alembic init --template async migrations

What does this command do?

This creates a migrations/ directory with an async-ready env.py, a script.py.mako template, and a versions/ subdirectory for migration scripts. It also creates alembic.ini at the project root.

  • Confirm the files were created by running:
ls migrations

You should see env.py, script.py.mako, and a versions folder listed.

Command not found?

If alembic is not recognized, make sure your virtual environment is activated (.venv\Scripts\activate on Windows). Alembic was installed as part of your project dependencies in Step 1.

help me fix Alembic not being recognized in my terminal

Now you need to update alembic.ini so it points to your database.

  • Open alembic.ini in VS Code (it is in the root of your taskapi folder).
  • Find this line:
sqlalchemy.url = driver://user:pass@localhost/dbname
  • Replace it with your SQLite async URL so the line reads:
sqlalchemy.url = sqlite+aiosqlite:///./taskapi.db
  • Also find and update the script_location line to confirm it says:
script_location = migrations
  • Save alembic.ini.

Why update the URL here?

Alembic reads alembic.ini to know which database to connect to when running migrations. This URL must match the one in your .env file.

Configure the async migration environment

The generated env.py is a starting template. You need to customize it so Alembic can find your models and generate migrations automatically.

  • Open migrations/env.py and replace its entire contents with the following:
import asyncio
from logging.config import fileConfig

from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config

from alembic import context

from app.db.base import Base
import app.models  # noqa: F401

config = context.config
fileConfig(config.config_file_name)
target_metadata = Base.metadata


def run_migrations_offline():
    url = config.get_main_option("sqlalchemy.url")
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )
    with context.begin_transaction():
        context.run_migrations()


def do_run_migrations(connection):
    context.configure(connection=connection, target_metadata=target_metadata)
    with context.begin_transaction():
        context.run_migrations()


async def run_async_migrations():
    connectable = async_engine_from_config(
        config.get_section(config.config_ini_section, {}),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )
    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)
    await connectable.dispose()


def run_migrations_online():
    asyncio.run(run_async_migrations())


if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()

What does this code do?

This file tells Alembic how to connect to your database and which models to compare against.

  • import app.models loads your Task model so Alembic can detect its table definition.
  • target_metadata = Base.metadata gives Alembic the full picture of what your database should look like.
  • async_engine_from_config creates an async engine from alembic.ini settings, and connection.run_sync bridges the sync migration runner to the async connection.
  • pool.NullPool ensures connections are not pooled during migrations, preventing stale connection issues.
  • Save migrations/env.py.

Seeing an import error?

If you see an error about app.db.base or app.models not being found, make sure you are running Alembic commands from the root of your taskapi folder (where alembic.ini lives). Alembic needs to find the app/ package on the Python path.

help me fix import errors when running Alembic

Generate and apply the first migration

With the environment configured, Alembic can now compare your models against the database and generate a migration script automatically.

  • Generate your first migration by running:
alembic revision --autogenerate -m "create tasks table"

What does this command do?

--autogenerate tells Alembic to compare your model metadata against the current database state. Since the database is empty, it detects the entire tasks table as new and generates a script with the CREATE TABLE statement.

The generated file appears inside migrations/versions/ with a unique revision ID in its filename.

  • Check the output. You should see a message confirming a new revision was generated, similar to:
Generating ...migrations/versions/abc123_create_tasks_table.py ...  done

The exact filename will differ because Alembic generates a unique revision ID each time.

  • Apply the migration to create the table by running:
alembic upgrade head

What does this command do?

alembic upgrade head runs every pending migration up to the latest revision. It creates the tasks table in your database and adds an alembic_version table to track which migrations have been applied.

  • Verify the migration worked by starting the app:
fastapi dev app/main.py
  • Open your browser to http://localhost:8000/api/v1/health/.
  • You should see {"status": "healthy", "database": "ok"} confirming the database is accessible with the migrated schema.
  • Stop the dev server with Ctrl+C.

Migration shows an empty upgrade?

If the generated migration file has pass inside the upgrade() function instead of table creation code, Alembic found a taskapi.db that already has the tasks table. Delete the database file and the migrations/versions/ folder contents, then re-run the revision and upgrade commands.

help me fix an empty Alembic migration

✔️ Awesome, I've got everything!

Great. Make sure you have saved both alembic.ini and migrations/env.py.

ⓧ I'd like to double check the full code

Here is the complete alembic.ini:

[alembic]
script_location = migrations
sqlalchemy.url = sqlite+aiosqlite:///./taskapi.db

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

Here is the complete migrations/env.py:

import asyncio
from logging.config import fileConfig

from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config

from alembic import context

from app.db.base import Base
import app.models  # noqa: F401

config = context.config
fileConfig(config.config_file_name)
target_metadata = Base.metadata


def run_migrations_offline():
    url = config.get_main_option("sqlalchemy.url")
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )
    with context.begin_transaction():
        context.run_migrations()


def do_run_migrations(connection):
    context.configure(connection=connection, target_metadata=target_metadata)
    with context.begin_transaction():
        context.run_migrations()


async def run_async_migrations():
    connectable = async_engine_from_config(
        config.get_section(config.config_ini_section, {}),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )
    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)
    await connectable.dispose()


def run_migrations_online():
    asyncio.run(run_async_migrations())


if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()

Here is the complete app/main.py (unchanged from the previous step):

from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware

from app.api.v1.router import api_router
from app.core.config import settings
from app.core.exceptions import AppException
from app.db.session import engine
from app.db.base import Base
from app.middleware.logging import log_requests


@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    await engine.dispose()


def create_application() -> FastAPI:
    application = FastAPI(
        title=settings.PROJECT_NAME,
        version=settings.VERSION,
        docs_url="/docs",
        redoc_url="/redoc",
        lifespan=lifespan,
    )

    application.add_middleware(
        CORSMiddleware,
        allow_origins=settings.ALLOWED_ORIGINS,
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )

    application.add_middleware(BaseHTTPMiddleware, dispatch=log_requests)

    @application.exception_handler(AppException)
    async def app_exception_handler(request: Request, exc: AppException):
        return JSONResponse(
            status_code=exc.status_code,
            content={"detail": exc.detail, "type": type(exc).__name__},
        )

    application.include_router(api_router, prefix="/api/v1")

    return application


app = create_application()

Your database schema is now version-controlled. Any time you change a model, you can generate a new migration and apply it cleanly. Next up, you will write automated tests to make sure your API works correctly.

Write Automated Tests

Your API works when you test it manually in Swagger UI. But manual testing does not scale.

Automated tests verify your API's behavior in seconds. They catch regressions before they reach production. In this step, you'll set up pytest with an in-memory database so tests run fast and never touch your real data.

In this step, get ready to:
  • Create test fixtures that provide an isolated in-memory database and HTTP client.
  • Write five tests covering all CRUD operations and error responses.
  • Run the test suite and confirm everything passes.
Create the test configuration file

Tests need their own database so they never pollute your development data. Pytest fixtures let you define reusable setup logic that runs before each test.

  • Open tests/conftest.py in VS Code (create the file inside the tests/ folder) and paste in the following code:
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker

from app.db.base import Base
from app.db.session import get_db
from app.main import app

# Use an in-memory SQLite database for fast, isolated tests
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"


@pytest_asyncio.fixture(scope="session")
async def test_engine():
    # Create a fresh engine and build all tables once per test session
    engine = create_async_engine(TEST_DATABASE_URL, echo=False)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()


@pytest_asyncio.fixture
async def db_session(test_engine):
    # Create a new session for each test, then roll back to keep tests isolated
    session_factory = async_sessionmaker(
        test_engine, class_=AsyncSession, expire_on_commit=False
    )
    async with session_factory() as session:
        yield session
        await session.rollback()


@pytest_asyncio.fixture
async def client(db_session):
    # Override the app's get_db dependency with the test session
    async def override_get_db():
        yield db_session

    app.dependency_overrides[get_db] = override_get_db
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as ac:
        yield ac
    app.dependency_overrides.clear()

What does this code do?

This file sets up three pytest fixtures that work together to give each test a clean environment.

  • test_engine creates an in-memory SQLite database and builds all your tables from Base.metadata. It runs once per test session for speed.
  • db_session opens a fresh AsyncSession for each test and rolls it back afterwards. This keeps tests isolated from each other.
  • client swaps the real get_db dependency with the test session, then provides an AsyncClient that sends HTTP requests directly to your app without starting a server.

Why dependency overrides?

FastAPI's dependency injection system lets you swap any dependency at runtime. By overriding get_db with a function that yields the test session, every endpoint automatically uses the in-memory database instead of your real one.

  • Save tests/conftest.py.

You will not see a change yet. The next file uses these fixtures to run actual test cases.

Write the test functions

Each test function exercises one endpoint and asserts on the HTTP status code and response body. The client fixture you just created is automatically injected by pytest.

  • Create tests/test_tasks.py and paste in the following code:
import pytest


@pytest.mark.asyncio
async def test_create_task(client):
    # POST a new task and verify it returns 201 with the correct data
    response = await client.post(
        "/api/v1/tasks/",
        json={"title": "Test task", "description": "A test", "completed": False},
    )
    assert response.status_code == 201
    data = response.json()
    assert data["title"] == "Test task"
    assert data["completed"] is False
    assert "id" in data


@pytest.mark.asyncio
async def test_list_tasks(client):
    # Create a task, then verify the list endpoint returns 200 with an array
    await client.post("/api/v1/tasks/", json={"title": "Task 1"})
    response = await client.get("/api/v1/tasks/")
    assert response.status_code == 200
    data = response.json()
    assert isinstance(data, list)


@pytest.mark.asyncio
async def test_get_task_not_found(client):
    # Requesting a non-existent task should return 404
    response = await client.get("/api/v1/tasks/99999")
    assert response.status_code == 404
    assert response.json()["detail"] == "Task not found"


@pytest.mark.asyncio
async def test_update_task(client):
    # Create a task, then update its title and verify the change
    create_response = await client.post(
        "/api/v1/tasks/", json={"title": "Original"}
    )
    task_id = create_response.json()["id"]
    response = await client.put(
        f"/api/v1/tasks/{task_id}", json={"title": "Updated"}
    )
    assert response.status_code == 200
    assert response.json()["title"] == "Updated"


@pytest.mark.asyncio
async def test_delete_task(client):
    # Create a task, delete it, and verify 200 response
    create_response = await client.post(
        "/api/v1/tasks/", json={"title": "To delete"}
    )
    task_id = create_response.json()["id"]
    response = await client.delete(f"/api/v1/tasks/{task_id}")
    assert response.status_code == 200

What does each test verify?

  • test_create_task confirms POST returns 201 and the response includes the task's title, completed status, and an auto-generated ID.
  • test_list_tasks confirms GET returns 200 with a list.
  • test_get_task_not_found confirms requesting a non-existent task returns 404 with the detail message from your custom NotFoundException.
  • test_update_task creates a task, updates its title via PUT, and verifies the response reflects the new title.
  • test_delete_task creates a task, deletes it, and confirms a 200 response.
  • Save tests/test_tasks.py.

Both test files are ready. Next you will run them to confirm everything passes.

Seeing red squiggles in VS Code?

Your editor may show import errors for pytest_asyncio or httpx. Make sure your VS Code Python interpreter is set to the .venv virtual environment. Press Ctrl+Shift+P and select Python: Select Interpreter, then choose the one inside .venv.

help me fix import errors in my test files

Run the test suite
  • Run all tests in verbose mode by running this command:
pytest tests/ -v

You should see output showing all 5 tests passing with green PASSED markers next to each test name.

Tests failing or command not found?

  • If you see command not found, make sure your virtual environment is activated. Run .venv\Scripts\activate and try again.
  • If tests fail with import errors, confirm you installed all dev dependencies with pip install pytest pytest-asyncio httpx.
  • If you see a ScopeMismatch error, make sure the test_engine fixture has scope="session" and the other two fixtures do not specify a scope.

help me debug my failing pytest tests

✔️ Awesome, I've got everything!

Great. Double check you have saved both tests/conftest.py and tests/test_tasks.py.

ⓧ I'd like to double check the full code

Here is the complete tests/conftest.py:

import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker

from app.db.base import Base
from app.db.session import get_db
from app.main import app

TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"


@pytest_asyncio.fixture(scope="session")
async def test_engine():
    engine = create_async_engine(TEST_DATABASE_URL, echo=False)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()


@pytest_asyncio.fixture
async def db_session(test_engine):
    session_factory = async_sessionmaker(
        test_engine, class_=AsyncSession, expire_on_commit=False
    )
    async with session_factory() as session:
        yield session
        await session.rollback()


@pytest_asyncio.fixture
async def client(db_session):
    async def override_get_db():
        yield db_session

    app.dependency_overrides[get_db] = override_get_db
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as ac:
        yield ac
    app.dependency_overrides.clear()

Here is the complete tests/test_tasks.py:

import pytest


@pytest.mark.asyncio
async def test_create_task(client):
    response = await client.post(
        "/api/v1/tasks/",
        json={"title": "Test task", "description": "A test", "completed": False},
    )
    assert response.status_code == 201
    data = response.json()
    assert data["title"] == "Test task"
    assert data["completed"] is False
    assert "id" in data


@pytest.mark.asyncio
async def test_list_tasks(client):
    await client.post("/api/v1/tasks/", json={"title": "Task 1"})
    response = await client.get("/api/v1/tasks/")
    assert response.status_code == 200
    data = response.json()
    assert isinstance(data, list)


@pytest.mark.asyncio
async def test_get_task_not_found(client):
    response = await client.get("/api/v1/tasks/99999")
    assert response.status_code == 404
    assert response.json()["detail"] == "Task not found"


@pytest.mark.asyncio
async def test_update_task(client):
    create_response = await client.post(
        "/api/v1/tasks/", json={"title": "Original"}
    )
    task_id = create_response.json()["id"]
    response = await client.put(
        f"/api/v1/tasks/{task_id}", json={"title": "Updated"}
    )
    assert response.status_code == 200
    assert response.json()["title"] == "Updated"


@pytest.mark.asyncio
async def test_delete_task(client):
    create_response = await client.post(
        "/api/v1/tasks/", json={"title": "To delete"}
    )
    task_id = create_response.json()["id"]
    response = await client.delete(f"/api/v1/tasks/{task_id}")
    assert response.status_code == 200

Your API is now backed by automated tests. Next up, you will containerize the whole application with Docker so it can run anywhere.

Containerize with Docker

Your API is fully built, tested, and ready for deployment. Running it locally with fastapi dev works for development, but it ties the app to your machine and your specific Python environment.

Docker packages your application, its dependencies, and its runtime into a single portable image. Anyone with Docker installed can run your API with one command, regardless of their operating system or Python version.

In this step, get ready to:
  • Create a Dockerfile that builds your application image.
  • Create a docker-compose.yml for single-command deployment.
  • Build and run the container, then verify the API works.
Create the Dockerfile

A Dockerfile is a set of instructions that tells Docker how to build your application image. It specifies the base operating system, installs dependencies, copies your code, and defines the command that runs when the container starts.

  • Create a new file called Dockerfile in the root of your taskapi folder (the same level as requirements.txt).
  • Paste the following into Dockerfile:
FROM python:3.13-slim

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["fastapi", "run", "app/main.py", "--host", "0.0.0.0", "--port", "8000"]

What does this Dockerfile do?

  • FROM python:3.13-slim starts from a lightweight Python 3.13 image. The slim variant excludes build tools you don't need at runtime, keeping the image small.
  • PYTHONDONTWRITEBYTECODE=1 prevents Python from writing .pyc files. PYTHONUNBUFFERED=1 ensures log output appears immediately in the container logs.
  • COPY requirements.txt . is copied separately before the rest of the code. This means Docker can cache the installed dependencies and skip reinstalling them unless requirements.txt changes.
  • CMD runs fastapi run in production mode (no auto-reload) on port 8000, listening on all network interfaces.
  • Save Dockerfile.

File has no extension?

The file must be named exactly Dockerfile with a capital D and no file extension. If your editor added .txt or another extension, rename it.

help me create a Dockerfile without an extension

Create the docker-compose file

Docker Compose lets you define and run your container with a single configuration file. Instead of typing a long docker run command with ports, environment variables, and volumes, you declare everything in a YAML file.

  • Create a new file called docker-compose.yml in the root of your taskapi folder.
  • Paste the following into docker-compose.yml:
services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=sqlite+aiosqlite:///./taskapi.db
      - ENVIRONMENT=production
      - PROJECT_NAME=Task Management API
    volumes:
      - db_data:/app

volumes:
  db_data:

What does this configuration do?

  • build: . tells Docker Compose to build the image from the Dockerfile in the current directory.
  • ports: "8000:8000" maps port 8000 inside the container to port 8000 on your machine.
  • environment injects environment variables directly into the container. These override any .env file values.
  • volumes: db_data:/app creates a named volume so the SQLite database persists even if the container is removed.
  • Save docker-compose.yml.

Why set ENVIRONMENT to production?

Your Settings class uses the ENVIRONMENT variable to control SQLAlchemy's echo mode. In production, SQL logging is turned off so your container logs stay clean and focused on request traffic.

Build and run the container

With both files in place, you can build the image and start the container in one command.

  • Make sure Docker Desktop is running. You should see the Docker icon in your system tray.
  • In your terminal (still inside the taskapi folder), build and start the container by running:
docker compose up --build

What does this command do?

docker compose up starts the services defined in docker-compose.yml. The --build flag forces a fresh image build so your latest code changes are included.

  • Wait for the build to complete. You should see output showing Docker installing your Python packages and then starting the FastAPI server.
  • Open your browser and navigate to http://localhost:8000/docs.
  • You should see the Swagger UI with all your task endpoints listed. Try creating a task through the UI to confirm everything works inside the container.

Container won't start?

  • If you see a "port already in use" error, stop the local dev server first. You may still have fastapi dev running on port 8000. Press Ctrl+C in that terminal window.
  • If Docker reports a build error about missing packages, double-check that requirements.txt exists at the project root (same folder as Dockerfile).
  • If you see "Docker daemon is not running", open Docker Desktop and wait for it to finish starting before retrying.

help me debug my Docker build

  • To stop the container, press Ctrl+C in the terminal where it is running.

✔️ Awesome, I've got everything!

Great. Double check you have saved both Dockerfile and docker-compose.yml in the root of your project.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile:

FROM python:3.13-slim

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["fastapi", "run", "app/main.py", "--host", "0.0.0.0", "--port", "8000"]

Here is the complete docker-compose.yml:

services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=sqlite+aiosqlite:///./taskapi.db
      - ENVIRONMENT=production
      - PROJECT_NAME=Task Management API
    volumes:
      - db_data:/app

volumes:
  db_data:

Here is the complete requirements.txt:

fastapi[standard]>=0.136.1,<0.137.0
sqlalchemy[asyncio]>=2.0.30
aiosqlite>=0.20.0
alembic>=1.18.4
pydantic-settings>=2.14.0
pytest>=8.2.0
pytest-asyncio>=0.23.0
httpx>=0.27.0

Your API is now fully containerized and portable. Anyone can clone your project and run it with a single docker compose up --build command. Next up, the Secret Mission challenges you to prove you have internalized this architecture by extending it with a brand new module.

Secret mission

Add a Users Module

You've built a complete Task Management API with proper layered architecture. Now prove you truly understand the pattern by adding a full Users module from scratch. Same layers, same separation of concerns, same professional quality.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally, so there are no ongoing cloud costs.

Resources you used:

  • taskapi project folder (all source code, configuration, and virtual environment).
  • taskapi.db SQLite database file.
  • Docker containers and images built during the containerization step.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project with the Secret Mission.

  • Your Docker container may still be running from Step 10. It uses minimal resources locally.
  • The SQLite database file and virtual environment stay on disk but cost nothing.
  • Python, VS Code, and Docker Desktop remain installed on your machine.

Pause - I'll come back to this later

Shut down running containers to free up memory while keeping all your files intact.

  • Open your terminal and navigate to your taskapi folder.
  • Stop the Docker containers by running:
docker compose down
  • Your project files, database, and virtual environment all remain on disk for when you return.
  • To resume later, navigate back to the taskapi folder, activate your virtual environment, and run fastapi dev app/main.py.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Stop and remove the Docker containers by running:
docker compose down
  • Remove the Docker image built for this project by running:
docker image prune -f
  • Delete the entire project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf taskapi
  • On Windows, run:
cd ~\Desktop
rmdir /s /q taskapi
  • This removes all source code, the .venv virtual environment, and the taskapi.db database file.

That's a Wrap!

That's a Wrap!

Nice work! You just built a complete, production-ready FastAPI application from scratch.

You now have a reusable template for building any API project with proper architecture, testing, and deployment.

You've learned how to:

  • Build a layered FastAPI application with proper separation of concerns across routers, schemas, services, and models.
  • Set up async SQLAlchemy with dependency injection and Alembic migrations for version-controlled database schema changes.
  • Write automated tests with pytest and httpx, then containerize the entire application with Docker and docker-compose.
  • Extended the architecture with a complete users module, proving the layered pattern is repeatable for any new domain.

Ready to quiz yourself?