DEV Community

archi-jain
archi-jain

Posted on

Day 1/100: Building Your First FastAPI REST API - A Complete Beginner's Guide

Day 1/100: Building Your First FastAPI REST API - A Complete Beginner's Guide

Part of my 100 Days of Code journey. Follow along as I build production-ready Python skills.

Introduction

Welcome to Day 1 of my 100 Days of Code challenge! Today, I'm diving into FastAPI, a modern Python web framework that's taking the backend world by storm. If you've been using Flask or Django and wondering what all the FastAPI hype is about, this post is for you.

By the end of this tutorial, you'll understand how to build a complete REST API from scratch, including proper validation, error handling, and auto-generated documentation.

Why FastAPI?

Before we dive into code, let's talk about why FastAPI matters:

  • Fast: As the name suggests, it's one of the fastest Python frameworks available (comparable to Node.js and Go)
  • Modern: Built on modern Python features like type hints and async/await
  • Auto-validation: Pydantic models validate requests automatically
  • Auto-docs: Interactive API documentation generated for free
  • Industry adoption: Used by Uber, Netflix, Microsoft, and other tech giants

The Challenge

Build a Task Management API with these features:

  • Create, read, update, and delete tasks (CRUD)
  • Mark tasks as complete/incomplete
  • Proper error handling
  • Request/response validation
  • Auto-generated API documentation

Project Setup

Installing Dependencies

First, create a virtual environment and install FastAPI:

# Create virtual environment
python3 -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

# Install dependencies
pip install fastapi uvicorn
Enter fullscreen mode Exit fullscreen mode

What are these packages?

  • fastapi: The web framework itself
  • uvicorn: An ASGI server to run our FastAPI application

Project Structure

Create a single file called main.py. For this beginner project, we'll keep everything in one file for simplicity.

Building the API - Step by Step

Step 1: Initial Setup and Imports

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
from typing import Optional, Dict, List
from datetime import datetime
import uuid
Enter fullscreen mode Exit fullscreen mode

Line-by-line explanation:

  • FastAPI: The main class to create our application
  • HTTPException: Used to return HTTP error responses (like 404 Not Found)
  • status: Contains HTTP status code constants (201, 404, etc.)
  • BaseModel from Pydantic: Base class for our data models
  • Field: Adds validation constraints to model fields
  • Optional: Type hint for fields that can be None
  • Dict, List: Type hints for dictionaries and lists
  • datetime: For timestamps
  • uuid: For generating unique task IDs

Step 2: Initialize the Application

app = FastAPI(
    title="Task Management API",
    description="A simple REST API for managing tasks",
    version="1.0.0"
)
Enter fullscreen mode Exit fullscreen mode

What's happening here?

We create a FastAPI instance with metadata. This information appears in the auto-generated documentation at /docs.

Step 3: Create In-Memory Storage

tasks_db: Dict[str, dict] = {}
Enter fullscreen mode Exit fullscreen mode

Why a dictionary?

For Day 1, we're using a simple Python dictionary to store tasks. The keys are task IDs (strings), and the values are task dictionaries. This simulates a database without the complexity.

Type hint breakdown:

  • Dict[str, dict]: A dictionary with string keys and dict values
  • : Dict[str, dict] = {}: Type annotation with default empty dict

In production, you'd use a real database (PostgreSQL, MongoDB, etc.), but this is perfect for learning.

Step 4: Define Data Models with Pydantic

TaskCreate Model

class TaskCreate(BaseModel):
    title: str = Field(..., max_length=100, description="Task title")
    description: Optional[str] = Field(None, max_length=500, description="Task description")
Enter fullscreen mode Exit fullscreen mode

Breaking it down:

  • class TaskCreate(BaseModel): Inherits from Pydantic's BaseModel
  • title: str: Required string field
  • Field(...): The ... means this field is required
  • max_length=100: Validation - title can't exceed 100 characters
  • description="Task title": Shows in API docs
  • Optional[str]: This field can be a string or None
  • Field(None, ...): Default value is None (field is optional)

Why separate models?

We create different models for different purposes:

  • TaskCreate: What the user sends when creating a task
  • TaskUpdate: What can be updated
  • TaskResponse: What the API returns (includes ID, timestamps)

This separation follows the Single Responsibility Principle.

TaskUpdate Model

