Back to Blog
Building a REST API with FastAPI and SQLAlchemy — A Practical Guide for Python Developers

Building a REST API with FastAPI and SQLAlchemy — A Practical Guide for Python Developers

August 16, 202560 viewsBuilding a REST API with FastAPI and SQLAlchemy

Learn how to build a production-ready REST API using **FastAPI** and **SQLAlchemy**. This hands-on guide walks you through core concepts, a complete example project (models, schemas, CRUD endpoints), deployment tips, CLI automation, data seeding via web scraping, and how this fits into microservice architectures with Docker.

Introduction

Why choose FastAPI + SQLAlchemy to build a REST API? FastAPI offers modern, asynchronous-friendly request handling, automatic interactive docs, and excellent developer ergonomics. SQLAlchemy is the battle-tested ORM for Python, giving you flexible schema mapping, query building, and database-agnostic patterns.

In this guide you'll learn:

  • Key concepts and prerequisites for building REST APIs with FastAPI and SQLAlchemy.
  • A step-by-step example implementing CRUD for a Book resource.
  • How to automate data entry (optionally via web scraping) and provide a CLI to seed data.
  • Deployment considerations and how this stacks within microservices and Docker.
  • Best practices, common pitfalls, and advanced tips.
Prerequisites: Python 3.8+, basic knowledge of Python, pip, and SQL. Familiarity with HTTP/REST helps but isn't required.

Prerequisites and Project Plan

Before coding, install required packages:

python -m venv .venv
source .venv/bin/activate  # Linux/macOS
.venv\Scripts\activate     # Windows
pip install fastapi uvicorn sqlalchemy alembic pydantic[dotenv] requests beautifulsoup4 click

Explanation:

  • fastapi: The web framework.
  • uvicorn: ASGI server to run FastAPI.
  • sqlalchemy: ORM for DB models and queries.
  • alembic: Optional — DB migrations (recommended for production).
  • pydantic: Model validation (FastAPI uses it heavily).
  • requests, beautifulsoup4: For optional web scraping to seed data.
  • click: Build command-line utilities (see CLI section).
Project structure (simple):

project/
├─ app/
│  ├─ main.py
│  ├─ models.py
│  ├─ schemas.py
│  ├─ database.py
│  ├─ crud.py
│  └─ cli.py
├─ requirements.txt
└─ Dockerfile

Core Concepts

  • FastAPI: A modern web framework that uses Python type hints to validate and serialize data with Pydantic.
  • SQLAlchemy ORM: Map Python classes to database tables; use sessions for transactional access.
  • Pydantic Schemas: Convert and validate request/response payloads.
  • Dependency Injection: FastAPI's dependency system is used to provide DB sessions to routes.
  • Asynchronous vs Synchronous: FastAPI supports async handlers. Using SQLAlchemy’s traditional ORM is synchronous; mixing async DB drivers and sync ORM requires caution.
Diagram (textual):
  • Client → FastAPI route → Dependency gives DB session → CRUD functions (SQLAlchemy) → DB → Response (Pydantic schema)

Step-by-Step Example: Building a Book API

We'll implement a simple Book resource with fields: id, title, author, published_year, description.

database.py — Engine and Session

# app/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base

SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"

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

Line-by-line:

  • import statements bring in SQLAlchemy facilities.
  • SQLALCHEMY_DATABASE_URL: SQLite file-based DB for development. Change to "postgresql://user:pass@host/db" for production.
  • create_engine(...): Creates the DB engine. For SQLite, check_same_thread=False allows multi-threaded access from uvicorn.
  • SessionLocal: Factory for DB sessions. autocommit/autoflush disabled to be explicit.
  • Base: Declarative base class used by ORM models.
Edge cases: For production using Postgres, drop connect_args. Use environment variables for credentials.

models.py — SQLAlchemy ORM models

# app/models.py
from sqlalchemy import Column, Integer, String, Text
from .database import Base

class Book(Base): __tablename__ = "books"

id = Column(Integer, primary_key=True, index=True) title = Column(String(200), nullable=False, index=True) author = Column(String(100), nullable=False, index=True) published_year = Column(Integer, nullable=True) description = Column(Text, nullable=True)

