Intermediate

Building modern web applications often requires a robust API layer, and FastAPI has emerged as one of the most powerful and developer-friendly frameworks for this task. If you’ve been working with Flask or Django and found yourself wanting something faster, more intuitive, and built specifically for modern Python, FastAPI is exactly what you’ve been waiting for. In this tutorial, we’ll walk through creating production-ready REST APIs from scratch, complete with validation, error handling, and real-world examples.

You don’t need to be an API expert to follow along. We’ll start with a simple “Hello World” endpoint and progressively build toward a complete CRUD application. By the end of this guide, you’ll understand how to design endpoints, validate user input with Pydantic, structure your code professionally, and handle errors gracefully.

Here’s what we’ll cover: we’ll install FastAPI and Uvicorn, create our first endpoint, explore path and query parameters, implement request validation using Pydantic models, build a complete CRUD Todo API, handle errors properly, define response models, and finally answer the most common questions developers ask when getting started. Let’s build something real.

Quick Example: Your First FastAPI Application

Before we dive into the details, here’s a fully functional FastAPI application that you can run right now. This will give you a taste of how simple and elegant FastAPI can be:

# main.py
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    description: str = None
    price: float

@app.get("/")
def read_root():
    return {"message": "Hello, FastAPI!"}

@app.get("/items/{item_id}")
def read_item(item_id: int, query_param: str = None):
    return {"item_id": item_id, "query_param": query_param}

@app.post("/items/")
def create_item(item: Item):
    return {"created_item": item}

Output: When you run this with uvicorn main.py:app --reload, you’ll have a fully functional API server with automatic documentation at http://localhost:8000/docs. This is the magic of FastAPI–your code is simultaneously your documentation.

What is FastAPI and Why Use It?

FastAPI is a modern, fast web framework for building APIs with Python 3.6+. Created by Sebastian Ramirez, it combines the simplicity of Flask with the power of Django REST Framework, while being incredibly performant. FastAPI automatically generates interactive API documentation (Swagger UI and ReDoc), validates request data using Pydantic models, and provides type hints that make your code self-documenting.

The “fast” in FastAPI refers to both development speed and runtime performance. Development is fast because you write less boilerplate code and get built-in validation. Runtime is fast because it’s built on top of Starlette and uses async/await natively. Unlike older frameworks, FastAPI was designed from the ground up for modern Python async programming.

Here’s how FastAPI compares to other popular frameworks:

Feature FastAPI Flask Django REST
Setup Complexity Minimal Minimal Moderate
Built-in Validation Yes (Pydantic) No Yes
Async Support Native Limited Limited
Auto Documentation Yes No Optional
Performance Very High High High
Learning Curve Low Very Low Moderate

FastAPI wins when you need a modern, high-performance API with minimal setup. It’s particularly valuable for microservices, data science applications, and any project where you want to move fast without sacrificing code quality.

Installing FastAPI and Uvicorn

FastAPI is just a framework–it needs a web server to run. The most common choice is Uvicorn, an ASGI server that’s fast, simple, and perfect for development and production. Let’s get everything installed.

First, make sure you have Python 3.7 or higher installed. You can check your version with python --version. Then, create a virtual environment to keep your project dependencies isolated:

# Setup (bash/zsh)
python -m venv fastapi_env
source fastapi_env/bin/activate  # On Windows: fastapi_env\Scripts\activate

# Install FastAPI and Uvicorn
pip install fastapi uvicorn[standard]

Output: The installation will complete in seconds. The [standard] extras include additional features like WebSocket support and automatic reload.

That’s it! You now have everything needed to build professional REST APIs. The next step is to create your first application file and start writing endpoints.

Creating Your First Endpoint

An endpoint is a URL on your API that clients can request. FastAPI uses Python decorators to define endpoints in a way that’s both elegant and powerful. Let’s create a simple application with a few endpoints:

# app.py
from fastapi import FastAPI

app = FastAPI(
    title="My First API",
    description="A simple API to learn FastAPI",
    version="1.0.0"
)

