Skip to content
Build a REST API with FastAPI (Step by Step)

Build a REST API with FastAPI (Step by Step)

DodaTech Updated Jun 19, 2026 7 min read

Build a production-ready REST API with FastAPI featuring CRUD operations, Pydantic validation, SQLite database integration, and auto-generated Swagger documentation.

What You’ll Build

You’ll build a RESTful API for a book library — create, read, update, and delete books with validation, search filtering, and automatic interactive documentation. This same pattern powers the backend services at DodaTech — for example, DodaZIP’s file conversion API and Durga Antivirus Pro’s signature update endpoints follow the same architecture.

Why REST APIs Matter

Every modern application communicates through APIs. When your phone checks the weather, when a website loads your shopping cart, when a CI/CD pipeline triggers a deployment — all of these use REST APIs. FastAPI is one of the fastest Python frameworks for building them, with automatic OpenAPI documentation, type validation, and async support out of the box.

Prerequisites

  • Python 3.9+ installed
  • Basic understanding of HTTP methods (GET, POST, PUT, DELETE)
  • Familiarity with JSON format

Step 1: Setup

mkdir fastapi-library
cd fastapi-library
python -m venv venv
source venv/bin/activate
pip install fastapi uvicorn sqlalchemy pydantic

Step 2: Define the Data Model

First, let’s define what a book looks like in our system:

# models.py
from pydantic import BaseModel
from typing import Optional
from datetime import datetime

class BookBase(BaseModel):
    title: str
    author: str
    year: int
    isbn: str
    genre: str

class BookCreate(BookBase):
    pass

class BookUpdate(BaseModel):
    title: Optional[str] = None
    author: Optional[str] = None
    year: Optional[int] = None
    isbn: Optional[str] = None
    genre: Optional[str] = None

class Book(BookBase):
    id: int
    created_at: datetime
    available: bool = True

    class Config:
        from_attributes = True

Notice how BookCreate inherits from BookBase — we reuse the fields but keep them separate so creation and updates can have different validation rules. BookUpdate makes every field optional because a PATCH request might only change the title.

Step 3: Database Setup with SQLAlchemy

# database.py
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime
from sqlalchemy.orm import declarative_base, sessionmaker
from datetime import datetime

SQLALCHEMY_DATABASE_URL = "sqlite:///./library.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

class BookDB(Base):
    __tablename__ = "books"

    id = Column(Integer, primary_key=True, index=True)
    title = Column(String, index=True)
    author = Column(String, index=True)
    year = Column(Integer)
    isbn = Column(String, unique=True, index=True)
    genre = Column(String)
    available = Column(Boolean, default=True)
    created_at = Column(DateTime, default=datetime.utcnow)

Base.metadata.create_all(bind=engine)

SQLAlchemy maps Python classes to database tables. BookDB is the database model — each attribute is a column. The Pydantic Book model (from models.py) is what we send as JSON responses. This separation keeps the database logic and API logic independent.

Step 4: CRUD Operations

# crud.py
from sqlalchemy.orm import Session
from models import BookCreate, BookUpdate
from database import BookDB

def get_books(db: Session, skip: int = 0, limit: int = 100, author: str = None, genre: str = None):
    query = db.query(BookDB)
    if author:
        query = query.filter(BookDB.author.ilike(f"%{author}%"))
    if genre:
        query = query.filter(BookDB.genre.ilike(f"%{genre}%"))
    return query.offset(skip).limit(limit).all()

def get_book(db: Session, book_id: int):
    return db.query(BookDB).filter(BookDB.id == book_id).first()

def create_book(db: Session, book: BookCreate):
    db_book = BookDB(**book.model_dump())
    db.add(db_book)
    db.commit()
    db.refresh(db_book)
    return db_book

def update_book(db: Session, book_id: int, book: BookUpdate):
    db_book = db.query(BookDB).filter(BookDB.id == book_id).first()
    if not db_book:
        return None
    update_data = book.model_dump(exclude_unset=True)
    for key, value in update_data.items():
        setattr(db_book, key, value)
    db.commit()
    db.refresh(db_book)
    return db_book

def delete_book(db: Session, book_id: int):
    db_book = db.query(BookDB).filter(BookDB.id == book_id).first()
    if not db_book:
        return None
    db.delete(db_book)
    db.commit()
    return db_book

Step 5: API Endpoints

# main.py
from fastapi import FastAPI, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from database import SessionLocal
from models import Book, BookCreate, BookUpdate
from crud import get_books, get_book, create_book, update_book, delete_book

app = FastAPI(
    title="Library API",
    description="A RESTful API for managing books in a library",
    version="1.0.0"
)

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

@app.get("/", tags=["Root"])
async def root():
    return {"message": "Library API — see /docs for interactive documentation"}

@app.get("/api/books", response_model=List[Book], tags=["Books"])
def list_books(
    skip: int = Query(0, ge=0),
    limit: int = Query(100, ge=1, le=500),
    author: Optional[str] = None,
    genre: Optional[str] = None,
    db: Session = Depends(get_db)
):
    return get_books(db, skip=skip, limit=limit, author=author, genre=genre)

@app.get("/api/books/{book_id}", response_model=Book, tags=["Books"])
def read_book(book_id: int, db: Session = Depends(get_db)):
    book = get_book(db, book_id)
    if not book:
        raise HTTPException(status_code=404, detail="Book not found")
    return book

