Building a REST API with FastAPI (Step-by-Step Guide)

In our previous lesson, we learned the Relationship Between FastAPI, SQLAlchemy, ORM, and Database Connections. This time, we move on to the practical foundation of nearly all modern web development: the REST API. This tutorial will guide you step-by-step through setting up a complete REST API using FastAPI, focusing on the absolutely fundamental concepts you need to master. By the end, you’ll know exactly how to structure our resources and define our basic CRUD operations.

Table of Contents

🧱 Building a REST API in FastAPI β€” Todo List Example

In this section, we’ll learn how to build a real REST API using FastAPI and SQLAlchemy ORM, based on everything we learned about database engines, sessions, and connection pools earlier plus horizontal architecture.

Our example project will be a simple Todo List API β€” a perfect beginner-friendly example that covers CRUD operations (Create, Read, Update, Delete) using sqlite.

🧩 1. Basic Project Structure

We are following the layer or horizontal archtiecture for our todo list application.

project/
β”œβ”€β”€ .env
β”œβ”€β”€ main.py
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ core/               # Core configuration and initialization
β”‚   β”‚   β”œβ”€β”€ config.py       # Pydantic Settings, app config
β”‚   β”‚
β”‚   β”œβ”€β”€ api/
β”‚   β”‚   β”œβ”€β”€ v1/
β”‚   β”‚   β”‚   β”œβ”€β”€ __init__.py
β”‚   β”‚   β”‚   └── todo.py
β”‚   β”‚   └── router.py        # APIRouter aggregator
β”‚   β”‚
β”‚   β”œβ”€β”€ db/
β”‚   β”‚   β”œβ”€β”€ base.py      # Base = declarative_base(), import all models here
β”‚   β”‚   β”œβ”€β”€ session.py       # engine, SessionLocal, get_db dependency
β”‚   β”‚   β”œβ”€β”€ init_db.py       # create_all (only for dev, not for prod)
β”‚   β”‚   └── models/
β”‚   β”‚       β”œβ”€β”€ todo.py
β”‚   β”‚       └── __init__.py
β”‚   β”‚
β”‚   β”œβ”€β”€ repositories/
β”‚   β”‚   β”œβ”€β”€ todo_repo.py
β”‚   β”‚   └── __init__.py
β”‚   β”‚
β”‚   β”œβ”€β”€ services/
β”‚   β”‚   β”œβ”€β”€ todo_service.py
β”‚   β”‚   └── __init__.py
β”‚   β”œβ”€β”€ schemas/
β”‚   β”‚   β”œβ”€β”€ todo.py
β”‚   β”‚   └── __init__.py
β”‚   β”‚
β”‚   └── __init__.py
β”‚
└── requirements.txt / pyproject.toml

Step 1: Defining configuration for our application

Purpose of config.py : The config.py is our central configuration file β€” it keeps environment-specific values (like database URLs, secret keys, API keys, etc.) separate from our main code. Instead of hardcoding things like:

DATABASE_URL = "postgresql://postgres:postgres@localhost/mydb"

We store them in config.py, often loading from environment variables.

  1. This makes our app flexible:
  2. Docker or deployment systems can inject environment variables automatically.
  3. You can switch from local to production DB easily.
  4. You keep sensitive data (passwords, hostnames) out of our code.

We need to define the our database configuration path in app/core/config.py file as

from pydantic_settings import BaseSettings
import os

# Get the current directory
current_file_dir = os.path.dirname(os.path.abspath(__file__))
# Construct the correct path for the database
database_file_name = 'todo_database.db'
database_path_segments = [current_file_dir, '..', 'db', database_file_name]

absolute_database_path = os.path.normpath(os.path.join(*database_path_segments))

class Settings(BaseSettings):
    # Use f-string or os.path.normpath to construct the SQLite URL
    database_url: str = f"sqlite:///{absolute_database_path}"

    class Config:
        env_file = ".env"

settings = Settings()

πŸ—‚οΈ Common Types of Settings in config.py

Here’s a categorized view of what you can (and should) store there πŸ‘‡

🧱 A. Database Configuration βœ… Always belongs here

  • Database URL / host / user / password
  • Pool size, timeout, or SSL settings
database_url: str = "postgresql://user:pass@localhost/mydb"

πŸ” B. Security & Authentication βœ… Yes, include these

  • JWT (JSON Web Token) secret key
  • Access/refresh token expiry time
  • Password hashing salt or algorithm
  • OAuth credentials (Google, GitHub login, etc.)
secret_key: str = "super-secret-key"
algorithm: str = "HS256"
access_token_expire_minutes: int = 30