@app.get("/")
def read_root():
    """This is the root endpoint"""
    return {"message": "Welcome to FastAPI!"}

@app.get("/about")
def read_about():
    """Learn more about this API"""
    return {
        "app_name": "My First API",
        "version": "1.0.0",
        "author": "Your Name"
    }

@app.post("/data")
def receive_data(data: str):
    """Receive data from client"""
    return {"received": data}

Output: Save this as app.py and run uvicorn app:app --reload. Visit http://localhost:8000 in your browser and you’ll see the welcome message. Then visit http://localhost:8000/docs to see the interactive API documentation that FastAPI generates automatically.

The --reload flag makes Uvicorn automatically restart when you change your code–perfect for development. Each decorator specifies the HTTP method (@app.get, @app.post) and the path. The function name doesn’t matter to the API, but descriptive names help readability. The docstring automatically becomes the endpoint description in the documentation.

Path Parameters and Query Parameters

APIs need flexibility to handle different requests. Path parameters are part of the URL itself (like /users/123), while query parameters come after a question mark (like /users?page=1&limit=10). FastAPI makes both incredibly easy to implement.

Path parameters are the most common way to identify a specific resource. When you want users to retrieve a specific item by ID, you use a path parameter:

# parameters.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/users/{user_id}")
def get_user(user_id: int):
    """Get a specific user by ID"""
    return {"user_id": user_id, "name": f"User {user_id}"}

@app.get("/posts/{post_id}/comments/{comment_id}")
def get_comment(post_id: int, comment_id: int):
    """Get a specific comment on a specific post"""
    return {
        "post_id": post_id,
        "comment_id": comment_id,
        "text": "This is a comment"
    }

Output: When you request /users/42, the API returns {"user_id": 42, "name": "User 42"}. FastAPI automatically extracts the user_id from the URL and validates that it’s an integer. If someone sends /users/abc, FastAPI automatically returns a 422 validation error without any extra code from you.

Query parameters are optional filtering and pagination options. They appear after a question mark in the URL:

# query_params.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/search")
def search(
    query: str,
    page: int = 1,
    limit: int = 10,
    sort_by: str = "relevance"
):
    """Search with pagination and sorting"""
    return {
        "query": query,
        "page": page,
        "limit": limit,
        "sort_by": sort_by,
        "results": []
    }

@app.get("/products")
def list_products(
    category: str = None,
    min_price: float = 0,
    max_price: float = 1000,
    in_stock: bool = True
):
    """List products with optional filters"""
    return {
        "category": category,
        "price_range": {"min": min_price, "max": max_price},
        "in_stock": in_stock,
        "products": []
    }

Output: A request to /search?query=python&page=2&limit=20 will parse all three parameters correctly. Query parameters with default values are optional–you can call /search?query=python and page and limit will use their defaults. This is how you build flexible, user-friendly APIs.

Request Body with Pydantic Models

When clients need to send complex data to your API (like creating a new user or updating a product), you use request bodies. This is where Pydantic models become invaluable. Pydantic automatically validates the incoming data against your model definition, converts types, and provides helpful error messages if something is wrong.

A Pydantic model is a Python class that defines the structure of your data. Here’s how to use them for request bodies:

# models.py
from fastapi import FastAPI
from pydantic import BaseModel, Field
from typing import Optional

app = FastAPI()

class User(BaseModel):
    username: str
    email: str
    full_name: Optional[str] = None
    age: Optional[int] = None