@app.post("/api/books", response_model=Book, status_code=201, tags=["Books"])
def create_new_book(book: BookCreate, db: Session = Depends(get_db)):
    return create_book(db, book)

@app.put("/api/books/{book_id}", response_model=Book, tags=["Books"])
def update_existing_book(book_id: int, book: BookUpdate, db: Session = Depends(get_db)):
    updated = update_book(db, book_id, book)
    if not updated:
        raise HTTPException(status_code=404, detail="Book not found")
    return updated

@app.delete("/api/books/{book_id}", tags=["Books"])
def delete_existing_book(book_id: int, db: Session = Depends(get_db)):
    deleted = delete_book(db, book_id)
    if not deleted:
        raise HTTPException(status_code=404, detail="Book not found")
    return {"message": "Book deleted successfully"}

Notice how each endpoint has a specific HTTP method that matches its purpose — GET to read, POST to create, PUT to update, DELETE to remove. The response_model=Book ensures FastAPI validates and filters the output to match the schema.

Step 6: Run and Test

uvicorn main:app --reload --port 8000

Open http://localhost:8000/docs — you’ll see the auto-generated Swagger UI. Try creating a book:

curl -X POST http://localhost:8000/api/books \
  -H "Content-Type: application/json" \
  -d '{"title":"1984","author":"George Orwell","year":1949,"isbn":"978-0451524935","genre":"Dystopian"}'

Expected response:

{
  "id": 1,
  "title": "1984",
  "author": "George Orwell",
  "year": 1949,
  "isbn": "978-0451524935",
  "genre": "Dystopian",
  "created_at": "2026-06-19T12:00:00",
  "available": true
}

List all books:

curl http://localhost:8000/api/books

Search by author:

curl "http://localhost:8000/api/books?author=orwell"

Architecture


sequenceDiagram
    participant Client as Browser / curl
    participant API as FastAPI Server
    participant DB as SQLite Database

    Client->>API: GET /api/books
    API->>DB: SELECT * FROM books
    DB-->>API: rows
    API-->>Client: JSON array

    Client->>API: POST /api/books (JSON body)
    API->>API: Validate with Pydantic
    API->>DB: INSERT INTO books
    DB-->>API: new row
    API-->>Client: 201 Created + JSON

    Client->>API: PUT /api/books/1 (JSON body)
    API->>DB: UPDATE books SET ...
    DB-->>API: updated row
    API-->>Client: JSON

    Client->>API: DELETE /api/books/1
    API->>DB: DELETE FROM books
    DB-->>API: success
    API-->>Client: 200 + message

Common Errors

1. “Table ‘books’ already exists” If you restart with Base.metadata.create_all() it won’t error — it skips existing tables. But if you change a column definition, SQLite won’t alter it. Delete the library.db file to recreate the schema from scratch.

2. 422 Validation Error FastAPI returns this when the request body doesn’t match the Pydantic model. Common causes: sending a string instead of an integer for year, misspelling field names, or sending extra fields not in the model. Check the error details — FastAPI tells you exactly which field failed and why.

3. 405 Method Not Allowed You’re using the wrong HTTP method. Common mistake: sending a POST request to an endpoint that only accepts GET, or vice versa. Check the decorator in main.py.

4. SQLite “database is locked” error SQLite doesn’t handle concurrent writes well. For production, switch to PostgreSQL. For development, ensure you’re not opening multiple connections simultaneously without proper session management.

Practice Questions

1. What’s the difference between a 201 and 200 status code? 201 means “Created” — used when a new resource is created (POST). 200 means “OK” — used for successful reads, updates, and deletes. Always return 201 for creation endpoints.

2. Why do we use Depends(get_db)? FastAPI’s dependency injection system creates a new database session for each request and closes it when the request ends. This prevents connection leaks and makes testing easier — you can override the dependency with a test database.

3. What does exclude_unset=True do in model_dump()? It tells Pydantic to only include fields that were explicitly sent in the request. This prevents accidentally setting fields to None when the user only wants to update one field.

4. Challenge: Add a borrow/return system Create a POST /api/books/{id}/borrow endpoint that sets available=False, and POST /api/books/{id}/return that sets it back. Return 400 if the book is already borrowed when someone tries to borrow it.

5. Challenge: Pagination metadata Instead of just returning the book list, return {"total": 100, "page": 1, "per_page": 10, "data": [...]}. Create a PaginatedResponse model and modify get_books() to return the count.

FAQ

How do I add authentication?
FastAPI supports OAuth2 with JWT tokens. Install python-jose and passlib, create login and signup endpoints, and use Depends() to protect routes. The FastAPI docs have a complete OAuth2 example.
Can I use async with SQLAlchemy?
Yes. Install asyncpg for PostgreSQL or aiosqlite for SQLite, create an async engine, and use async with Session(engine) as session. FastAPI async routes (async def) will then run concurrently without blocking.
How do I test my API?
Use FastAPI’s TestClient from httpx. Create a test database (or use SQLite in-memory), override the get_db dependency, and call endpoints directly from Python tests. This is faster than curl-based testing.

Next Steps

  • Add user authentication with JWT tokens
  • Containerize with Docker
  • Explore the PostgreSQL tutorial for production database setup
  • Check the Express.js tutorial for the Node.js equivalent of this API

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro