FastAPI Tutorial: Build an API in 10 Minutes

FastAPI is the fastest way to build production-ready REST APIs in Python—automatic docs, built-in validation, and zero boilerplate. Most tutorials stop at "Hello World." This one gets you to a working CRUD API with database persistence and JWT auth before you finish your coffee.

1. Install FastAPI & Set Up Your First Endpoint

A woman writes 'Use APIs' on a whiteboard, focusing on software planning and strategy.
Photo by ThisIsEngineering on Pexels

FastAPI's minimal setup means your first working endpoint is under 10 lines of code. You'll have a live server running with interactive documentation before most frameworks finish their import chain.

Install FastAPI and Uvicorn

Always work inside a virtual environment. Skipping this causes version conflicts that are painful to debug later.

# Create and activate a virtual environment
python -m venv .venv
source .venv/bin/activate  # Windows: .venv\Scripts\activate

# Install FastAPI and Uvicorn (ASGI server)
pip install "fastapi[standard]" uvicorn[standard]

# Verify
python -c "import fastapi; print(fastapi.__version__)"

If you're using uv (the fast modern package manager), replace the pip line with uv add "fastapi[standard]". Either works fine.

Create Your First Hello World Endpoint

Create main.py in your project folder:

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
async def root():
    return {"message": "Hello World"}

Start the dev server:

fastapi dev main.py

Open http://127.0.0.1:8000/docs in your browser. You'll see a fully interactive Swagger UI—no configuration required. FastAPI generates this automatically from your type hints and Pydantic models. That's not magic; it's just FastAPI's OpenAPI integration working as designed.

Understanding the Request/Response Cycle

The decorator tells FastAPI which HTTP method and path to match. The function's return value becomes the JSON response body. Type hints on parameters tell FastAPI how to validate and parse incoming data automatically—no manual request.json() parsing needed.

@app.get("/items/{item_id}")
async def get_item(item_id: int, q: str | None = None):
    return {"item_id": item_id, "query": q}

Pass a string where item_id expects an int and FastAPI returns a 422 validation error before your function even runs. That's the contract at work.

2. Build CRUD Operations with Request/Response Models

Real APIs accept structured data. Pydantic models let you define your data shape once and get validation, serialization, and documentation for free.

Define Pydantic Models for Data Validation

Here's the wrong approach beginners take:

# Wrong - manual dict parsing, no validation, no docs
@app.post("/tasks")
async def create_task(body: dict):
    title = body.get("title")  # No type safety, no required field enforcement
    return {"title": title}

Do this instead:

from pydantic import BaseModel
from datetime import datetime

class TaskCreate(BaseModel):
    title: str
    description: str | None = None
    completed: bool = False

class TaskResponse(TaskCreate):
    id: int
    created_at: datetime

    model_config = {"from_attributes": True}

Separate request models (TaskCreate) from response models (TaskResponse). The request model never exposes id or created_at—those are server-generated. This matters when you add a real database.

Implement CREATE and READ Endpoints

from fastapi import HTTPException

tasks_db: list[dict] = []
next_id = 1

@app.post("/tasks", response_model=TaskResponse, status_code=201)
async def create_task(task: TaskCreate):
    global next_id
    new_task = {
        "id": next_id,
        "created_at": datetime.utcnow(),
        **task.model_dump()
    }
    tasks_db.append(new_task)
    next_id += 1
    return new_task

@app.get("/tasks", response_model=list[TaskResponse])
async def get_tasks():
    return tasks_db

@app.get("/tasks/{task_id}", response_model=TaskResponse)
async def get_task(task_id: int):
    task = next((t for t in tasks_db if t["id"] == task_id), None)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    return task

Notice status_code=201 on the POST. Most tutorials return 200 for created resources—that's technically wrong. Use proper HTTP semantics from day one.

Add UPDATE and DELETE Operations

@app.put("/tasks/{task_id}", response_model=TaskResponse)
async def update_task(task_id: int, task_update: TaskCreate):
    for i, task in enumerate(tasks_db):
        if task["id"] == task_id:
            tasks_db[i] = {**task, **task_update.model_dump()}
            return tasks_db[i]
    raise HTTPException(status_code=404, detail="Task not found")

@app.delete("/tasks/{task_id}", status_code=204)
async def delete_task(task_id: int):
    global tasks_db
    original_len = len(tasks_db)
    tasks_db = [t for t in tasks_db if t["id"] != task_id]
    if len(tasks_db) == original_len:
        raise HTTPException(status_code=404, detail="Task not found")

Test everything in /docs—the Swagger UI lets you fire requests directly without Postman or curl. Check out our Python type hints guide if Pydantic field types look unfamiliar.

3. Persist Data with SQLAlchemy and an Async Database

Stylish desk setup with a how-to book, keyboard, and world map on paper.
Photo by Walls.io on Pexels

In-memory lists disappear on restart. Replace the list with SQLite using async SQLAlchemy—the correct pattern for 2026 FastAPI apps.

Set Up SQLAlchemy with SQLite

pip install sqlalchemy aiosqlite
# database.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession

DATABASE_URL = "sqlite+aiosqlite:///./tasks.db"

engine = create_async_engine(DATABASE_URL, echo=True)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)

async def get_db():
    async with AsyncSessionLocal() as session:
        yield session