Line-by-line:

  • Column, Integer, etc.: SQLAlchemy column types.
  • Book class inherits from Base and maps to "books" table.
  • id: Primary key, indexed for faster lookups.
  • title/author: Strings, non-nullable, indexed for search.
  • published_year/description: Optional fields.
Edge cases: Consider length limits for String. Use Text for large descriptions.

schemas.py — Pydantic models (request/response)

# app/schemas.py
from pydantic import BaseModel
from typing import Optional

class BookBase(BaseModel): title: str author: str published_year: Optional[int] = None description: Optional[str] = None

class BookCreate(BookBase): pass

class BookUpdate(BaseModel): title: Optional[str] = None author: Optional[str] = None published_year: Optional[int] = None description: Optional[str] = None

class BookOut(BookBase): id: int

class Config: orm_mode = True

Line-by-line:

  • BookBase: Shared fields for create & output.
  • BookCreate: For POST payloads (same as BookBase).
  • BookUpdate: All fields optional for PATCH/PUT operations.
  • BookOut: Response model includes id. orm_mode = True lets Pydantic read SQLAlchemy objects directly.
Edge cases: Always validate input lengths and add custom validators if needed (e.g., published_year ranges).

crud.py — Database operations

# app/crud.py
from sqlalchemy.orm import Session
from . import models, schemas
from typing import List, Optional

def get_book(db: Session, book_id: int) -> Optional[models.Book]: return db.query(models.Book).filter(models.Book.id == book_id).first()

def get_books(db: Session, skip: int = 0, limit: int = 100) -> List[models.Book]: return db.query(models.Book).offset(skip).limit(limit).all()

def create_book(db: Session, book: schemas.BookCreate) -> models.Book: db_book = models.Book(book.dict()) db.add(db_book) db.commit() db.refresh(db_book) return db_book

def update_book(db: Session, book_id: int, changes: schemas.BookUpdate) -> Optional[models.Book]: db_book = get_book(db, book_id) if not db_book: return None for field, value in changes.dict(exclude_unset=True).items(): setattr(db_book, field, value) db.commit() db.refresh(db_book) return db_book

def delete_book(db: Session, book_id: int) -> bool: db_book = get_book(db, book_id) if not db_book: return False db.delete(db_book) db.commit() return True

Line-by-line:

  • get_book: Fetch by primary key, returns None if not found.
  • get_books: Simple pagination with skip & limit.
  • create_book: Construct a Book instance from Pydantic model, add, commit, and refresh to get DB-generated fields.
  • update_book: Use exclude_unset to only change provided fields.
  • delete_book: Delete if exists, return boolean for success.
Edge cases: Consider transactions or try/except for DB errors. For bulk operations, optimize with bulk methods.

main.py — FastAPI app and routes

# app/main.py
from fastapi import FastAPI, Depends, HTTPException, status
from sqlalchemy.orm import Session
from . import models, schemas, crud
from .database import SessionLocal, engine, Base

Create DB tables (for demo). In production use Alembic migrations.

Base.metadata.create_all(bind=engine)

app = FastAPI(title="Books API")

Dependency to get DB session

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

@app.post("/books/", response_model=schemas.BookOut, status_code=status.HTTP_201_CREATED) def create_book_endpoint(book: schemas.BookCreate, db: Session = Depends(get_db)): return crud.create_book(db, book)

@app.get("/books/", response_model=list[schemas.BookOut]) def read_books(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): return crud.get_books(db, skip=skip, limit=limit)

@app.get("/books/{book_id}", response_model=schemas.BookOut) def read_book(book_id: int, db: Session = Depends(get_db)): db_book = crud.get_book(db, book_id) if not db_book: raise HTTPException(status_code=404, detail="Book not found") return db_book

@app.patch("/books/{book_id}", response_model=schemas.BookOut) def update_book_endpoint(book_id: int, changes: schemas.BookUpdate, db: Session = Depends(get_db)): updated = crud.update_book(db, book_id, changes) if not updated: raise HTTPException(status_code=404, detail="Book not found") return updated

