How to Build a REST API with FastAPI: Complete Tutorial (2026)
FastAPI has become the go-to Python web framework in 2026, and for good reason. It's fast (comparable to Node.js and Go), has automatic API documentation, type-safe by default, and genuinely enjoyable to use.
This tutorial builds a complete production-ready REST API from scratch: authentication, database integration, error handling, testing, and deployment.
Why FastAPI?
| Feature | FastAPI | Flask | Django | |---------|---------|-------|--------| | Performance | ⚡ Extremely fast | Moderate | Moderate | | Async support | Native | Extension | Limited | | Type safety | Built-in | Manual | Partial | | API docs | Auto-generated | Manual (Swagger) | Manual | | Learning curve | Low | Very low | High | | Best for | APIs | Simple apps | Full-stack apps |
FastAPI uses Python type hints for validation, serialization, and documentation. Write your code once — get validation, docs, and IDE support for free.
Project Setup
Prerequisites
# Python 3.12+
python --version
# Create project structure
mkdir fastapi-app && cd fastapi-app
# Create virtual environment
python -m venv .venv
source .venv/bin/activate # Linux/Mac
# .venv\Scripts\activate # Windows
# Install dependencies
pip install fastapi uvicorn[standard] sqlalchemy[asyncio] asyncpg python-jose[cryptography] passlib[bcrypt] python-multipart pydantic-settings
# Save dependencies
pip freeze > requirements.txt
Project Structure
fastapi-app/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── config.py
│ ├── database.py
│ ├── models.py
│ ├── schemas.py
│ ├── auth.py
│ ├── dependencies.py
│ └── routers/
│ ├── __init__.py
│ ├── users.py
│ ├── posts.py
│ └── auth.py
├── tests/
│ ├── conftest.py
│ ├── test_users.py
│ └── test_posts.py
├── .env
├── requirements.txt
└── Dockerfile
Configuration
.env
DATABASE_URL=postgresql+asyncpg://app:secret@localhost:5432/fastapi_db
SECRET_KEY=your-super-secret-key-change-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
app/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str
SECRET_KEY: str
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
class Config:
env_file = ".env"
settings = Settings()
Database Setup
app/database.py
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session() as session:
try:
yield session
finally:
await session.close()
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
Models and Schemas
app/models.py (Database Models)
from datetime import datetime
from sqlalchemy import String, Text, DateTime, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
posts: Mapped[list["Post"]] = relationship(back_populates="author", cascade="all, delete-orphan")
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
content: Mapped[str] = mapped_column(Text)
published: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
author: Mapped["User"] = relationship(back_populates="posts")
app/schemas.py (Pydantic Schemas)
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field, ConfigDict
# User schemas
class UserBase(BaseModel):
email: EmailStr
username: str = Field(min_length=3, max_length=100)
class UserCreate(UserBase):
password: str = Field(min_length=8, max_length=100)
class UserResponse(UserBase):
model_config = ConfigDict(from_attributes=True)
id: int
is_active: bool
created_at: datetime
# Post schemas
class PostBase(BaseModel):
title: str = Field(min_length=1, max_length=200)
content: str = Field(min_length=1)
published: bool = True
class PostCreate(PostBase):
pass
class PostResponse(PostBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
updated_at: datetime
author_id: int
# Token schemas
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: str | None = None
Authentication
app/auth.py
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from app.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> dict:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
app/dependencies.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import decode_token, verify_password, oauth2_scheme
from app.database import get_db
from app.models import User
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
) -> User:
payload = decode_token(token)
username = payload.get("sub")
if not username:
raise HTTPException(status_code=401, detail="Invalid token")
result = await db.execute(select(User).where(User.username == username))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return user
Routers (API Endpoints)
app/routers/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import create_access_token, verify_password, hash_password
from app.database import get_db
from app.models import User
from app.schemas import UserCreate, UserResponse, Token
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserResponse, status_code=201)
async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
# Check if user exists
existing = await db.execute(select(User).where(User.email == user_data.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
# Create user
user = User(
email=user_data.email,
username=user_data.username,
hashed_password=hash_password(user_data.password),
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.post("/token", response_model=Token)
async def login(
form: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(User).where(User.username == form.username))
user = result.scalar_one_or_none()
if not user or not verify_password(form.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
access_token = create_access_token(data={"sub": user.username})
return {"access_token": access_token, "token_type": "bearer"}
app/routers/posts.py
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user
from app.models import Post, User
from app.schemas import PostCreate, PostResponse
router = APIRouter(prefix="/posts", tags=["posts"])
@router.get("/", response_model=list[PostResponse])
async def list_posts(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db)
):
result = await db.execute(
select(Post).where(Post.published == True).offset(skip).limit(limit).order_by(Post.created_at.desc())
)
return result.scalars().all()
@router.get("/{post_id}", response_model=PostResponse)
async def get_post(post_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Post).where(Post.id == post_id))
post = result.scalar_one_or_none()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
return post
@router.post("/", response_model=PostResponse, status_code=201)
async def create_post(
post_data: PostCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
post = Post(**post_data.model_dump(), author_id=current_user.id)
db.add(post)
await db.commit()
await db.refresh(post)
return post
@router.put("/{post_id}", response_model=PostResponse)
async def update_post(
post_id: int,
post_data: PostCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
result = await db.execute(select(Post).where(Post.id == post_id))
post = result.scalar_one_or_none()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
if post.author_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
for field, value in post_data.model_dump().items():
setattr(post, field, value)
await db.commit()
await db.refresh(post)
return post
@router.delete("/{post_id}", status_code=204)
async def delete_post(
post_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
result = await db.execute(select(Post).where(Post.id == post_id))
post = result.scalar_one_or_none()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
if post.author_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
await db.delete(post)
await db.commit()
Main Application
app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import init_db
from app.routers import auth, posts
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(
title="Blog API",
description="A REST API built with FastAPI",
version="1.0.0",
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routers
app.include_router(auth.router)
app.include_router(posts.router)
@app.get("/")
async def root():
return {"message": "Welcome to the Blog API. Visit /docs for documentation."}
@app.get("/health")
async def health():
return {"status": "healthy"}
Running the API
# Development (with auto-reload)
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# Production
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
Open http://localhost:8000/docs — you get interactive API documentation for free. Every endpoint, with request/response examples, is automatically generated from your type hints.
Testing
tests/conftest.py
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.database import Base, get_db
from app.main import app
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
test_engine = create_async_engine(TEST_DATABASE_URL)
test_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async def override_get_db():
async with test_session() as session:
yield session
app.dependency_overrides[get_db] = override_get_db
@pytest_asyncio.fixture(autouse=True)
async def setup_db():
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
tests/test_posts.py
import pytest
@pytest.mark.asyncio
async def test_list_posts_empty(client):
response = await client.get("/posts/")
assert response.status_code == 200
assert response.json() == []
@pytest.mark.asyncio
async def test_register_and_login(client):
# Register
response = await client.post("/auth/register", json={
"email": "test@example.com",
"username": "testuser",
"password": "password123"
})
assert response.status_code == 201
assert response.json()["username"] == "testuser"
# Login
response = await client.post("/auth/token", data={
"username": "testuser",
"password": "password123"
})
assert response.status_code == 200
token = response.json()["access_token"]
assert token
@pytest.mark.asyncio
async def test_create_post_requires_auth(client):
response = await client.post("/posts/", json={
"title": "Test Post",
"content": "Hello World",
"published": True
})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_create_post_with_auth(client):
# Register and login
await client.post("/auth/register", json={
"email": "test@example.com",
"username": "testuser",
"password": "password123"
})
login_response = await client.post("/auth/token", data={
"username": "testuser",
"password": "password123"
})
token = login_response.json()["access_token"]
# Create post
response = await client.post(
"/posts/",
json={"title": "My First Post", "content": "Hello World", "published": True},
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 201
assert response.json()["title"] == "My First Post"
# Run tests
pip install pytest pytest-asyncio httpx aiosqlite
pytest -v
Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Create non-root user
RUN useradd -m appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
docker-compose.yml
services:
api:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql+asyncpg://app:secret@db:5432/fastapi_db
- SECRET_KEY=production-secret-key
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: fastapi_db
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 5s
retries: 5
volumes:
db_data:
Conclusion
FastAPI gives you the speed of Go/Node.js with the developer experience of Python. The type hints do triple duty: input validation, response serialization, and API documentation.
The API we built has:
- ✅ JWT authentication
- ✅ CRUD operations
- ✅ Database integration (async PostgreSQL)
- ✅ Automatic API documentation at /docs
- ✅ Error handling
- ✅ Test coverage
- ✅ Docker deployment
This is a solid foundation. Add rate limiting, logging, pagination, and you have a production-ready API that rivals anything built in Go or Node.js.