πŸ€– C. External API Keys & Services Definitely belongs here

  • API keys for third-party services (like OpenAI, Stripe, AWS, etc.)
  • Webhook URLs
  • SMTP (email) credentials
  • File storage (S3, GCP) credentials
openai_api_key: str = "sk-xxxx"
stripe_secret_key: str = "sk_test_..."
smtp_server: str = "smtp.gmail.com"
smtp_password: str = "email_password"

πŸ’‘ These can be loaded via environment variables (for security) instead of hardcoding them.

🧰 D. Application Settings Good to include

  • Debug mode (True/False)
  • Project name or version
  • Base URLs
  • Logging level
app_name: str = "Todo REST API"
debug: bool = True
api_version: str = "v1"

🌐 E. Frontend / CORS / Domain Settings Optional but useful

  • Allowed frontend origins
  • Base URLs for services
allowed_origins: list[str] = ["http://localhost:3000", "https://myapp.com"]

🚫 What Not to Put in config.py

❌ Application Logic: No functions, algorithms, or endpoint code. Keep it purely for configuration data.

❌ Sensitive Keys in Plaintext: Don’t hardcode passwords or API keys in config.py directly.
Use environment variables (.env, Docker secrets, etc.) and load them safely.

❌ Large data or constants: Don’t dump big dictionaries, file paths, or unrelated static data.
Keep it focused on environment settings.

🧩 pydantic_settings comes from the pydantic-settings library.

  • It’s an official extension of Pydantic
  • It provides the BaseSettings class β€” used for managing configuration and environment variables.
  • Purpose: pydantic-settings automatically reads values from: 1. Environment variables (export DATABASE_URL=...) Or a .env file and loads them into our app as typed Python attributes.

The inner config class read : environment variables and if environment variables aren’t found in the system, look inside a file named .env in the project root. The .env file

DATABASE_URL=postgresql://postgres:postgres@db:5432/myfastapiapp
OPENAI_API_KEY=sk-123abc
SECRET_KEY=mysecret

This file is ignored by Git (to keep secrets private), but our app will automatically load these values when it starts.
βœ… settings = Settings() This creates an object settings that holds all our configuration values in memory. It gives us the database URL safely β€” no need to manually read environment variables.

ConceptExplanation
pydantic_settingsLibrary that loads config from environment or .env file
BaseSettingsBase class that makes our settings automatic and type-safe
Settings classOur custom config model β€” defines all environment variables
Config.env_fileOptional .env file to load local development settings
settings instanceGlobal object to access configuration anywhere in our app

πŸ“¦ 2. Defining API Data Shapes (Schemas) for Our Todo App

Before our API can accept or return any data, we need to define the shape of that data. In FastAPI, these shapes are defined using Pydantic models, also known as schemas.

These schemas act as the API contract, enforcing what structure the client must sendβ€”and what structure our API will return. They ensure consistency, validation, and clean communication between backend and frontend.

Why schemas matter

Using Pydantic models gives us:

  • Automatic validation β€” ensures incoming data is valid before touching the database
  • Response formatting β€” controls what fields are returned to the client
  • Automatic documentation β€” FastAPI uses schemas to generate clean OpenAPI docs
  • Safety β€” prevents malformed data from breaking our API

In short: schemas enforce clean, predictable communication.

πŸ“ app/schemas/todo.py

from pydantic import BaseModel
from enum import Enum

class TodoStatus(str, Enum):
    PENDING = "Pending"
    COMPLETED = "Completed"
    IN_PROCESS = "In Process"

class TodoBase(BaseModel):
    task: str
    status: TodoStatus = TodoStatus.PENDING

class TodoCreate(TodoBase):
    pass

class Todo(TodoBase):
    id: int

    class Config:
        from_attributes = True  # Enables ORM mode

What each schema does

SchemaPurpose
TodoBaseShared fields for todos (task, status)
TodoCreateUsed when creating a new todo. Inherits everything from TodoBase.
TodoUsed for reading todo data. Includes the id field returned from the database.

from_attributes = True allows Pydantic to read SQLAlchemy ORM models directly β€” perfect when returning ORM objects from the database.

Schemas guarantee that your API always sends and receives clean, well-structured data, making debugging easier and preventing invalid data from slipping in.


🧱 3. Setting Up SQLAlchemy: Base Class, Database Connection & Session

FastAPI doesn’t include its own ORM, so we use SQLAlchemy, the most popular Python ORM. It lets us interact with relational databases without writing raw SQL.

To integrate SQLAlchemy with FastAPI, we need to:

1️⃣ Create the database
2️⃣ Define a base class for ORM models
3️⃣ Create an engine (connection to the DB)
4️⃣ Create session management
5️⃣ Initialize the database (dev-only)

