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.
- This makes our app flexible:
- Docker or deployment systems can inject environment variables automatically.
- You can switch from local to production DB easily.
- 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
BaseSettingsclass β used for managing configuration and environment variables. - Purpose:
pydantic-settingsautomatically reads values from: 1. Environment variables (export DATABASE_URL=...) Or a.envfile 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.
| Concept | Explanation |
|---|---|
pydantic_settings | Library that loads config from environment or .env file |
BaseSettings | Base class that makes our settings automatic and type-safe |
Settings class | Our custom config model β defines all environment variables |
Config.env_file | Optional .env file to load local development settings |
settings instance | Global 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
| Schema | Purpose |
|---|---|
| TodoBase | Shared fields for todos (task, status) |
| TodoCreate | Used when creating a new todo. Inherits everything from TodoBase. |
| Todo | Used 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
| Responsibility | Description |
|---|---|
| 1οΈβ£ Create Engine | Opens the connection to the database using the URL from config.py. |
| 2οΈβ£ Create Session Factory | Creates isolated database sessions per API request. |
| 3οΈβ£ Provide Dependency | get_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:
db = SessionLocal()- Perform queries:
db.query(...) - 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:
| Method | Endpoint | Description |
|---|---|---|
GET | /v1/api/todos | Get all todos |
GET | /v1/api/todos/{id} | Get one todo |
POST | /v1/api/todos | Create 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 ?
- FastAPI calls
get_db()before your endpoint executes. - The
yieldkeyword passes a session to your route. - When the route finishes, the session is automatically closed.
- 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 includesdb: 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.
| Event | When it Happens | Purpose |
|---|
db = SessionLocal() | Start of each API request | Create a new session |
yield db | During request execution | Pass session to route |
db.close() | After request finishes | Return 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
finallyensures 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 (
= Noneor= 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:
| Layer | Responsibility |
|---|---|
| Routes | Handle HTTP requests & responses |
| Services | Business logic, orchestration |
| Repository | Direct DB operations |
| Database Layer | Models, sessions, engine |
| Schemas | Validate + structure API data |
This is the same structure used in many production FastAPI apps because it is:
β maintainable
β testable
β scalable
β clean