class TaskUpdate(BaseModel):
    title: Optional[str] = Field(None, max_length=100)
    description: Optional[str] = Field(None, max_length=500)
Enter fullscreen mode Exit fullscreen mode

Why everything is Optional here?

When updating, users might want to change only the title OR only the description, not necessarily both. Making fields optional allows partial updates.

TaskResponse Model

class TaskResponse(BaseModel):
    id: str
    title: str
    description: Optional[str]
    completed: bool
    created_at: datetime
    updated_at: datetime
Enter fullscreen mode Exit fullscreen mode

What's different?

This model includes fields that the API generates automatically:

  • id: Auto-generated UUID
  • completed: Default False, set by the system
  • created_at: Timestamp when task was created
  • updated_at: Timestamp when task was last modified

Users don't send these fields; the API creates them.

Step 5: Helper Function for Error Handling

def get_task_or_404(task_id: str):
    task = tasks_db.get(task_id)

    if not task:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Task not found"
        )

    return task
Enter fullscreen mode Exit fullscreen mode

Why create this helper?

Multiple endpoints need to check if a task exists. Instead of repeating this code, we create a reusable function. This follows the DRY principle (Don't Repeat Yourself).

How it works:

  1. tasks_db.get(task_id): Safely get task (returns None if not found)
  2. If task doesn't exist, raise HTTPException with 404 status
  3. If task exists, return it

HTTPException explained:

When raised, FastAPI automatically converts this to a proper HTTP error response:

{
  "detail": "Task not found"
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Create Task Endpoint

@app.post("/tasks", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
async def create_task(task: TaskCreate):
    task_id = str(uuid.uuid4())
    now = datetime.utcnow()

    new_task = {
        "id": task_id,
        "title": task.title,
        "description": task.description,
        "completed": False,
        "created_at": now,
        "updated_at": now
    }

    tasks_db[task_id] = new_task

    return new_task
Enter fullscreen mode Exit fullscreen mode

Decorator breakdown:

  • @app.post("/tasks"): This function handles POST requests to /tasks
  • response_model=TaskResponse: FastAPI validates the response matches this model
  • status_code=status.HTTP_201_CREATED: Returns 201 (resource created) instead of default 200

Why async def?

FastAPI is built for asynchronous programming. Even though this function doesn't use await, using async def is best practice and allows FastAPI to handle it efficiently.

Function parameters:

  • task: TaskCreate: FastAPI automatically:
    1. Parses the JSON request body
    2. Validates it against TaskCreate model
    3. Converts it to a TaskCreate object
    4. If validation fails, returns 422 error automatically

Step-by-step logic:

  1. Generate unique ID: str(uuid.uuid4()) creates a random UUID like "a3bb189e-8bf9-..."
  2. Get current timestamp: datetime.utcnow() gets UTC time (best practice for APIs)
  3. Create task dictionary with all required fields
  4. Store in our "database": tasks_db[task_id] = new_task
  5. Return the task (FastAPI automatically converts to JSON)

Step 7: Get All Tasks Endpoint

@app.get("/tasks", response_model=List[TaskResponse])
async def get_all_tasks():
    return list(tasks_db.values())
Enter fullscreen mode Exit fullscreen mode

Simple but powerful!

  • @app.get("/tasks"): Handles GET requests to /tasks
  • response_model=List[TaskResponse]: Returns a list of TaskResponse objects
  • list(tasks_db.values()): Gets all task dictionaries from our storage

What FastAPI does automatically:

  1. Validates each task in the list matches TaskResponse
  2. Converts Python datetime objects to ISO format strings
  3. Converts the entire list to JSON
  4. Sets proper Content-Type header

Step 8: Get Single Task Endpoint

@app.get("/tasks/{task_id}", response_model=TaskResponse)
async def get_task(task_id: str):
    task = get_task_or_404(task_id)
    return task
Enter fullscreen mode Exit fullscreen mode

Path parameters explained:

  • "/tasks/{task_id}": Curly braces define a path parameter
  • task_id: str: FastAPI extracts this from the URL
  • Example: GET /tasks/abc123 → task_id = "abc123"

Flow:

  1. FastAPI extracts task_id from URL
  2. Our helper checks if it exists
  3. If not found, raises 404 automatically
  4. If found, returns the task

Step 9: Update Task Endpoint

@app.put("/tasks/{task_id}", response_model=TaskResponse)
async def update_task(task_id: str, task_update: TaskUpdate):
    task = get_task_or_404(task_id)

    if task_update.title is not None:
        task["title"] = task_update.title

    if task_update.description is not None:
        task["description"] = task_update.description

    task["updated_at"] = datetime.utcnow()

    return task
Enter fullscreen mode Exit fullscreen mode

PUT vs PATCH:

  • PUT: Update entire resource (we're using this)
  • PATCH: Partial update (we'll use this for completion toggle)

Why check is not None?

Fields in TaskUpdate are optional. We only update fields that were actually sent in the request:

# Request: {"title": "New Title"}
# Only title is updated, description stays the same

if task_update.title is not None:  # True
    task["title"] = task_update.title

if task_update.description is not None:  # False (not sent)
    task["description"] = task_update.description  # Skipped!
Enter fullscreen mode Exit fullscreen mode

Always update timestamp:

Even if only one field changed, we update updated_at to track when the task was last modified.

Step 10: Toggle Completion Endpoint

@app.patch("/tasks/{task_id}/complete", response_model=TaskResponse)
async def toggle_task_completion(task_id: str):
    task = get_task_or_404(task_id)

    task["completed"] = not task["completed"]
    task["updated_at"] = datetime.utcnow()

    return task
Enter fullscreen mode Exit fullscreen mode

Why PATCH here?

PATCH is perfect for small, specific updates. This endpoint does one thing: toggle completion status.

The toggle logic:

task["completed"] = not task["completed"]
Enter fullscreen mode Exit fullscreen mode
  • If completed is True → becomes False
  • If completed is False → becomes True

Simple and elegant!

Step 11: Delete Task Endpoint

@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task(task_id: str):
    task = get_task_or_404(task_id)
    del tasks_db[task_id]
    return None
Enter fullscreen mode Exit fullscreen mode

HTTP 204 No Content:

Standard for successful deletions. Means:

  • ✅ Request succeeded
  • ℹ️ No content in response body

Why check if exists first?

We want to return 404 if the task doesn't exist, not silently succeed. This gives clients better feedback.

Delete operation:

del tasks_db[task_id]
Enter fullscreen mode Exit fullscreen mode

Removes the key-value pair from our dictionary.

Testing Your API

Run the Server

uvicorn main:app --reload
Enter fullscreen mode Exit fullscreen mode

Command breakdown:

  • uvicorn: The ASGI server
  • main:app: File name (main.py) : FastAPI instance (app)
  • --reload: Auto-restart on code changes (development only!)

Access Interactive Docs

Open your browser to:

This is FastAPI's superpower!

You get a full interactive API documentation where you can:

  • See all endpoints
  • Read descriptions
  • Try requests directly in the browser
  • See request/response examples

Manual Testing Examples

Create a Task

curl -X POST "http://localhost:8000/tasks" \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn FastAPI", "description": "Build a REST API"}'
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "id": "a3bb189e-8bf9-423b-bb46-98c7c5f3e5a7",
  "title": "Learn FastAPI",
  "description": "Build a REST API",
  "completed": false,
  "created_at": "2026-03-05T10:30:00.123456",
  "updated_at": "2026-03-05T10:30:00.123456"
}
Enter fullscreen mode Exit fullscreen mode

Get All Tasks

curl http://localhost:8000/tasks
Enter fullscreen mode Exit fullscreen mode

Update a Task

curl -X PUT "http://localhost:8000/tasks/{task_id}" \
  -H "Content-Type: application/json" \
  -d '{"title": "Updated Title"}'
Enter fullscreen mode Exit fullscreen mode

Toggle Completion

curl -X PATCH "http://localhost:8000/tasks/{task_id}/complete"
Enter fullscreen mode Exit fullscreen mode

Delete a Task

curl -X DELETE "http://localhost:8000/tasks/{task_id}"
Enter fullscreen mode Exit fullscreen mode

Key Concepts Learned

1. REST API Design

CRUD Operations mapped to HTTP methods:

  • Create → POST /tasks
  • Read → GET /tasks, GET /tasks/{id}
  • Update → PUT /tasks/{id}
  • Delete → DELETE /tasks/{id}

Proper HTTP Status Codes:

  • 200 OK: Successful GET, PUT, PATCH
  • 201 Created: Successful POST
  • 204 No Content: Successful DELETE
  • 404 Not Found: Resource doesn't exist
  • 422 Unprocessable Entity: Validation failed (automatic!)

2. Pydantic Validation

Pydantic handles all this automatically:

# Too long title (>100 chars)
{"title": "A" * 101}
# Returns: 422 with error message

# Missing required field
{"description": "Only description"}
# Returns: 422 "title is required"

# Wrong type
{"title": 123}
# Returns: 422 "title must be string"
Enter fullscreen mode Exit fullscreen mode

3. Type Hints

Type hints make code:

  • Self-documenting: Clear what each variable should be
  • Editor-friendly: Better autocomplete
  • FastAPI-compatible: Framework uses them for validation

4. Async/Await

While we didn't use await in this project, using async def prepares our code for:

  • Database operations
  • External API calls
  • File operations
  • Concurrent request handling

Common Mistakes and How to Avoid Them

Mistake 1: Mutable Default Arguments

# ❌ WRONG
def create_task(tags: list = []):
    tags.append("new")  # Modifies the same list every time!

# ✅ CORRECT
def create_task(tags: Optional[List[str]] = None):
    if tags is None:
        tags = []
    tags.append("new")
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Not Checking Existence Before Update

# ❌ WRONG
@app.put("/tasks/{task_id}")
async def update_task(task_id: str, task_update: TaskUpdate):
    # What if task_id doesn't exist? KeyError!
    tasks_db[task_id]["title"] = task_update.title

# ✅ CORRECT (using our helper)
@app.put("/tasks/{task_id}")
async def update_task(task_id: str, task_update: TaskUpdate):
    task = get_task_or_404(task_id)  # Returns 404 if not found
    task["title"] = task_update.title
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Wrong HTTP Methods

# ❌ WRONG
@app.get("/tasks/{task_id}/delete")  # Should be DELETE, not GET

# ✅ CORRECT
@app.delete("/tasks/{task_id}")
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Not Using Status Codes

# ❌ WRONG
@app.post("/tasks")  # Returns 200 by default

# ✅ CORRECT
@app.post("/tasks", status_code=status.HTTP_201_CREATED)
Enter fullscreen mode Exit fullscreen mode

Next Steps and Improvements

Immediate Enhancements

  1. Add filtering:
   @app.get("/tasks")
   async def get_tasks(completed: Optional[bool] = None):
       if completed is not None:
           return [t for t in tasks_db.values() if t["completed"] == completed]
       return list(tasks_db.values())
Enter fullscreen mode Exit fullscreen mode
  1. Add pagination:
   @app.get("/tasks")
   async def get_tasks(skip: int = 0, limit: int = 10):
       tasks = list(tasks_db.values())
       return tasks[skip : skip + limit]
Enter fullscreen mode Exit fullscreen mode
  1. Add search:
   @app.get("/tasks")
   async def get_tasks(search: Optional[str] = None):
       if search:
           return [t for t in tasks_db.values() 
                   if search.lower() in t["title"].lower()]
       return list(tasks_db.values())
Enter fullscreen mode Exit fullscreen mode

Future Learning Path

Day 2-5: Database integration (SQLAlchemy + PostgreSQL)
Day 6-10: Authentication and authorization (JWT, OAuth2)
Day 11-15: Testing (pytest, coverage)
Day 16-20: Docker deployment

Real-World Applications

This exact pattern is used for:

  • Todo apps (like we built)
  • Blog APIs (posts, comments)
  • E-commerce (products, orders)
  • Social media (posts, likes)
  • Project management (tasks, projects)

The CRUD pattern is fundamental to web development!

Conclusion

Today I learned:

✅ FastAPI basics and why it's gaining popularity
✅ How to build a complete REST API from scratch
✅ Pydantic models for automatic validation
✅ Proper HTTP methods and status codes
✅ Error handling best practices
✅ How to structure a professional API

Time Investment: 2.5 hours
Knowledge Gained: Production-ready API skills

Resources

Full Code

The complete code is available on my GitHub:
100-days-of-python/days/001


Tomorrow's Challenge: Adding PostgreSQL database integration and learning SQLAlchemy ORM.

Following my 100 Days journey? Connect with me on Twitter and LinkedIn!

Day 1/100 complete!


Tags: #100DaysOfCode #Python #FastAPI #BackendDevelopment #WebDevelopment #RestAPI #LearningInPublic

Top comments (0)