Let’s break this down.

Step 1 β€” Create the SQLite database

For this tutorial, we use SQLite, a file-based database. Inside app/db/todo_database.db create an empty file and this becomes our database file.

Step 2 β€” Define the SQLAlchemy Base class

Every SQLAlchemy model must inherit from a common Base class so SQLAlchemy can track and generate their tables.

πŸ“ app/db/base.py

from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

Later, if you use Alembic for migrations, you can import all models here so Alembic can discover them:

from app.db.models.user import User
from app.db.models.todo import Todo

(Not required yet, but very helpful in larger apps.)

Step 3 β€” Database Initialization (Dev Only)

During development, we need a quick way to create database tables automatically.

πŸ“ app/db/init_db.py

from app.db.base import Base
from app.db.session import engine

def init_db():
    Base.metadata.create_all(bind=engine)
    print("Database initialized.")

This scans all models that inherit from Base and creates matching tables in the database.

Step 4 β€” Setting Up the Session

Every API request that interacts with the database needs its own session, we have full tutorial on SQLAlchemy setup in our application in previous tutorial.

πŸ“ app/db/session.py

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings

# 1. Create SQLAlchemy engine
engine = create_engine(
    settings.database_url,
    connect_args={"check_same_thread": False}  # Required for SQLite
)

# 2. Session factory
SessionLocal = sessionmaker(
    autocommit=False,
    autoflush=False,
    bind=engine
)

# 3. Dependency for FastAPI
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

What this file does

ResponsibilityDescription
1️⃣ Create EngineOpens the connection to the database using the URL from config.py.
2️⃣ Create Session FactoryCreates isolated database sessions per API request.
3️⃣ Provide Dependencyget_db() injects a session into any route that needs database access.

Step 4 β€” Setting Up the Session

We need to define ORM model for our todo table, we can have multiple pydantic model of todo but only one ORM model for each table. In app/db/models/todo.py file let define model for todo.

# app/db/models/todo.py (SQLAlchemy)
from sqlalchemy import Column, Integer, String, Enum as SQLEnum
from app.db.base import Base
from enum import Enum

class TodoStatus(str, Enum):
    PENDING = "Pending"
    COMPLETED = "Completed"
    IN_PROCESS = "In Process"

class Todo(Base):
    __tablename__ = "todos"

    id = Column(Integer, primary_key=True, index=True)
    task = Column(String, index=True)
    status = Column(SQLEnum(TodoStatus), default=TodoStatus.PENDING)

πŸ” Breaking Down Each Component

πŸ”§ (1) The Engine: Database Gateway

engine = create_engine(settings.database_url)
  • Connects to SQLite (or Postgres/MySQL later)
  • Manages the connection pool
  • All ORM operations pass through this engine

Think of it as the bridge between your Python code and the database.

πŸ” (2) SessionLocal: The Session Factory

SessionLocal = sessionmaker(...)

A new session is created for every API request:

  1. db = SessionLocal()
  2. Perform queries: db.query(...)
  3. Close after the request

Sessions provide transactional safetyβ€”each request is isolated and clean.

πŸ”Œ (3) get_db(): Injecting DB Sessions Into Routes

This dependency gives routes a ready-to-use database session:

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

Use it like:

@router.get("/")
def list_todos(db: Session = Depends(get_db)):
    ...

This keeps session handling consistent, safe, and automatic.

πŸ—Ί How Everything Fits Together

config.py
   ↓  (provides database_url)
base.py
   ↓  (Base for all models)
session.py
   ↓  (engine + session + get_db dependency)
models.py
   ↓  (ORM models inherit from Base)
init_db.py
   ↓  (creates tables for dev)
main.py
   ↓  (initializes DB + loads routes)

This structure is clean, modular, and easy to scale as your application grows.


βš™οΈ 4. What We’re Building

We’ll make a REST API to manage a Todo List, with endpoints to:

MethodEndpointDescription
GET/v1/api/todosGet all todos
GET/v1/api/todos/{id}Get one todo
POST/v1/api/todosCreate a new todo
PUT/v1/api/todos/{id}Update an existing todo
DELETE/v1/api/todos/{id}Delete a todo

Application Flow Overview

Before diving in, here’s how requests move through your FastAPI app:

Client β†’ FastAPI route β†’ Dependency (get_db) β†’ SessionLocal β†’ Engine β†’ Connection Pool β†’ Database

