REST APIs power nearly every modern application — from mobile apps and single-page frontends to the wave of AI agents that now consume APIs autonomously. This guide walks you through building a fully functional REST API with Python and FastAPI, then testing it properly. No fluff, just working code you can ship.
REST (Representational State Transfer) is an architectural style for building web services. A REST API exposes resources — things like users, products, or orders — at predictable URLs and lets clients interact with them using standard HTTP methods.
What makes an API "RESTful" in practice:
/users/42), not verbs (/getUser?id=42).Every REST API operation maps to an HTTP method. Here's how they correspond to CRUD operations:
| Method | CRUD Operation | Example | Idempotent |
|---|---|---|---|
| GET | Read | GET /books |
Yes |
| POST | Create | POST /books |
No |
| PUT | Update | PUT /books/1 |
Yes |
| DELETE | Delete | DELETE /books/1 |
Yes |
Idempotent means calling the same request multiple times produces the same result. GET, PUT, and DELETE are idempotent; POST is not — sending the same POST twice creates two resources.
Flask dominated Python API development for a decade, but FastAPI has become the clear default for new projects. The reasons are practical:
async/await support, which matters when your API talks to databases or other services.We'll build a book catalog API with full CRUD operations. Start by installing FastAPI and Uvicorn:
pip install fastapi uvicorn
book-api/
├── main.py # API routes and app setup
├── models.py # Pydantic data models
└── requirements.txt # Dependencies
Pydantic models define the shape of your data. They handle validation automatically — if a client sends an invalid request body, FastAPI returns a clear 422 error before your code ever runs.
# models.py
from pydantic import BaseModel, Field
from typing import Optional
class BookCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
author: str = Field(..., min_length=1, max_length=100)
year: int = Field(..., ge=1000, le=2030)
isbn: Optional[str] = Field(None, pattern=r"^\d{10}(\d{3})?$")
class Book(BookCreate):
id: int
class BookUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=200)
author: Optional[str] = Field(None, min_length=1, max_length=100)
year: Optional[int] = Field(None, ge=1000, le=2030)
isbn field uses a regex pattern to enforce either 10 or 13-digit ISBN formats. Need to build more complex validation patterns? Generate them instantly with our free Regex Generator — describe what you want to match in plain English and get the pattern back.
# main.py
from fastapi import FastAPI, HTTPException
from models import Book, BookCreate, BookUpdate
from typing import List
app = FastAPI(
title="Book Catalog API",
description="A simple REST API for managing books",
version="1.0.0"
)
# In-memory storage (use a database in production)
books_db: dict[int, Book] = {}
next_id = 1
@app.get("/books", response_model=List[Book])
def list_books(author: str = None, year: int = None):
"""List all books, with optional author/year filters."""
results = list(books_db.values())
if author:
results = [b for b in results if author.lower() in b.author.lower()]
if year:
results = [b for b in results if b.year == year]
return results
@app.get("/books/{book_id}", response_model=Book)
def get_book(book_id: int):
"""Get a single book by ID."""
if book_id not in books_db:
raise HTTPException(status_code=404, detail="Book not found")
return books_db[book_id]
@app.post("/books", response_model=Book, status_code=201)
def create_book(book: BookCreate):
"""Create a new book entry."""
global next_id
new_book = Book(id=next_id, **book.model_dump())
books_db[next_id] = new_book
next_id += 1
return new_book
@app.put("/books/{book_id}", response_model=Book)
def update_book(book_id: int, updates: BookUpdate):
"""Update an existing book (partial updates supported)."""
if book_id not in books_db:
raise HTTPException(status_code=404, detail="Book not found")
existing = books_db[book_id]
update_data = updates.model_dump(exclude_unset=True)
updated = existing.model_copy(update=update_data)
books_db[book_id] = updated
return updated
@app.delete("/books/{book_id}", status_code=204)
def delete_book(book_id: int):
"""Delete a book by ID."""
if book_id not in books_db:
raise HTTPException(status_code=404, detail="Book not found")
del books_db[book_id]
Run the server:
uvicorn main:app --reload --port 8000
Your API is now live at http://localhost:8000. Visit /docs for the interactive Swagger UI, or /openapi.json for the raw schema that AI agents can consume.
Building an API is half the work. Testing it properly is the other half — and where most tutorials cut corners. Here are the three layers of testing you should use.
Before writing automated tests, verify each endpoint works. You can do this instantly in a browser with our free API Tester — enter your local URL, pick the HTTP method, and send. No signup, no install.
Or use curl from the terminal:
# Create a book
curl -X POST http://localhost:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "Dune", "author": "Frank Herbert", "year": 1965}'
# List all books
curl http://localhost:8000/books
# Get a specific book
curl http://localhost:8000/books/1
# Update a book
curl -X PUT http://localhost:8000/books/1 \
-H "Content-Type: application/json" \
-d '{"year": 1965, "title": "Dune (Revised)"}'
# Delete a book
curl -X DELETE http://localhost:8000/books/1
Test your endpoints with our free API Tester — paste a URL, pick a method, hit send. Zero friction.
Open API TesterFastAPI includes a TestClient that lets you write tests without starting a real server. Install the test dependencies:
pip install pytest httpx
Then write your tests:
# test_api.py
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_create_book():
response = client.post("/books", json={
"title": "Neuromancer",
"author": "William Gibson",
"year": 1984
})
assert response.status_code == 201
data = response.json()
assert data["title"] == "Neuromancer"
assert "id" in data
def test_get_nonexistent_book():
response = client.get("/books/9999")
assert response.status_code == 404
def test_create_book_invalid_year():
response = client.post("/books", json={
"title": "Future Book",
"author": "Someone",
"year": 3000 # Exceeds max of 2030
})
assert response.status_code == 422
def test_filter_by_author():
client.post("/books", json={
"title": "Count Zero",
"author": "William Gibson",
"year": 1986
})
response = client.get("/books?author=gibson")
assert response.status_code == 200
books = response.json()
assert all("Gibson" in b["author"] for b in books)
Run with pytest test_api.py -v. Every endpoint should have at least a happy path and a failure case.
Production APIs need a health endpoint. Add one to your app:
@app.get("/health")
def health_check():
return {"status": "healthy", "version": "1.0.0"}
Then schedule automated health checks to run on a cron schedule. A simple approach: hit the /health endpoint every 5 minutes and alert if it fails. Need to build the right cron expression? Our Cron Expression Generator can produce the schedule in crontab, GitHub Actions, Kubernetes, or systemd format with one click.
# crontab entry: check API health every 5 minutes
*/5 * * * * curl -sf http://localhost:8000/health > /dev/null || echo "API DOWN" | mail -s "Alert" you@email.com
Good error responses help clients (human and AI) recover gracefully. Follow these conventions:
detail field explaining what went wrong.FastAPI handles validation errors automatically, but for custom errors, use exception handlers:
from fastapi import Request
from fastapi.responses import JSONResponse
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={
"detail": "Internal server error",
"type": type(exc).__name__
}
)
These conventions are well-established, but they matter more now that AI agents are consuming APIs alongside humans:
/books, not /book/books/1/reviews/books?author=herbert&year=1965/book-reviews, not /bookReviewsVersion your API from day one. The simplest approach is URL-based:
app = FastAPI(root_path="/v1")
# Endpoints become /v1/books, /v1/books/{id}, etc.
When you need breaking changes, deploy /v2 alongside /v1 and give consumers time to migrate.
Never return unbounded lists. Add pagination to any endpoint that returns collections:
@app.get("/books", response_model=List[Book])
def list_books(skip: int = 0, limit: int = 20):
all_books = list(books_db.values())
return all_books[skip : skip + limit]
In 2026, a growing percentage of API traffic comes from AI agents — LLMs that autonomously discover and call endpoints. To make your API agent-friendly:
/openapi.json. Agents use it to understand your endpoints.book_title is better than bt — agents infer semantics from names.Before shipping to production:
/health endpoint and monitor it on a cron scheduleYou now have a working REST API with CRUD operations, validation, error handling, and tests. From here, the natural next steps are connecting a real database (SQLite with SQLAlchemy, or PostgreSQL with asyncpg), adding authentication (OAuth2 or API keys), and deploying behind a reverse proxy like Nginx.
The core patterns don't change. Whether your API serves a React frontend, a mobile app, or an AI agent that discovers your endpoints through the OpenAPI schema — the same principles apply: predictable URLs, proper status codes, validated inputs, and clear error messages.
Ready to test your API? Try these free developer tools — no signup required.
API Tester Regex Generator Cron Generator