class Product(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    description: str = Field(default="", max_length=500)
    price: float = Field(..., gt=0, le=999999)
    stock: int = Field(default=0, ge=0)
    tags: list = Field(default_factory=list)

@app.post("/users")
def create_user(user: User):
    """Create a new user with validation"""
    return {
        "message": f"User {user.username} created successfully",
        "user": user
    }

@app.post("/products")
def create_product(product: Product):
    """Create a new product with detailed validation"""
    return {
        "message": f"Product '{product.name}' created",
        "product": product
    }

@app.put("/users/{user_id}")
def update_user(user_id: int, user: User):
    """Update a user by ID"""
    return {
        "user_id": user_id,
        "message": "User updated",
        "user": user
    }

Output: When you send this JSON to the /products endpoint:

{
  "name": "Python Book",
  "price": 29.99,
  "stock": 50,
  "tags": ["programming", "python", "learning"]
}

FastAPI validates that the price is positive, the stock is non-negative, and the name exists and is between 1 and 100 characters. If you send "price": -10, it rejects it with a clear error message. This validation is automatic–you don’t write any custom validation code. The Field function provides fine-grained control: gt=0 means “greater than zero”, le=999999 means “less than or equal to”, min_length and max_length validate string length.

CRUD Operations

CRUD stands for Create, Read, Update, and Delete–the four fundamental database operations. Let’s build a complete CRUD API for managing a collection of articles. This example uses an in-memory list to keep things simple, but in production you’d use a real database like PostgreSQL:

# crud_api.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime

app = FastAPI(title="Article API")

class Article(BaseModel):
    id: Optional[int] = None
    title: str
    content: str
    author: str
    created_at: Optional[datetime] = None

# In-memory storage (replace with database in production)
articles_db = []
article_counter = 1

@app.get("/articles", response_model=List[Article])
def list_articles(skip: int = 0, limit: int = 10):
    """Get all articles with pagination"""
    return articles_db[skip:skip + limit]

@app.get("/articles/{article_id}", response_model=Article)
def get_article(article_id: int):
    """Get a specific article by ID"""
    for article in articles_db:
        if article.id == article_id:
            return article
    raise HTTPException(status_code=404, detail="Article not found")

@app.post("/articles", response_model=Article)
def create_article(article: Article):
    """Create a new article"""
    global article_counter
    article.id = article_counter
    article.created_at = datetime.now()
    article_counter += 1
    articles_db.append(article)
    return article

@app.put("/articles/{article_id}", response_model=Article)
def update_article(article_id: int, updated_article: Article):
    """Update an existing article"""
    for i, article in enumerate(articles_db):
        if article.id == article_id:
            updated_article.id = article_id
            updated_article.created_at = article.created_at
            articles_db[i] = updated_article
            return updated_article
    raise HTTPException(status_code=404, detail="Article not found")

@app.delete("/articles/{article_id}")
def delete_article(article_id: int):
    """Delete an article by ID"""
    global articles_db
    initial_length = len(articles_db)
    articles_db = [a for a in articles_db if a.id != article_id]
    if len(articles_db) == initial_length:
        raise HTTPException(status_code=404, detail="Article not found")
    return {"message": f"Article {article_id} deleted successfully"}

Output: This API provides all four CRUD operations:

  • CREATE: POST to /articles with a JSON body to create a new article
  • READ: GET /articles to list all, or GET /articles/1 to get a specific one
  • UPDATE: PUT to /articles/1 to modify an article
  • DELETE: DELETE to /articles/1 to remove an article

The response_model parameter tells FastAPI what shape the response should be, enabling automatic validation of your response and generation of proper API documentation. The HTTPException with status code 404 is the standard way to signal that a resource wasn’t found.

Error Handling and Status Codes

Professional APIs don’t just return data–they communicate what went wrong when something fails. HTTP status codes are the standard way to do this. FastAPI makes error handling straightforward with the HTTPException class and customizable exception handlers.

Here are the most important HTTP status codes for your API:

  • 200 OK: Request succeeded, here’s your data
  • 201 Created: Resource was successfully created
  • 204 No Content: Success but no data to return (like DELETE)
  • 400 Bad Request: Client sent invalid data
  • 401 Unauthorized: Authentication required
  • 403 Forbidden: Not allowed to access this resource
  • 404 Not Found: Resource doesn’t exist
  • 422 Unprocessable Entity: Validation failed (FastAPI uses this automatically)
  • 500 Internal Server Error: Server error

Let’s implement comprehensive error handling:

# error_handling.py
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, validator
from typing import Optional

app = FastAPI()

class Item(BaseModel):
    name: str
    price: float
    quantity: int

    @validator('price')
    def price_must_be_positive(cls, v):
        if v <= 0:
            raise ValueError('Price must be greater than 0')
        return v

    @validator('quantity')
    def quantity_must_be_positive(cls, v):
        if v < 0:
            raise ValueError('Quantity cannot be negative')
        return v

items_db = {
    1: Item(name="Laptop", price=999.99, quantity=5),
    2: Item(name="Mouse", price=29.99, quantity=50)
}

@app.get("/items/{item_id}")
def get_item(item_id: int):
    """Get item, with proper error handling"""
    if item_id not in items_db:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Item with ID {item_id} not found"
        )
    return items_db[item_id]

