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
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
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.) -
BaseModelfrom 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"
)
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] = {}
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")
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)
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
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
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:
-
tasks_db.get(task_id): Safely get task (returns None if not found) - If task doesn't exist, raise
HTTPExceptionwith 404 status - If task exists, return it
HTTPException explained:
When raised, FastAPI automatically converts this to a proper HTTP error response:
{
"detail": "Task not found"
}
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
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:- Parses the JSON request body
- Validates it against TaskCreate model
- Converts it to a TaskCreate object
- If validation fails, returns 422 error automatically
Step-by-step logic:
- Generate unique ID:
str(uuid.uuid4())creates a random UUID like "a3bb189e-8bf9-..." - Get current timestamp:
datetime.utcnow()gets UTC time (best practice for APIs) - Create task dictionary with all required fields
- Store in our "database":
tasks_db[task_id] = new_task - 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())
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:
- Validates each task in the list matches TaskResponse
- Converts Python datetime objects to ISO format strings
- Converts the entire list to JSON
- 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
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:
- FastAPI extracts task_id from URL
- Our helper checks if it exists
- If not found, raises 404 automatically
- 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
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!
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
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"]
- 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
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]
Removes the key-value pair from our dictionary.
Testing Your API
Run the Server
uvicorn main:app --reload
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:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
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"}'
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"
}
Get All Tasks
curl http://localhost:8000/tasks
Update a Task
curl -X PUT "http://localhost:8000/tasks/{task_id}" \
-H "Content-Type: application/json" \
-d '{"title": "Updated Title"}'
Toggle Completion
curl -X PATCH "http://localhost:8000/tasks/{task_id}/complete"
Delete a Task
curl -X DELETE "http://localhost:8000/tasks/{task_id}"
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"
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")
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
Mistake 3: Wrong HTTP Methods
# ❌ WRONG
@app.get("/tasks/{task_id}/delete") # Should be DELETE, not GET
# ✅ CORRECT
@app.delete("/tasks/{task_id}")
Mistake 4: Not Using Status Codes
# ❌ WRONG
@app.post("/tasks") # Returns 200 by default
# ✅ CORRECT
@app.post("/tasks", status_code=status.HTTP_201_CREATED)
Next Steps and Improvements
Immediate Enhancements
- 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())
- 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]
- 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())
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)