The get_db function is a FastAPI dependency. It opens a session, yields it to your endpoint, and closes it when the request finishes—even on errors.

Define SQLAlchemy ORM Models

# models.py
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from datetime import datetime

class Base(DeclarativeBase):
    pass

class Task(Base):
    __tablename__ = "tasks"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    title: Mapped[str]
    description: Mapped[str | None] = mapped_column(default=None)
    completed: Mapped[bool] = mapped_column(default=False)
    created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)

You need both models: the SQLAlchemy model maps to your database table, the Pydantic model defines your API contract. They look similar but serve different purposes. Beginners often try to use one for both—don't.

Rewrite CRUD Operations to Use the Database

from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from database import get_db
from models import Task, Base, engine

@app.on_event("startup")
async def startup():
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

@app.post("/tasks", response_model=TaskResponse, status_code=201)
async def create_task(task: TaskCreate, db: AsyncSession = Depends(get_db)):
    db_task = Task(**task.model_dump())
    db.add(db_task)
    await db.commit()
    await db.refresh(db_task)
    return db_task

@app.get("/tasks", response_model=list[TaskResponse])
async def get_tasks(db: AsyncSession = Depends(get_db)):
    result = await db.execute(select(Task))
    return result.scalars().all()

@app.get("/tasks/{task_id}", response_model=TaskResponse)
async def get_task(task_id: int, db: AsyncSession = Depends(get_db)):
    task = await db.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    return task

Data now survives restarts. The Depends(get_db) pattern is FastAPI's dependency injection—it handles session lifecycle automatically. See the SQLAlchemy async docs for advanced query patterns.

4. Secure Your API with JWT Authentication

Detailed view of programming code in a dark theme on a computer screen.
Photo by Stanislav Kondratiev on Pexels

Right now anyone can hit your endpoints. Adding JWT authentication takes about 20 lines and uses FastAPI's built-in OAuth2 support.

Implement JWT Token Generation

pip install python-jose[cryptography] passlib[bcrypt]
# auth.py
from datetime import datetime, timedelta
from jose import JWTError, jwt

SECRET_KEY = "your-secret-key-change-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

def create_access_token(data: dict) -> str:
    to_encode = data.copy()
    expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    to_encode["exp"] = expire
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

# Simple login endpoint
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    # In production: verify against database
    if form_data.username != "admin" or form_data.password != "secret":
        raise HTTPException(status_code=401, detail="Incorrect credentials")
    token = create_access_token({"sub": form_data.username})
    return {"access_token": token, "token_type": "bearer"}

Protect Endpoints with Token Verification

async def get_current_user(token: str = Depends(oauth2_scheme)):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username = payload.get("sub")
        if username is None:
            raise HTTPException(status_code=401, detail="Invalid token")
        return username
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid token")

# Protect any endpoint by adding the dependency
@app.post("/tasks", response_model=TaskResponse, status_code=201)
async def create_task(
    task: TaskCreate,
    db: AsyncSession = Depends(get_db),
    current_user: str = Depends(get_current_user)  # Add this line
):
    db_task = Task(**task.model_dump())
    db.add(db_task)
    await db.commit()
    await db.refresh(db_task)
    return db_task

Adding Depends(get_current_user) to any endpoint locks it behind authentication. The Swagger UI at /docs automatically shows an "Authorize" button once you define the OAuth2 scheme. For a deeper dive into dependency patterns, see our FastAPI dependency injection guide.

Feature FastAPI Flask Django REST
Automatic docs ✅ Built-in ❌ Extension needed ✅ drf-spectacular
Async support ✅ Native ⚠️ Limited ⚠️ Partial
Data validation ✅ Pydantic built-in ❌ Manual/Marshmallow ✅ Serializers
Setup time ~2 min ~5 min ~15 min
Learning curve Low Low High

Frequently Asked Questions

Q: Do I need to use async/await for every endpoint?

A: No. Use async def for endpoints that do I/O (database calls, HTTP requests). Use regular def for CPU-bound or purely synchronous operations—FastAPI runs sync endpoints in a thread pool automatically. Forcing async on sync code actually hurts performance.

Q: What's the difference between Pydantic models and SQLAlchemy models?

A: Pydantic models define your API contract—what data comes in and goes out over HTTP. SQLAlchemy models define your database schema. They often look identical, but mixing them leads to leaking database internals into your API responses. Keep them separate.

Q: Is FastAPI production-ready for 2026?

A: Yes. FastAPI powers APIs at Uber, Netflix, and Microsoft internally. For production, swap SQLite for PostgreSQL with asyncpg, use Alembic for migrations, deploy behind Nginx with multiple Uvicorn workers, and never hardcode your SECRET_KEY.

Wrap-up

You went from zero to a CRUD API with database persistence and JWT authentication—the same structure used in real production services. FastAPI's combination of type hints, Pydantic validation, and automatic OpenAPI docs removes entire categories of bugs that Flask and Django REST developers write tests to catch manually. Your next step: replace the SQLite database with PostgreSQL using asyncpg and run your first load test with locust.

Comments

Popular posts from this blog

Node.js Error Handling Best Practices 2026: Complete Guide

How to Use Docker for Local Development (Complete Guide 2026)

CSS Flexbox vs Grid: When to Use Each Layout