@app.post("/items", status_code=status.HTTP_201_CREATED)
def create_item(item: Item):
    """Create new item with 201 response"""
    new_id = max(items_db.keys()) + 1 if items_db else 1
    items_db[new_id] = item
    return {"id": new_id, "item": item}

@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int):
    """Delete item, returning 204 No Content"""
    if item_id not in items_db:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Item not found"
        )
    del items_db[item_id]
    return None

@app.post("/checkout/{item_id}")
def checkout(item_id: int, quantity: int):
    """Purchase item with inventory check"""
    if item_id not in items_db:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Item not found"
        )

    item = items_db[item_id]
    if quantity > item.quantity:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Insufficient stock. Only {item.quantity} available"
        )

    if quantity <= 0:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Quantity must be positive"
        )

    item.quantity -= quantity
    return {
        "status": "success",
        "item": item.name,
        "quantity": quantity,
        "total": quantity * item.price
    }

Output: When you request an item that doesn't exist, you get a proper 404 response with a descriptive message. When you try to buy more than available, you get a 400 error explaining the shortage. The validators in the Pydantic model automatically reject invalid data before your handler code even runs.

Response Models

Response models define the structure of your API responses and enable several powerful features: automatic response validation, filtering, serialization, and documentation. Even though your code might work with complex objects, response models let you control exactly what gets sent to the client.

Consider a real-world example where your database contains sensitive information you shouldn't expose:

# response_models.py
from fastapi import FastAPI
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime

app = FastAPI()

class UserInDB(BaseModel):
    """Full user data with sensitive info"""
    id: int
    username: str
    email: str
    password_hash: str  # Never expose this!
    created_at: datetime

class UserResponse(BaseModel):
    """What we return to clients"""
    id: int
    username: str
    email: str
    created_at: datetime

class BlogPost(BaseModel):
    title: str
    content: str
    author: str

class BlogPostResponse(BaseModel):
    """Response with computed fields"""
    title: str
    content: str
    author: str
    word_count: Optional[int] = None
    excerpt: Optional[str] = None

# Simulated database with sensitive data
users_db = {
    1: UserInDB(
        id=1,
        username="alice",
        email="alice@example.com",
        password_hash="$2b$12$...hashed...",
        created_at=datetime.now()
    )
}

@app.get("/users/{user_id}", response_model=UserResponse)
def get_user(user_id: int):
    """
    Get user - only returns safe fields.
    Password hash is never exposed even though
    the internal representation includes it.
    """
    if user_id not in users_db:
        from fastapi import HTTPException
        raise HTTPException(status_code=404, detail="User not found")
    return users_db[user_id]

@app.post("/posts", response_model=BlogPostResponse)
def create_post(post: BlogPost):
    """
    Create post - response includes computed fields
    like word_count and excerpt that aren't in the request
    """
    content = post.content
    word_count = len(content.split())
    excerpt = content[:100] + "..." if len(content) > 100 else content

    return {
        "title": post.title,
        "content": content,
        "author": post.author,
        "word_count": word_count,
        "excerpt": excerpt
    }

Output: When you request a user, the response only contains the fields defined in UserResponse. The password hash from the database is automatically filtered out. When you create a post, the response includes computed fields like word_count and excerpt even though the client never sent them. This is the power of response models--they decouple your internal data structures from your API contract.

Real-Life Example: Building a Todo API