@app.delete("/books/{book_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_book_endpoint(book_id: int, db: Session = Depends(get_db)): success = crud.delete_book(db, book_id) if not success: raise HTTPException(status_code=404, detail="Book not found") return

Line-by-line highlights:

  • Base.metadata.create_all: Creates tables automatically (OK for development).
  • get_db dependency: yields a session and always closes it (very important to avoid connection leaks).
  • Each route uses dependency injection to get DB sessions, returns Pydantic models, and raises HTTP errors appropriately.
Inputs/outputs:
  • POST /books/ accepts BookCreate JSON; returns created BookOut with id.
  • GET /books/ returns paginated list.
  • GET/ PATCH / DELETE by id with proper error handling.
Edge cases: For concurrent workloads, prefer a production DB and Alembic migrations. Validate content sizes, handle DB exceptions with custom exception handlers.

Automating Data Entry: Web Scraping to Seed Data

Want to bulk-add books automatically? You can write a small scraper and a CLI command to seed your DB. This ties into "Automating Data Entry with Python: A Hands-On Guide to Web Scraping".

Example scraper (very simple, hypothetical site):

# app/seed_scraper.py
import requests
from bs4 import BeautifulSoup
from typing import List, Dict

def scrape_books_from_page(url: str) -> List[Dict]: resp = requests.get(url, timeout=10) resp.raise_for_status() soup = BeautifulSoup(resp.text, "html.parser") books = [] for item in soup.select(".book"): title = item.select_one(".title").get_text(strip=True) author = item.select_one(".author").get_text(strip=True) year_txt = item.select_one(".year").get_text(strip=True) try: year = int(year_txt) except ValueError: year = None books.append({"title": title, "author": author, "published_year": year}) return books

Line-by-line:

  • requests.get: fetch page with timeout.
  • resp.raise_for_status: raise HTTPError for non-2xx responses.
  • BeautifulSoup: parse HTML.
  • CSS selectors to extract book data.
  • Cast year to int when possible.
Edge cases: Websites with anti-scraping measures, legal/ethical concerns, dynamic content (rendered by JavaScript). Use APIs when available.

CLI to seed DB with Click

Integrate scraping into a command-line tool using Click — related to "Creating a Command-Line Interface with Click: Best Practices and Tips".

# app/cli.py
import click
from .database import SessionLocal, engine, Base
from .crud import create_book
from .seed_scraper import scrape_books_from_page

@click.group() def cli(): pass

@cli.command() @click.argument("url") def seed(url): """Seed the DB with books scraped from URL.""" books = scrape_books_from_page(url) db = SessionLocal() try: for b in books: create_book(db, b) # create_book accepts Pydantic-like dict click.echo(f"Seeded {len(books)} books") finally: db.close()

@cli.command() def initdb(): Base.metadata.create_all(bind=engine) click.echo("Initialized the database")

Line-by-line:

  • click.group: CLI group entrypoint.
  • seed command: accepts a URL, scrapes books, and uses CRUD to insert them.
  • initdb: convenience command to create tables.
Edge cases: Avoid seeding duplicate entries (add dedup logic) and handle timeouts or partial failures with retries/transactions.

Run CLI:

python -m app.cli seed "https://example.com/books"
python -m app.cli initdb

Deploying and Microservices Context

If you're building a microservice, you might containerize your FastAPI app. This guide touches how this differs from "Building Microservices with Flask and Docker: A Practical Approach". While Flask is a great microservice framework, FastAPI is often preferred for async performance and automatic docs.

A simple Dockerfile:

# Dockerfile
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ./app /app/app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80", "--workers", "1"]

Notes:

  • For high concurrency, tune workers and use Gunicorn + Uvicorn workers.
  • Use environment variables for DB URL and secrets instead of hardcoding.
  • In microservices, separate responsibilities: one service for books, another for authors, etc.
Compose snippet for PostgreSQL:

version: '3.8'
services:
  api:
    build: .
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/appdb
    depends_on:
      - db
    ports:
      - "8000:80"
  db:
    image: postgres:14
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:

Best Practices

  • Use Alembic for schema migrations — never rely solely on create_all in production.
  • Use environment variables and a 12-factor approach for config.
  • Keep business logic out of routes; put it in services or CRUD modules.
  • Validate inputs with Pydantic and set limits for pagination.
  • Use connection pooling and tune pool_size when using SQL databases.
  • Implement proper logging and structured error handling.
  • Add tests: unit tests for CRUD functions and integration tests for endpoints.

Common Pitfalls

  • Connection leaks: Always close sessions or use context managers. The get_db dependency pattern avoids leaks.
  • Mixing async route functions with sync DB calls: If you mark a route async def and perform blocking DB calls, you risk blocking the event loop. Use synchronous routes or offload DB operations to threadpool, or use async DB drivers.
  • Not handling DB integrity errors: Wrap commits in try/except to translate DB errors to HTTP responses.
  • Blindly crawling websites: When scraping to seed data, respect robots.txt and terms of service.

Advanced Tips

  • Async SQLAlchemy (1.4+): If you need full async DB access, explore SQLAlchemy's async ORM and async drivers (asyncpg for Postgres). Requires different Session and engine patterns.
  • Pagination strategies: Cursor-based pagination scales better than offset-based for large tables.
  • Caching: Add response caching (Redis) for read-heavy endpoints.
  • Background tasks: Use FastAPI's BackgroundTasks or Celery for long-running imports or expensive scraping jobs.
  • Authentication & Authorization: Add OAuth2 with JWT tokens or integrate with API gateways.
  • Observability: Add OpenTelemetry, structured logs, and metrics.

Error Handling Example

Catch DB integrity errors and return friendly messages:

from fastapi import Request
from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError

@app.exception_handler(IntegrityError) def db_integrity_error_handler(request: Request, exc: IntegrityError): return JSONResponse( status_code=400, content={"detail": "Database integrity error", "error": str(exc.orig)} )

This returns a 400 with a concise message. In production, avoid leaking DB internals in error messages.

Testing

  • Unit test CRUD functions using a SQLite in-memory DB: sqlite:///:memory: and create tables in setup.
  • Use FastAPI's TestClient (starlette.testclient) to test routes.

Conclusion

You've seen how to build a clean REST API with FastAPI and SQLAlchemy**, including Pydantic schemas, DB sessions, CRUD operations, and practical concerns like seeding data and CLI automation. This stack gives you a solid foundation for building scalable services and fits well into microservice architectures using Docker — similar to patterns used with Flask microservices.

Call to action: Try the example code locally — run uvicorn app.main:app --reload, explore /docs, and extend the API (authentication, pagination, async DB). If you enjoyed this, explore the referenced topics:

  • Automating Data Entry with Python: A Hands-On Guide to Web Scraping (for seeding).
  • Creating a Command-Line Interface with Click: Best Practices and Tips (for operational tooling).
  • Building Microservices with Flask and Docker: A Practical Approach (for alternative microservice patterns and Docker deployments).

Further Reading and References

Happy coding — fork the sample, run the scraper, and build something useful!

Related Posts

Implementing Functional Programming Techniques in Python: Map, Filter, and Reduce Explained

Dive into Python's functional programming tools — **map**, **filter**, and **reduce** — with clear explanations, real-world examples, and best practices. Learn when to choose these tools vs. list comprehensions, how to use them with dataclasses and type hints, and how to handle errors cleanly using custom exceptions.

Creating a Python CLI Tool: Best Practices for User Input and Output Handling

Command-line tools remain essential for automation, ETL tasks, and developer workflows. This guide walks intermediate Python developers through building robust CLI tools with practical examples, covering input parsing, I/O patterns, error handling, logging, packaging, and Docker deployment. Learn best practices and real-world patterns to make your CLI reliable, user-friendly, and production-ready.

Mastering Retry Logic in Python: Best Practices for Robust API Calls

Ever wondered why your Python scripts fail miserably during flaky network conditions? In this comprehensive guide, you'll learn how to implement resilient retry logic for API calls, ensuring your applications stay robust and reliable. Packed with practical code examples, best practices, and tips on integrating with virtual environments and advanced formatting, this post will elevate your Python skills to handle real-world challenges effortlessly.