Each layer plays a specific role:

  • FastAPI route β†’ Handles HTTP request.
  • Dependency injection (Depends) β†’ Gives a DB session to the route if it is related to database.
  • SessionLocal β†’ Creates a per-request database session.
  • Engine β†’ Talks to PostgreSQL through a connection pool.
  • SQLAlchemy ORM β†’ Converts Python objects into SQL.

🧩 5. Repository Layer β€” Database Operations (Infrastructure Layer)

Now that our database, models, and schemas are ready, it’s time to create the layer responsible for direct database operations. This is known as the Repository Layer.

The repository contains functions that perform CRUD operations using SQLAlchemy ORM. It interacts directly with the database and keeps all database code in one place.

This keeps your application modular and makes it easier to:

  • Swap SQLAlchemy with another ORM
  • Write unit tests
  • Follow Clean Architecture principles
from sqlalchemy.orm import Session
from app.db.models.todo import Todo

def create_todo(db: Session, task: str, status: str):
    db_todo = Todo(task=task, status=status)
    db.add(db_todo)
    db.commit()
    db.refresh(db_todo)
    return db_todo

def get_todos(db: Session):
    return db.query(Todo).all()

def get_todo(db: Session, todo_id: int):
    todo = db.query(Todo).filter(Todo.id == todo_id).first()
    if not todo:
        return None 
    return todo

def update_todo(db: Session, todo_id: int, task: str, status: str):
    todo = db.query(Todo).filter(Todo.id == todo_id).first()

    if not todo:
        return None

    todo.task = task
    todo.status = status
    db.commit()
    db.refresh(todo)
    return todo


def delete_todo(db: Session, todo_id: int):
    todo = db.query(Todo).filter(Todo.id == todo_id).first()
    if not todo:
        return None 
    
    db.delete(todo)
    db.commit()
    return todo

πŸ” Why have a Repository Layer?

  • Keeps SQLAlchemy code out of routes and services
  • Makes the app easier to maintain
  • Centralizes all interactions with the database
  • Helps with testing (mock the repo instead of DB)

This layer doesn’t apply business rulesβ€”it focuses only on CRUD operations.


βš™οΈ 6. Service Layer β€” Orchestrating Logic & Business Rules

In a full Clean Architecture, you would have:
Domain β†’ Application β†’ Infrastructure β†’ Presentation

But in most real-world Python/FastAPI projects, β€œapplication services” and β€œdomain services” are combined into a single Service Layer.

What services do:

  • Call repository functions
  • Apply business rules
  • Orchestrate workflows
  • Provide a clean API for route handlers

This keeps your API routes simple and focused only on HTTP concerns.

from sqlalchemy.orm import Session
from typing import Optional, List

from app.db.models.todo import Todo as TodoORM
from app.schemas.todo import TodoCreate
from app.repository import todo_repo


def create_todo(db: Session, todo: TodoCreate) -> TodoORM:
    return todo_repo.create_todo(db, task=todo.task, status=todo.status)

def get_todos(db: Session) -> List[TodoORM]:
    return todo_repo.get_todos(db)

def get_todo(db: Session, todo_id: int) -> Optional[TodoORM]:
    return todo_repo.get_todo(db, todo_id)

def update_todo(db: Session, todo_id: int, todo: TodoCreate) -> Optional[TodoORM]:
    return todo_repo.update_todo(
        db,
        todo_id=todo_id,
        task=todo.task,
        status=todo.status,
    )

def delete_todo(db: Session, todo_id: int) -> Optional[TodoORM]:
    return todo_repo.delete_todo(db, todo_id)

βœ” Why use a Service Layer?

  • Keeps your routes lightweight
  • Adds a place for future business rules
  • Makes it easier to add features like:
    • validations
    • notifications
    • logging
    • external API calls
  • Encourages separation of concerns

Your route functions now look clean, simple, and focused:

def create_todo(todo: TodoCreate, db: Session = Depends(get_db)):
return todo_service.create_todo(db, todo)

🧱 7. Setting Up FastAPI Application (main.py)

The main.py file is the entry point for your FastAPI app. It does several important jobs:

  • Create the FastAPI application instance
  • Configure middleware (CORS)
  • Handle startup/shutdown events
  • Connect routers
  • Start the application

The  main function in  main.py is in root folder is often used to initialize and run the FastAPI application. It typically follows this structure:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager

from app.db.init_db import init_db
from app.api.router import api_router

@asynccontextmanager
async def lifespan(app: FastAPI):
    init_db()  # runs once on startup
    yield      # shutdown happens after this

app = FastAPI(lifespan=lifespan, title='Todo list Rest API')

origins = ["http://localhost", "http://localhost:3000"]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

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

πŸ” Explanation of important parts:

1. lifespan: FastAPI’s modern way of running startup/shutdown tasks (replaces @app.on_event("startup")).

  • Runs init_db() only once when server starts
  • Ensures all tables exist in SQLite (dev only)

2. CORS: Allows your backend to communicate with your frontend during development.

3. app.include_router(api_router): Brings all routes from:

app/api/v1/

into your API under:

/api/v1

✳️ What happens here:

  • FastAPI() creates the web app instance.
  • models.Base.metadata.create_all() scans your ORM models and ensures tables exist.
  • When you later start your API, FastAPI automatically generates interactive docs at:
    • /docs β†’ Swagger UI
    • /redoc β†’ ReDoc (alternative UI)
  • Adding CORS middleware: CORS (Cross-Origin Resource Sharing) lets your frontend (e.g., React) talk to your backend API. βœ… This ensures browsers don’t block requests between different ports.

More on def get_db() in details

This is important to know β€” I was initially confused, thinking it was called globally and only once. But that’s not the case. The get_db() function is called each time an API endpoint includes it as a dependency. Whenever an endpoint interacts with the database, we must inject it using Depends(get_db). We’ll explore this in more detail later.

✳️ Dependency Injection (get_db)

Every request needs a database session β€” but it must be created and closed safely. How it works ?

  1. FastAPI calls get_db() before your endpoint executes.
  2. The yield keyword passes a session to your route.
  3. When the route finishes, the session is automatically closed.
  4. Each request β†’ gets a new session.

🟒 1️⃣ A new API request comes in

Note: πŸ‘‰ get_db() is executed once per API request that depends on it β€” not once globally. Every incoming request that includes
db: Session = Depends(get_db) will trigger a new call to get_db().

Here’s what happens step by step during each API request πŸ‘‡

@app.get("/todos")
def get_todos(db: Session = Depends(get_db)):
    return db.query(models.Todo).all()

When this route is called, FastAPI looks at the parameters and sees: β€œThis function depends on get_db().” So before running get_todos(), FastAPI calls get_db().

🟒 2️⃣ SessionLocal() creates a new session

Inside get_db(), this line runs:

db = database.SessionLocal()

That creates a brand-new SQLAlchemy Session object. This session borrows a connection from the global connection pool.

βœ… It’s unique to this request only.
βœ… It’s isolated (so two users can’t interfere with each other’s transactions).

🟒 3️⃣ The session is yielded to the route

This line: yield db hands that db session to your route handler:

def get_todos(db: Session = Depends(get_db)):

Now your endpoint can safely use that session for database operations.

🟒 4️⃣ After the route finishes, the session is closed

When the request ends (successfully or with an error), FastAPI automatically runs the finally: block:

finally:
    db.close()

This returns the connection back to the connection pool so it can be reused for another request.

βœ… It ensures no leaks (connections don’t stay open).
βœ… It keeps the pool healthy.

EventWhen it HappensPurpose
db = SessionLocal()Start of each API requestCreate a new session
yield dbDuring request executionPass session to route
db.close()After request finishesReturn connection to pool

🧱 In Plain Words: Each API request that needs database access creates its own short-lived session using get_db().

  • πŸ’‘ The engine and pool are global (created once at startup).
  • πŸ” The session is per-request (created and closed for each API call).
  • 🧹 The finally ensures cleanup β€” even if an exception happens.

Additional info

πŸ” Understanding Parameters in FastAPI

1️⃣ Path Parameters

Used directly in the URL. Example:

@app.get("/todos/{todo_id}")
def get_todo(todo_id: int):
    return {"id": todo_id}

πŸ”Ή Must always be part of the path
πŸ”Ή FastAPI automatically validates type (int, str, etc.)

2️⃣ Query Parameters

Sent after a question mark in the URL. Used for filtering or optional parameters.

Example:

@app.get("/search/")
def search_todos(keyword: str | None = None, limit: int = 10):
    return {"keyword": keyword, "limit": limit}

Request:

GET /search/?keyword=project&limit=5

Response:

{"keyword": "project", "limit": 5}

🧠 Notes:

  • Query params are optional if a default value is provided (= None or = 10).
  • You can mix path and query parameters.

πŸŽ‰ Summary

You now have a clean and scalable architecture:

API β†’ Service β†’ Repository β†’ Database (SQLAlchemy)

Each layer has a clear purpose:

LayerResponsibility
RoutesHandle HTTP requests & responses
ServicesBusiness logic, orchestration
RepositoryDirect DB operations
Database LayerModels, sessions, engine
SchemasValidate + structure API data

This is the same structure used in many production FastAPI apps because it is:

βœ” maintainable
βœ” testable
βœ” scalable
βœ” clean

Scroll to Top