Let's tie everything together with a complete, production-ready Todo API that demonstrates all the concepts we've learned. This is a practical example you can extend with a real database like PostgreSQL or MongoDB:

# todo_api.py
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from enum import Enum

app = FastAPI(
    title="Todo API",
    description="A complete todo management API",
    version="1.0.0"
)

class PriorityLevel(str, Enum):
    low = "low"
    medium = "medium"
    high = "high"

class TodoCreate(BaseModel):
    title: str = Field(..., min_length=1, max_length=200)
    description: Optional[str] = Field(None, max_length=1000)
    priority: PriorityLevel = PriorityLevel.medium
    due_date: Optional[datetime] = None

class TodoResponse(BaseModel):
    id: int
    title: str
    description: Optional[str]
    priority: PriorityLevel
    completed: bool
    due_date: Optional[datetime]
    created_at: datetime
    completed_at: Optional[datetime] = None

todos_db: List[TodoResponse] = []
todo_counter = 1

@app.get("/todos", response_model=List[TodoResponse])
def list_todos(
    completed: Optional[bool] = None,
    priority: Optional[PriorityLevel] = None,
    skip: int = 0,
    limit: int = 20
):
    """Get todos with optional filtering"""
    results = todos_db

    if completed is not None:
        results = [t for t in results if t.completed == completed]
    if priority:
        results = [t for t in results if t.priority == priority]

    return results[skip:skip + limit]

@app.get("/todos/{todo_id}", response_model=TodoResponse)
def get_todo(todo_id: int):
    """Get a specific todo"""
    for todo in todos_db:
        if todo.id == todo_id:
            return todo
    raise HTTPException(status_code=404, detail="Todo not found")

@app.post("/todos", response_model=TodoResponse, status_code=status.HTTP_201_CREATED)
def create_todo(todo: TodoCreate):
    """Create a new todo"""
    global todo_counter
    new_todo = TodoResponse(
        id=todo_counter,
        title=todo.title,
        description=todo.description,
        priority=todo.priority,
        completed=False,
        due_date=todo.due_date,
        created_at=datetime.now()
    )
    todo_counter += 1
    todos_db.append(new_todo)
    return new_todo

@app.put("/todos/{todo_id}", response_model=TodoResponse)
def update_todo(todo_id: int, todo_update: TodoCreate):
    """Update an existing todo"""
    for i, todo in enumerate(todos_db):
        if todo.id == todo_id:
            updated = TodoResponse(
                id=todo.id,
                title=todo_update.title,
                description=todo_update.description,
                priority=todo_update.priority,
                completed=todo.completed,
                due_date=todo_update.due_date,
                created_at=todo.created_at
            )
            todos_db[i] = updated
            return updated
    raise HTTPException(status_code=404, detail="Todo not found")

@app.patch("/todos/{todo_id}/complete", response_model=TodoResponse)
def complete_todo(todo_id: int):
    """Mark a todo as complete"""
    for i, todo in enumerate(todos_db):
        if todo.id == todo_id:
            todo.completed = True
            todo.completed_at = datetime.now()
            todos_db[i] = todo
            return todo
    raise HTTPException(status_code=404, detail="Todo not found")

@app.delete("/todos/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_todo(todo_id: int):
    """Delete a todo"""
    global todos_db
    initial_length = len(todos_db)
    todos_db = [t for t in todos_db if t.id != todo_id]
    if len(todos_db) == initial_length:
        raise HTTPException(status_code=404, detail="Todo not found")

Output: This Todo API supports:

  • Creating todos with title, description, priority, and optional due date
  • Listing all todos with filtering by completion status and priority
  • Getting individual todos by ID
  • Updating todo details
  • Marking todos as complete (with completion timestamp)
  • Deleting todos
  • Proper HTTP status codes for each operation
  • Full validation of input data through Pydantic models

Run this with uvicorn todo_api:app --reload and visit http://localhost:8000/docs to see the interactive documentation. You can test every endpoint right from your browser. To use this in production, replace the in-memory todos_db list with actual database calls to PostgreSQL, MongoDB, or any other database your project uses.

Frequently Asked Questions

Should I use async functions in FastAPI?

Use async functions when your endpoint does I/O-bound operations like database queries, API calls, or file operations. These operations have "wait time" during which your server can handle other requests. For CPU-bound operations (heavy calculations), use regular synchronous functions. FastAPI handles both seamlessly--just use async def when appropriate. Most real-world APIs benefit from async because they spend time waiting for databases and external services.

How do I add custom validation to Pydantic models?

Use the @validator decorator from Pydantic. The validator function runs after type conversion and can raise a ValueError with a custom message. You can validate a single field or multiple fields by passing multiple field names to the decorator. FastAPI automatically includes validation errors in the 422 response.

How do I secure my FastAPI endpoints?

FastAPI includes built-in support for OAuth2, JWT tokens, and HTTP Basic authentication. Use the Security dependency from FastAPI to protect your endpoints. For a quick start with JWT: install python-jose and passlib, create login endpoints that return tokens, and use Depends() to require tokens on protected endpoints. Never store passwords in plain text--always use password hashing with libraries like bcrypt.

How do I handle CORS (Cross-Origin) requests?

CORS is needed when your frontend runs on a different domain than your API. Install fastapi-cors and add middleware to your app. Specify which origins are allowed, which HTTP methods they can use, and which headers are permitted. This prevents unauthorized websites from accessing your API while allowing your legitimate frontend to communicate.

What's the best way to integrate a database?

Use SQLAlchemy for relational databases (PostgreSQL, MySQL) or an async-compatible library like `tortoise-orm` or `sqlalchemy` with `asyncpg`. FastAPI works great with both synchronous and asynchronous database libraries. Create a separate database.py file with connection logic, use dependency injection with Depends() to pass database sessions to your endpoints, and keep database models separate from Pydantic models to decouple your API contract from your database schema.

How do I test my FastAPI endpoints?

Use the TestClient from FastAPI and pytest. Create a test file that imports your app, creates a client with TestClient(app), and makes requests to your endpoints. The test client simulates HTTP requests without actually running a server. Test successful requests, validation failures, authorization errors, and edge cases. FastAPI makes testing incredibly easy because everything is synchronous in tests.

How do I deploy a FastAPI application to production?

Don't use the development server in production. Use Gunicorn with Uvicorn workers: gunicorn app:app --workers 4 --worker-class uvicorn.workers.UvicornWorker. For containerization, create a Dockerfile with Python, install dependencies, and run Gunicorn. Deploy to any platform that supports Docker: AWS ECS, Google Cloud Run, Heroku, DigitalOcean App Platform, or Kubernetes. Make sure your environment variables for secrets, database URLs, and API keys are set properly--never hardcode them.

Conclusion

You've now learned how to build professional REST APIs with FastAPI. You understand decorators and path parameters, you can validate complex data structures with Pydantic models, you know how to implement complete CRUD operations, and you can handle errors gracefully with proper HTTP status codes. These foundations will serve you well whether you're building a simple microservice or a complex distributed system.

The real power of FastAPI lies in its simplicity and performance. Your code is simultaneously your tests, documentation, and API specification. The validation is automatic. The performance is exceptional. What used to take dozens of lines of boilerplate in older frameworks now takes a few lines of elegant Python.

Your next steps: start building real projects with FastAPI, integrate a real database when your app grows beyond in-memory storage, add authentication with JWT tokens, and deploy to your preferred cloud platform. The FastAPI documentation at https://fastapi.tiangolo.com/ is comprehensive and worth reading as you tackle more advanced topics like WebSockets, background tasks, and database migrations.

  • Building a Web Scraper with Beautiful Soup and Requests
  • Django REST Framework: Build Powerful APIs with Django
  • Database Modeling with SQLAlchemy in Python
  • Deploying Python Applications with Docker and Kubernetes
  • Async Programming in Python with asyncio
  • Testing Python Code with pytest and Mock
  • Building a Real-Time Chat Application with FastAPI and WebSockets
  • API Authentication with JWT Tokens in Python