Build a Task Manager API with FastAPI
Build, test, and deploy a REST API with FastAPI and Pydantic.
Introduction
30 Second Summary
Every app you use daily, from weather forecasts to to-do lists, talks to an API behind the scenes. APIs are how software components communicate with each other, and knowing how to build one is the bridge between writing scripts and building real software.
In this project, you will build a Task Manager API using Python and FastAPI, write automated tests to prove it works, and deploy it live on the internet for anyone to access.
What You'll Build
A REST API that lets users create, read, update, delete, and filter tasks through HTTP endpoints, with proper data validation, meaningful error responses, and auto-generated interactive documentation.
By the end of this project, you'll have:
- A FastAPI application with 5+ endpoints that perform full CRUD operations on tasks.
- An automated test suite using pytest that validates your API handles both success and failure cases.
- A professional multi-file project structure that separates data models, business logic, and routing into distinct layers.
- A live deployment on Render accessible via a public URL.
- Secret Mission: A priority system with sorting and a statistics endpoint that aggregates task data.
Are there any prerequisites?
This project assumes you have completed a Programming Fundamentals course covering loops, lists, dictionaries, classes, and functions. You will use every one of those concepts here, applied in a production context.
No prior experience with APIs or web frameworks is needed. This project teaches those from scratch.
Set Up Your Development Environment
Before you can build an API, you need a solid foundation. This step is all about getting your tools in place so everything runs smoothly when you start writing code.
You will confirm your Python version, create a project directory with Git tracking, set up an isolated virtual environment, and install the packages your API needs. By the end, you will have a minimal server running to prove the setup works.
In this step, get ready to:
- Verify Python 3.12+ and initialize a Git repository.
- Create a virtual environment and install FastAPI with its dependencies.
- Build the project folder structure and run a hello world server.
Verify Python and create your project
This project uses Python 3.12+ for modern syntax like dict[int, X] type hints. Let's confirm your installed version meets this requirement.
- Check your Python version by running this command:
python3 --version
On Windows?
If python3 is not recognized, try python --version instead. Windows often uses python as the command name.
✔️ I see version 3.12 or higher
You are good to go. Any version number starting with 3.12, 3.13, or 3.14 works perfectly for this project.
ⓧ I see an older version
You have Python installed, but it is an older version that does not support the syntax this project uses. You need to install a newer version alongside your existing one.
- Visit python.org/downloads and download the latest Python 3 installer for your operating system.
- Run the installer. On Windows, check the box that says Add Python to PATH before clicking Install Now.
- After installation, close and reopen your terminal.
- Run python3 --version again (or python --version on Windows) to confirm you now see 3.12 or higher.
macOS users with an older default
macOS may still resolve python3 to an older version even after installing a new one. Try python3.14 --version or python3.13 --version to call the new install directly. If that works, use that command throughout this project wherever you see python3.
ⓧ Command not found
Python is not installed on your system yet.
- Visit python.org/downloads and download the latest Python 3 installer for your operating system.
- Run the installer. On Windows, check Add Python to PATH before clicking Install Now.
- After installation, close and reopen your terminal.
- Run python3 --version (or python --version on Windows) to confirm you see 3.12 or higher.
Now let's create a home for your project on your Desktop and initialize version control.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir task-manager-api
cd task-manager-api
- Initialize a Git repository to track your changes by running:
git init
You should see a message that says Initialized empty Git repository followed by the path to your folder.
Don't see the initialized message?
- Make sure you ran cd task-manager-api before git init. You want the repo inside your project folder, not on the Desktop.
- If you see git: command not found, install Git from git-scm.com and reopen your terminal.
Help me troubleshoot my git init issue.
Create a virtual environment and install dependencies
A virtual environment gives your project its own isolated set of packages. Without one, installing packages would affect every Python project on your machine.
- Create a virtual environment by running this command:
python3 -m venv venv
What does this command do?
python3 -m venv venv tells Python to run its built-in venv module and create a new virtual environment in a folder called venv. This folder holds a private copy of the Python interpreter and a space for your project's packages.
- Activate the virtual environment so your terminal uses it for all pip installs:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
- Make sure you are still inside the task-manager-api folder. Run ls (macOS) or dir (Windows) and confirm you see a venv folder listed.
- On Windows, if you see a permissions error, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned in PowerShell first, then try activating again.
Help me activate my virtual environment.
- Install the project's core packages by running this command:
pip install fastapi==0.136.3 uvicorn==0.48.0 pytest==9.0.3 httpx==0.28.1
What are these packages?
- fastapi is the web framework that handles HTTP requests and auto-generates API documentation.
- uvicorn is the server that runs your FastAPI application.
- pytest is the testing framework you will use to write automated tests later.
- httpx is an HTTP client that pytest uses internally to simulate API requests during tests.
The == pins each package to an exact version. This makes sure everyone running this project gets identical behavior.
You should see a series of download and install messages ending with Successfully installed followed by the package names.
Seeing an install error?
- Confirm (venv) appears in your prompt. If it does not, you are installing globally instead of into your virtual environment.
- If you see a version conflict error, make sure you are running Python 3.12 or higher. Older Python versions may not support these package versions.
Help me fix my pip install error.
Create the project folder structure and test your setup
Professional Python projects separate code into logical folders. Your project will have an app/ folder for application code and a tests/ folder for automated tests.
- Create the folder structure by running these commands:
mkdir app tests
touch app/__init__.py tests/__init__.py
What are __init__.py files?
An __init__.py file tells Python that a folder is a package (a collection of modules that can be imported). Without it, Python would not let you import code from that folder. The files can be empty. Their existence is what matters.
Windows alternative
If touch is not recognized on Windows, use these commands instead:
New-Item -ItemType File app\__init__.py
New-Item -ItemType File tests\__init__.py
- Confirm the files were created by running:
ls app tests
You should see __init__.py listed under both app and tests.
- Create requirements.txt in the root of your project to document your dependencies. Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code, and pressing Enter.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the task-manager-api folder on your Desktop, then click Open.
- Create a new file called requirements.txt in the root of the project by pressing Cmd+N (macOS) or Ctrl+N (Windows).
- Paste the following content into the file:
fastapi==0.136.3
uvicorn==0.48.0
pytest==9.0.3
httpx==0.28.1
Why pin exact versions?
Pinning versions with == ensures anyone who clones your project gets the same package versions you used. Without pins, a future install might pull a newer version that breaks something.
- Save the file as requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows). When prompted for a file name, enter requirements.txt and save it in the root of task-manager-api.
Now let's verify your setup works end-to-end by running a minimal FastAPI server.
- Create a new file at app/main.py in VS Code. Right-click the app folder in the Explorer sidebar and select New File.
- Name the file main.py and paste the following code:
from fastapi import FastAPI
# Create the FastAPI application instance
app = FastAPI()
# A simple root endpoint to verify the server is running
@app.get("/")
def root():
return {"message": "Hello, world!"}
What does this code do?
- from fastapi import FastAPI imports the framework.
- app = FastAPI() creates your application object. This is the entry point for all your routes.
- @app.get("/") registers the function below it as the handler for GET requests to the root URL.
- The function returns a Python dictionary, which FastAPI automatically converts to JSON.
- Save app/main.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Return to your terminal (make sure (venv) is still showing) and start the server by running:
uvicorn app.main:app --reload
What does this command do?
- uvicorn is the server that runs your application.
- app.main:app tells uvicorn to look inside the app folder, open main.py, and use the object named app.
- --reload enables auto-reloading. The server restarts automatically whenever you save a code change.
You should see output that includes Uvicorn running on http://127.0.0.1:8000.
- Open your browser and navigate to http://127.0.0.1:8000.
You should see a JSON response: {"message": "Hello, world!"}.
- Now visit http://127.0.0.1:8000/docs in your browser.
This is FastAPI's auto-generated interactive documentation. You will use this page throughout the project to test your endpoints.
- Stop the server by pressing Ctrl+C in your terminal.
Server not starting?
- If you see ModuleNotFoundError: No module named 'fastapi', your virtual environment is not active. Run the activation command again and retry.
- If you see Error loading ASGI app, double-check that your file is saved at app/main.py (inside the app folder, not the project root).
- Make sure app/__init__.py exists. Without it, Python cannot treat app as a package.
Help me get my FastAPI server running.
✔️ Awesome, I've got everything!
Great. Double check you have saved both requirements.txt and app/main.py.
ⓧ I'd like to double check the full code
Here is what your requirements.txt should look like:
fastapi==0.136.3
uvicorn==0.48.0
pytest==9.0.3
httpx==0.28.1
Your app/__init__.py and tests/__init__.py should both be empty files (no content needed).
Your app/main.py should have the hello world code you pasted earlier. This file will be replaced in a later step when you build the full application.
Your environment is ready and your first FastAPI server is running. Next up, you will define the data models that describe what a task looks like using Pydantic classes.
Define Your Data Models with Pydantic
Your environment is ready. The next question is: what does a "task" actually look like in your API?
You'll answer that question with Pydantic models. These are Python classes that define the shape of your data and automatically reject anything that doesn't match your rules.
In this step, get ready to:
- Create Pydantic models that validate task data.
- Build an in-memory data store using a dictionary.
- Test validation by passing bad data in the Python REPL.
Create the task status enum and input model
Every task in your API will have a status: pending, in progress, or completed. You'll define this as an Enum class, then build the first model that uses it.
- In VS Code, create a new file called models.py inside the app/ folder.
- Add the following code to app/models.py:
from pydantic import BaseModel, Field
from enum import Enum
from datetime import datetime
from typing import Optional
# Define the allowed statuses a task can have
class TaskStatus(str, Enum):
pending = "pending"
in_progress = "in_progress"
completed = "completed"
# Define what data is required to CREATE a new task
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = Field(default="", max_length=1000)
status: TaskStatus = TaskStatus.pending
What does this code do?
TaskStatus inherits from both str and Enum. This means each status is both a string value and a restricted choice. Only "pending", "in_progress", or "completed" are allowed.
TaskCreate inherits from Pydantic's BaseModel. Each field has a type annotation and validation rules. The title must be between 1 and 200 characters. The description defaults to an empty string. The status defaults to "pending".
- Don't save yet. You'll add the remaining models in the next chunk.
Add the update and response models
Your API needs two more models. One handles partial updates (where not every field is required). The other defines the shape of data your API sends back to users.
- Add the following code below the TaskCreate class in app/models.py:
# Define what data is accepted for UPDATING an existing task
class TaskUpdate(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=200)
description: Optional[str] = Field(default=None, max_length=1000)
status: Optional[TaskStatus] = None
# Define the shape of task data sent back in API responses
class TaskResponse(BaseModel):
id: int
title: str
description: str
status: TaskStatus
created_at: datetime
What does this code do?
TaskUpdate uses Optional on every field. This means the caller can send only the fields they want to change. If a field is not provided, it stays None (meaning "don't change this").
TaskResponse defines what the API returns to users. It includes an id and created_at timestamp that the server generates. The user never provides these values directly.
- Save app/models.py.
- You should see models.py listed under the app/ folder in VS Code's Explorer sidebar.
Don't see models.py in the Explorer?
Make sure you created the file inside the app/ folder, not in the project root. The path should be app/models.py.
If the Explorer sidebar is hidden, press Cmd+Shift+E (macOS) or Ctrl+Shift+E (Windows) to reveal it. Help me find models.py in my project
✔️ Awesome, I've got everything!
Great. Double check you've saved app/models.py before continuing.
ⓧ I'd like to double check the full code
Here is the complete app/models.py file:
from pydantic import BaseModel, Field
from enum import Enum
from datetime import datetime
from typing import Optional
class TaskStatus(str, Enum):
pending = "pending"
in_progress = "in_progress"
completed = "completed"
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = Field(default="", max_length=1000)
status: TaskStatus = TaskStatus.pending
class TaskUpdate(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=200)
description: Optional[str] = Field(default=None, max_length=1000)
status: Optional[TaskStatus] = None
class TaskResponse(BaseModel):
id: int
title: str
description: str
status: TaskStatus
created_at: datetime
What is Pydantic?
Pydantic is a data validation library that comes bundled with FastAPI. You define a class with typed fields, and Pydantic automatically checks that incoming data matches those types. If it doesn't, Pydantic raises a detailed error explaining exactly what went wrong.
These model classes are just Python classes with attributes and validation logic. Every field is typed. This is OOP with a purpose.
Build the in-memory data store
Your API needs somewhere to store tasks. Instead of setting up a database, you'll use a Python dictionary. This is the same data structure from your coursework, now powering an API.
- Create a new file called database.py inside the app/ folder.
- Add the following code to app/database.py:
from app.models import TaskResponse, TaskStatus
from datetime import datetime, timezone
# Store tasks as a dictionary: task_id -> TaskResponse
tasks_db: dict[int, TaskResponse] = {}
# Track the next available ID
next_id: int = 1
def reset_db():
"""Clear all tasks and reset the ID counter."""
global tasks_db, next_id
tasks_db = {}
next_id = 1
def get_next_id() -> int:
"""Return the next available task ID and increment the counter."""
global next_id
current = next_id
next_id += 1
return current
What does this code do?
tasks_db is a dictionary that maps integer IDs to TaskResponse objects. This is your entire data store. It works exactly like the dictionaries you've used before.
reset_db() wipes the store clean. You'll use this in automated tests later to get a fresh state between test runs. get_next_id() generates unique IDs by incrementing a counter each time a task is created.
- Save app/database.py.
- You should see database.py alongside models.py in the app/ folder in the Explorer sidebar.
Seeing an import error underline?
If VS Code shows a red underline on from app.models import TaskResponse, TaskStatus, make sure your app/__init__.py file exists (it can be empty). Also confirm your VS Code workspace is opened at the task-manager-api/ folder level, not a parent folder.
Help me fix import errors in database.py
✔️ Awesome, I've got everything!
Great. Double check you've saved app/database.py before continuing.
ⓧ I'd like to double check the full code
Here is the complete app/database.py file:
from app.models import TaskResponse, TaskStatus
from datetime import datetime, timezone
tasks_db: dict[int, TaskResponse] = {}
next_id: int = 1
def reset_db():
global tasks_db, next_id
tasks_db = {}
next_id = 1
def get_next_id() -> int:
global next_id
current = next_id
next_id += 1
return current
Test your models in the Python REPL
The best way to understand Pydantic validation is to see it reject bad data in real time. You'll create a valid task, then intentionally break the rules.
- Open your terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Make sure your virtual environment is still active (you should see (venv) in your terminal prompt).
- Start the Python REPL by running:
python
- Create a valid task by entering the following lines one at a time:
from app.models import TaskCreate, TaskStatus
# Create a valid task (this should work)
task = TaskCreate(title="Buy groceries")
print(task)
- You should see output showing the task with its title, an empty description, and a status of "pending".
- Now try creating a task with an empty title by entering:
# Try creating a task with an empty title (this will fail!)
bad_task = TaskCreate(title="")
- You should see a ValidationError from Pydantic explaining that the string is too short. This is your validation rules in action.
- Exit the Python REPL by typing:
exit()
Why is automatic validation useful?
Without Pydantic, you'd write manual if-statements to check every field. Pydantic does this automatically based on your type annotations and Field constraints. When your API receives a request, Pydantic validates the data before your code ever touches it.
The error messages tell the caller exactly what went wrong, so they can fix their request without guessing.
Seeing a ModuleNotFoundError?
If Python says it cannot find the app module, make sure you started the REPL from the task-manager-api/ project root (the folder containing the app/ directory). Also confirm that app/__init__.py exists.
Help me fix ModuleNotFoundError for app.models
Your data models and store are ready. In the next step, you'll write the business logic that creates, reads, updates, and deletes tasks using these models.
Build the Service Layer
Your data models define the shape of tasks, and your database module stores them. Now you need the logic that actually does things with that data.
In this step, you'll write the service layer. These are plain Python functions that create, retrieve, filter, update, and delete tasks. Every one of them uses the fundamentals you already know: dictionaries, loops, conditionals, and list comprehensions.
In this step, get ready to:
- Create a custom exception class for missing tasks.
- Build service functions for creating, reading, updating, and deleting tasks.
- Add filtering logic using list comprehensions and conditionals.
Create a custom exception class
When someone requests a task that doesn't exist, your API needs a clear way to signal that error. A custom exception class gives every function in your service layer a shared, readable way to say "this task is missing."
This is inheritance with a practical purpose. Your TaskNotFound class inherits from Python's built-in Exception class, adding a task_id attribute and a meaningful error message.
- Create a new file called exceptions.py inside your app/ folder.
- Add the following code to app/exceptions.py:
class TaskNotFound(Exception):
def __init__(self, task_id: int):
self.task_id = task_id
super().__init__(f"Task with id {task_id} not found")
What does this code do?
This is a Python class that inherits from Exception. It stores the task_id that was requested and passes a human-readable error message to the parent class via super().__init__().
Later, your API routes will catch this exception and convert it into a proper HTTP 404 response.
- Save app/exceptions.py.
Why create a separate exceptions file?
You could define this exception inside service.py directly. But giving exceptions their own file means multiple modules can import and use them without circular dependency issues.
This is separation of concerns. Each file has one job.
Build the core service functions
Your service layer is where the real work happens. Each function operates on the tasks_db dictionary using loops, conditionals, and dictionary operations. These are the same fundamentals you learned in your coursework, now powering an API.
- Create a new file called service.py inside your app/ folder.
- Add the imports and your first two service functions by pasting the following code into app/service.py:
from app.models import TaskCreate, TaskUpdate, TaskResponse, TaskStatus
from app.database import tasks_db, get_next_id
from app.exceptions import TaskNotFound
from datetime import datetime, timezone
from typing import Optional
def create_task(task_data: TaskCreate) -> TaskResponse:
task_id = get_next_id()
task = TaskResponse(
id=task_id,
title=task_data.title,
description=task_data.description,
status=task_data.status,
created_at=datetime.now(timezone.utc),
)
tasks_db[task_id] = task
return task
def get_task(task_id: int) -> TaskResponse:
if task_id not in tasks_db:
raise TaskNotFound(task_id)
return tasks_db[task_id]
What does this code do?
- The imports pull in your models, your database store, and the exception you just created.
- create_task() gets a fresh ID, builds a TaskResponse object with the current UTC timestamp, stores it in the dictionary, and returns it.
- get_task() checks if the ID exists in the dictionary. If not, it raises your custom TaskNotFound exception.
- Now add the update and delete functions below get_task. Copy the following code and paste it at the bottom of app/service.py:
def update_task(task_id: int, task_data: TaskUpdate) -> TaskResponse:
if task_id not in tasks_db:
raise TaskNotFound(task_id)
existing_task = tasks_db[task_id]
update_fields = task_data.model_dump(exclude_unset=True)
updated_task = existing_task.model_copy(update=update_fields)
tasks_db[task_id] = updated_task
return updated_task
def delete_task(task_id: int) -> None:
if task_id not in tasks_db:
raise TaskNotFound(task_id)
del tasks_db[task_id]
What does this code do?
- update_task() first checks the task exists, then uses model_dump(exclude_unset=True) to get only the fields the caller actually sent. This way, omitting a field means "leave it unchanged" instead of "set it to None."
- model_copy(update=update_fields) creates a new copy of the task with the updated fields applied. This is a Pydantic v2 method that keeps your models immutable and validated.
- delete_task() removes the task from the dictionary using del. It returns nothing (None), matching the HTTP 204 "No Content" pattern you'll use later.
- Save app/service.py.
Seeing a red underline on the imports?
If VS Code shows import errors, make sure you saved app/exceptions.py before editing service.py. Also confirm that your app/__init__.py file exists (even if it's empty).
Help me debug import errors in my FastAPI service layer.
Add filtering logic to list tasks
Listing all tasks is useful, but a real API needs filtering. You want users to find tasks by status ("show me only completed tasks") or by keyword in the title ("find tasks about groceries").
This is just a for-loop with an if-condition, expressed as a list comprehension. The same pattern you used in your fundamentals coursework.
- In app/service.py, find the get_task function. Add the following get_all_tasks function directly below it (between get_task and update_task):
def get_all_tasks(
status: Optional[TaskStatus] = None,
search: Optional[str] = None,
) -> list[TaskResponse]:
tasks = list(tasks_db.values())
if status is not None:
tasks = [task for task in tasks if task.status == status]
if search is not None:
search_lower = search.lower()
tasks = [task for task in tasks if search_lower in task.title.lower()]
return tasks
What does this code do?
- list(tasks_db.values()) starts with every task in the dictionary, converted to a list.
- The first list comprehension filters by status. If the caller passes status="completed", only completed tasks survive.
- The second list comprehension filters by keyword. It lowercases both the search term and each title so matching is case-insensitive.
- Both filters are optional. If neither is passed, the function returns every task unfiltered.
- Save app/service.py.
- Confirm that your file has no red underlines or syntax errors in VS Code.
Seeing a syntax error after pasting?
Make sure there are exactly two blank lines between each function (one blank line between the last line of one function and the def keyword of the next). Also check that get_all_tasks sits between get_task and update_task, not at the very end of the file.
Help me fix a syntax error in my service.py file.
✔️ Awesome, I've got everything!
Great. Double check you've saved both app/exceptions.py and app/service.py.
ⓧ I'd like to double check the full code
Here's the complete app/exceptions.py:
class TaskNotFound(Exception):
def __init__(self, task_id: int):
self.task_id = task_id
super().__init__(f"Task with id {task_id} not found")
Here's the complete app/service.py:
from app.models import TaskCreate, TaskUpdate, TaskResponse, TaskStatus
from app.database import tasks_db, get_next_id
from app.exceptions import TaskNotFound
from datetime import datetime, timezone
from typing import Optional
def create_task(task_data: TaskCreate) -> TaskResponse:
task_id = get_next_id()
task = TaskResponse(
id=task_id,
title=task_data.title,
description=task_data.description,
status=task_data.status,
created_at=datetime.now(timezone.utc),
)
tasks_db[task_id] = task
return task
def get_task(task_id: int) -> TaskResponse:
if task_id not in tasks_db:
raise TaskNotFound(task_id)
return tasks_db[task_id]
def get_all_tasks(
status: Optional[TaskStatus] = None,
search: Optional[str] = None,
) -> list[TaskResponse]:
tasks = list(tasks_db.values())
if status is not None:
tasks = [task for task in tasks if task.status == status]
if search is not None:
search_lower = search.lower()
tasks = [task for task in tasks if search_lower in task.title.lower()]
return tasks
def update_task(task_id: int, task_data: TaskUpdate) -> TaskResponse:
if task_id not in tasks_db:
raise TaskNotFound(task_id)
existing_task = tasks_db[task_id]
update_fields = task_data.model_dump(exclude_unset=True)
updated_task = existing_task.model_copy(update=update_fields)
tasks_db[task_id] = updated_task
return updated_task
def delete_task(task_id: int) -> None:
if task_id not in tasks_db:
raise TaskNotFound(task_id)
del tasks_db[task_id]
Your business logic is ready. You have five functions that create, read, filter, update, and delete tasks. Next up, you'll expose this logic to the outside world by building API routes that anyone can call over HTTP.
Build the API Routes
You have a complete service layer with functions for creating, reading, updating, and deleting tasks. Now you need a way for the outside world to call those functions over the internet.
That's what API routes do. Each route is a function that receives an HTTP request, calls the appropriate service function, and returns a response. In this step, you'll connect your business logic to FastAPI's routing system and see your API come alive in an interactive browser interface.
In this step, get ready to:
- Create a routes file with endpoints for all CRUD operations.
- Add error handling that converts Python exceptions into proper HTTP responses.
- Assemble and run your API application with auto-generated documentation.
Create your routes file with the first endpoints
Routes map URL paths to Python functions. When someone sends a request to /tasks, FastAPI looks up which function handles that path and calls it with the request data.
- Create a new file called app/routes.py in VS Code.
- Add the imports, router setup, and your first two endpoints by pasting this code:
from fastapi import APIRouter, HTTPException, Query
from app.models import TaskCreate, TaskUpdate, TaskResponse, TaskStatus
from app import service
from typing import Optional
# Create a router that groups all task-related endpoints under /tasks
router = APIRouter(prefix="/tasks", tags=["Tasks"])
# POST /tasks - Create a new task
@router.post("", response_model=TaskResponse, status_code=201)
def create_task(task_data: TaskCreate):
return service.create_task(task_data)
# GET /tasks - List all tasks with optional filters
@router.get("", response_model=list[TaskResponse])
def list_tasks(
status: Optional[TaskStatus] = Query(default=None),
search: Optional[str] = Query(default=None),
):
return service.get_all_tasks(status=status, search=search)
What does this code do?
APIRouter groups related endpoints together. The prefix="/tasks" means every route on this router starts with /tasks automatically.
Each route function receives parameters and returns a value. create_task receives a TaskCreate object (FastAPI validates the incoming JSON against your Pydantic model automatically). list_tasks uses Query to declare optional URL parameters like /tasks?status=completed.
The response_model tells FastAPI what shape the response should have. The status_code=201 on create_task returns "Created" instead of the default 200 "OK."
- Save app/routes.py.
Why import HTTPException now?
You imported HTTPException at the top even though these two endpoints don't use it yet. The next substep adds endpoints that need it. Putting all imports at the top keeps the file clean.
Add error handling for individual task endpoints
The endpoints above always succeed because listing tasks and creating tasks can't fail (you can always add to a list). But getting, updating, or deleting a specific task can fail if that task doesn't exist.
Your service layer raises TaskNotFound when a task ID doesn't exist. Routes need to catch that exception and convert it into an HTTP 404 response the client understands.
- In app/routes.py, add these three endpoints below the list_tasks function:
# GET /tasks/{task_id} - Get a single task by ID
@router.get("/{task_id}", response_model=TaskResponse)
def get_task(task_id: int):
try:
return service.get_task(task_id)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))
# PUT /tasks/{task_id} - Update an existing task
@router.put("/{task_id}", response_model=TaskResponse)
def update_task(task_id: int, task_data: TaskUpdate):
try:
return service.update_task(task_id, task_data)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))
# DELETE /tasks/{task_id} - Remove a task
@router.delete("/{task_id}", status_code=204)
def delete_task(task_id: int):
try:
service.delete_task(task_id)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))
What does the try/except pattern do?
Each of these endpoints wraps the service call in a try/except block. If the service raises TaskNotFound, the route catches it and raises an HTTPException with status_code=404. This tells the client "the resource you asked for doesn't exist."
The {task_id} in the URL path is a path parameter. FastAPI automatically extracts it from the URL and passes it to your function as an integer.
- Save app/routes.py.
Seeing a red underline on imports?
If VS Code shows import errors, make sure your virtual environment is selected as the Python interpreter. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type "Python: Select Interpreter", and choose the one inside your project's venv folder.
help me fix import errors in my FastAPI routes file
✔️ Awesome, I've got everything!
Great. Double check you've saved app/routes.py before moving on.
ⓧ I'd like to double check the full code
from fastapi import APIRouter, HTTPException, Query
from app.models import TaskCreate, TaskUpdate, TaskResponse, TaskStatus
from app import service
from typing import Optional
router = APIRouter(prefix="/tasks", tags=["Tasks"])
@router.post("", response_model=TaskResponse, status_code=201)
def create_task(task_data: TaskCreate):
return service.create_task(task_data)
@router.get("", response_model=list[TaskResponse])
def list_tasks(
status: Optional[TaskStatus] = Query(default=None),
search: Optional[str] = Query(default=None),
):
return service.get_all_tasks(status=status, search=search)
@router.get("/{task_id}", response_model=TaskResponse)
def get_task(task_id: int):
try:
return service.get_task(task_id)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/{task_id}", response_model=TaskResponse)
def update_task(task_id: int, task_data: TaskUpdate):
try:
return service.update_task(task_id, task_data)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))
@router.delete("/{task_id}", status_code=204)
def delete_task(task_id: int):
try:
service.delete_task(task_id)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))
Create the main application file and test your API
Your routes exist, but nothing is serving them yet. You need an entry point that creates the FastAPI application, attaches your router, and starts listening for requests.
- Create a new file called app/main.py and paste this code:
from fastapi import FastAPI
from app.routes import router
# Create the FastAPI application with metadata
app = FastAPI(
title="Task Manager API",
description="A production-ready task management API",
version="1.0.0",
)
# Attach all task routes to the application
app.include_router(router)
# Root endpoint to confirm the API is running
@app.get("/")
def root():
return {"message": "Task Manager API is running", "docs": "/docs"}
What does this code do?
FastAPI() creates your application instance. The title, description, and version parameters appear in the auto-generated documentation.
app.include_router(router) registers all the endpoints you defined in routes.py. The root / endpoint is a quick health check that confirms the API is alive.
- Save app/main.py.
- Start your API server by running this command in your terminal:
uvicorn app.main:app --reload
What does this command do?
uvicorn is the server that runs your FastAPI app. app.main:app tells it to look inside the app package, in the main.py file, for the variable named app.
The --reload flag restarts the server automatically whenever you save a file. This is for development only.
You should see output showing Uvicorn running on http://127.0.0.1:8000.
- Open your browser and go to http://127.0.0.1:8000/docs.
You should see the Swagger UI with all your endpoints listed. This interactive documentation is generated automatically from your route definitions and Pydantic models.
- Click the POST /tasks endpoint to expand it.
- Click Try it out.
- Replace the example JSON body with:
{
"title": "Buy groceries",
"description": "Milk, eggs, bread",
"status": "pending"
}
- Click Execute.
You should see a 201 response with your newly created task, including an auto-generated id and created_at timestamp.
- Try the GET /tasks endpoint to confirm your task appears in the list.
- Try GET /tasks/999 to verify you get a 404 error response.
Server won't start?
If you see ModuleNotFoundError, make sure your virtual environment is activated and you're running the command from your project's root folder (not from inside the app/ directory).
If you see a port-in-use error, another process is already using port 8000. Stop it or run with --port 8001 instead.
help me fix my uvicorn server startup error
✔️ Awesome, I've got everything!
Great. Double check you've saved app/main.py and your server is running.
ⓧ I'd like to double check the full code
from fastapi import FastAPI
from app.routes import router
app = FastAPI(
title="Task Manager API",
description="A production-ready task management API",
version="1.0.0",
)
app.include_router(router)
@app.get("/")
def root():
return {"message": "Task Manager API is running", "docs": "/docs"}
Your API is alive and responding to requests. Every service function you wrote is now accessible over HTTP, with automatic input validation and interactive documentation. Next up, you'll write automated tests that prove your API works correctly without clicking through the browser manually.
Write Automated Tests
Your API has routes, a service layer, and proper error handling. But how do you know everything actually works together?
Automated tests give you confidence that your code does what you think it does. In this step, you'll write tests that simulate HTTP requests to your API and verify the responses. The test file uses the same fundamentals you already know: functions, assert statements, and dictionaries.
In this step, get ready to:
- Install pytest and httpx, then set up a test client with fixtures.
- Write happy-path tests covering create, read, filter, update, and delete operations.
- Write error-case tests and run the full test suite.
Install pytest and httpx
Your requirements.txt already lists pytest and httpx, but you need to install them into your virtual environment before you can use them.
- Install the testing packages by running this command:
pip install pytest httpx
What are these packages?
pytest is a testing framework that discovers and runs your test functions automatically. httpx is an HTTP client that FastAPI's TestClient uses internally to simulate requests without starting a real server.
- Confirm the install succeeded by running:
pytest --version
- You should see a version number like pytest 9.0.3 printed in the terminal.
Command not found?
Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt. If not, activate it again, then re-run the install command.
help me fix pytest not being recognized after installing it
Create the test file with fixtures
A fixture is a function that provides setup for your tests. You'll create two: one that resets the database between tests (so each test starts fresh), and one that provides a test client for making HTTP requests.
- Create a new file called test_tasks.py inside the tests/ folder.
- Add the imports and fixture functions by pasting the following code:
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.database import reset_db
@pytest.fixture(autouse=True)
def clean_db():
reset_db()
yield
reset_db()
@pytest.fixture
def client():
return TestClient(app)
What do these fixtures do?
clean_db has autouse=True, which means it runs automatically before and after every test. It calls reset_db() to wipe the in-memory dictionary clean. The yield keyword separates setup (before the test) from teardown (after the test).
client returns a TestClient that simulates HTTP requests to your FastAPI app. Tests receive it by naming it as a function parameter.
- Save tests/test_tasks.py.
- You won't see a change yet. The next chunk adds the actual test functions that use these fixtures.
Write happy-path tests
Happy-path tests verify that everything works when given valid input. Each test follows the same pattern: arrange the data, act by making a request, then assert the response matches expectations.
- In tests/test_tasks.py, add the first test function below the fixtures. This tests the create operation:
def test_create_task(client):
response = client.post("/tasks", json={"title": "Buy groceries"})
assert response.status_code == 201
data = response.json()
assert data["title"] == "Buy groceries"
assert data["status"] == "pending"
assert data["id"] == 1
What does this test do?
client.post("/tasks", json={...}) simulates sending a POST request to your API with a JSON body. The test then checks three things: the status code is 201 (created), the title matches what was sent, and the default status is pending.
Notice how client appears as a function parameter. pytest sees the name matches your fixture and automatically passes the TestClient in.
- Now add the read and filter tests below test_create_task:
def test_get_task(client):
client.post("/tasks", json={"title": "Test task"})
response = client.get("/tasks/1")
assert response.status_code == 200
assert response.json()["title"] == "Test task"
def test_list_tasks(client):
client.post("/tasks", json={"title": "Task 1"})
client.post("/tasks", json={"title": "Task 2"})
response = client.get("/tasks")
assert response.status_code == 200
assert len(response.json()) == 2
def test_filter_tasks_by_status(client):
client.post("/tasks", json={"title": "Task 1", "status": "pending"})
client.post("/tasks", json={"title": "Task 2", "status": "completed"})
response = client.get("/tasks?status=completed")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["title"] == "Task 2"
def test_search_tasks(client):
client.post("/tasks", json={"title": "Buy groceries"})
client.post("/tasks", json={"title": "Read book"})
response = client.get("/tasks?search=buy")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["title"] == "Buy groceries"
What do these tests cover?
test_get_task creates a task first, then retrieves it by ID. Every test starts with an empty database (thanks to the clean_db fixture), so each test must create its own data.
test_filter_tasks_by_status and test_search_tasks exercise the query parameters you built into your GET /tasks endpoint. They verify that your list comprehension filtering logic works correctly.
- Add the update and delete tests below test_search_tasks:
def test_update_task(client):
client.post("/tasks", json={"title": "Old title"})
response = client.put("/tasks/1", json={"title": "New title"})
assert response.status_code == 200
assert response.json()["title"] == "New title"
def test_delete_task(client):
client.post("/tasks", json={"title": "Delete me"})
response = client.delete("/tasks/1")
assert response.status_code == 204
response = client.get("/tasks/1")
assert response.status_code == 404
What does test_delete_task check?
This test uses two assertions. First it verifies the delete returns 204 (no content). Then it tries to fetch the same task and confirms it now returns 404. This proves the task was actually removed from the database.
- Save tests/test_tasks.py.
- You won't see a change yet. The next chunk adds error-case tests, then you'll run the full suite.
Write error-case tests and run pytest
Error-case tests verify that your API handles bad input gracefully. These prove your error handling and validation logic work as expected.
- In tests/test_tasks.py, add these two tests below test_delete_task:
def test_get_task_not_found(client):
response = client.get("/tasks/999")
assert response.status_code == 404
def test_create_task_invalid_title(client):
response = client.post("/tasks", json={"title": ""})
assert response.status_code == 422
What do these error tests verify?
test_get_task_not_found requests a task ID that doesn't exist. Your TaskNotFound exception gets caught by the route and converted into a 404 response.
test_create_task_invalid_title sends an empty string for the title. Your Pydantic model has min_length=1 on the title field, so FastAPI automatically returns a 422 (unprocessable entity) without your route code needing to check it manually.
- Save tests/test_tasks.py.
- Run the full test suite by running this command:
pytest
- You should see output showing all 9 tests passing. Each dot represents one passing test, and the final line reads something like 9 passed in green.
How does pytest find your tests?
pytest automatically discovers test files named test_*.py and collects functions prefixed with test_. You don't need to register them anywhere. Just name them correctly and pytest handles the rest.
Tests failing or not collected?
If you see no tests ran, make sure your file is named test_tasks.py (not tasks_test.py or tests.py) and lives inside the tests/ folder. Also confirm that tests/__init__.py exists (it can be empty).
If a test fails, read the assertion error carefully. It shows the expected value vs. the actual value. Check that your route, service, and model code matches what you wrote in earlier steps.
help me debug my failing pytest tests for my FastAPI task manager API
✔️ Awesome, I've got everything!
Great. Double check that you've saved tests/test_tasks.py and all 9 tests pass when you run pytest.
ⓧ I'd like to double check the full code
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.database import reset_db
@pytest.fixture(autouse=True)
def clean_db():
reset_db()
yield
reset_db()
@pytest.fixture
def client():
return TestClient(app)
def test_create_task(client):
response = client.post("/tasks", json={"title": "Buy groceries"})
assert response.status_code == 201
data = response.json()
assert data["title"] == "Buy groceries"
assert data["status"] == "pending"
assert data["id"] == 1
def test_get_task(client):
client.post("/tasks", json={"title": "Test task"})
response = client.get("/tasks/1")
assert response.status_code == 200
assert response.json()["title"] == "Test task"
def test_get_task_not_found(client):
response = client.get("/tasks/999")
assert response.status_code == 404
def test_list_tasks(client):
client.post("/tasks", json={"title": "Task 1"})
client.post("/tasks", json={"title": "Task 2"})
response = client.get("/tasks")
assert response.status_code == 200
assert len(response.json()) == 2
def test_filter_tasks_by_status(client):
client.post("/tasks", json={"title": "Task 1", "status": "pending"})
client.post("/tasks", json={"title": "Task 2", "status": "completed"})
response = client.get("/tasks?status=completed")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["title"] == "Task 2"
def test_search_tasks(client):
client.post("/tasks", json={"title": "Buy groceries"})
client.post("/tasks", json={"title": "Read book"})
response = client.get("/tasks?search=buy")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["title"] == "Buy groceries"
def test_update_task(client):
client.post("/tasks", json={"title": "Old title"})
response = client.put("/tasks/1", json={"title": "New title"})
assert response.status_code == 200
assert response.json()["title"] == "New title"
def test_delete_task(client):
client.post("/tasks", json={"title": "Delete me"})
response = client.delete("/tasks/1")
assert response.status_code == 204
response = client.get("/tasks/1")
assert response.status_code == 404
def test_create_task_invalid_title(client):
response = client.post("/tasks", json={"title": ""})
assert response.status_code == 422
You now have an automated test suite that proves your API works. Next up, you'll deploy your tested API live on the internet so anyone can use it.
Deploy to Render
Your API is built, tested, and working locally. The final step is getting it live on the internet so anyone can access it.
To deploy, you will push your code to GitHub and connect it to Render. Render pulls your code, installs dependencies, and runs your server on a public URL.
In this step, get ready to:
- Push your project to a GitHub repository.
- Create a Render Web Service connected to GitHub.
- Verify your live API is accessible from a public URL.
Push your project to GitHub
Render deploys code from a Git repository. You need your project on GitHub so Render can pull and build it.
- Confirm all your files are committed by running this command:
git add . && git commit -m "Complete task manager API"
What does this command do?
`git add .` stages all files in your project. `git commit -m` saves them as a snapshot in your local Git history.
If you already committed everything earlier, Git will tell you there is nothing to commit. That is fine.
- Open GitHub in your browser and sign in (or create a free account if you do not have one).
- Click the + icon in the top-right corner.
- Select New repository.
- Enter task-manager-api as the repository name.
- Leave the repository set to Public.
- Do NOT check any of the initialization options (no README, no .gitignore, no license).
- Click Create repository.
Why skip the initialization options?
You already have files locally. If GitHub adds its own README or .gitignore, it creates a merge conflict when you push. Start with an empty remote repository and push your existing code into it.
- GitHub shows a Quick Setup page with your repository URL. Copy the HTTPS URL (it looks like https://github.com/YOUR-USERNAME/task-manager-api.git).
- Back in your terminal, connect your local repo to GitHub by running this command:
git remote add origin [[REPO_URL="https://github.com/YOUR-USERNAME/task-manager-api.git"]]
- Push your code to GitHub by running this command:
git push -u origin main
What does this command do?
`git push -u origin main` uploads your local commits to the main branch on GitHub. The -u flag sets origin main as the default for future pushes.
GitHub may prompt you to authenticate. Follow the prompts to sign in with your browser or enter a personal access token.
- Refresh your GitHub repository page. You should see all your project files (app/, tests/, requirements.txt) listed.
Push rejected or branch error?
If you see an error about main not existing, your local branch might be named master. Run git branch -M main to rename it, then try the push again.
If prompted for a password, GitHub no longer accepts passwords for Git operations. Use a personal access token instead.
Help me troubleshoot this
Create a Render Web Service
Render is a cloud platform that can host your API for free. It pulls code from your GitHub repository, installs dependencies, and starts your server automatically.
- Open render.com and click Get Started to create a free account. No credit card is required.
- Sign up using your GitHub account for the easiest connection.
What is Render?
Render is a hosting platform that deploys web applications directly from Git repositories. Its free tier gives you 750 instance hours per month.
Free services spin down after 15 minutes of inactivity and restart on the next request. The first request after a spin-down takes a few extra seconds.
- In the Render Dashboard, click New in the top-right corner.
- Select Web Service.
- Select Git Provider and connect your GitHub account if prompted. Authorize Render to access your repositories.
- Find your task-manager-api repository in the list.
- Click Connect.
The service creation form appears. Fill in the following fields:
- Set Name to task-manager-api (this becomes part of your public URL).
- Leave Branch as main.
- Set Language to Python.
- Set Build Command to:
pip install -r requirements.txt
- Set Start Command to:
uvicorn app.main:app --host 0.0.0.0 --port $PORT
Why these commands?
The build command installs your project's dependencies from requirements.txt. The start command launches your FastAPI server using uvicorn.
`--host 0.0.0.0` tells uvicorn to accept connections from any address (required for Render to route traffic). $PORT is an environment variable that Render sets automatically.
- Scroll down to Instance Type and select the Free tier.
- Click Create Web Service.
Render starts building your project. You can watch the build logs in real time on the Events page.
Build failing?
Check that your requirements.txt is in the root of your repository (not inside the app/ folder). Render runs the build command from the repository root.
Make sure your start command is uvicorn app.main:app --host 0.0.0.0 --port $PORT with the app.main:app path. This tells uvicorn to find the app object inside app/main.py.
Help me troubleshoot my build
Verify your live deployment
Your API is now live on the internet. Time to confirm it works from the public URL.
- Find your service's public URL at the top of the Render service page. It looks like https://task-manager-api-xxxx.onrender.com.
- Click the URL to open it in your browser. You should see:
{"message": "Task Manager API is running", "docs": "/docs"}
- Add /docs to the end of your URL to open the interactive Swagger documentation.
- In the Swagger UI, expand the POST /tasks endpoint.
- Click Try it out.
- Replace the example request body with:
{
"title": "My first deployed task",
"description": "Created through the live API",
"status": "pending"
}
- Click Execute.
- You should see a 201 response with your task data, including an id and created_at timestamp.
What is the Swagger UI?
FastAPI auto-generates interactive API documentation at the /docs path. Anyone with your URL can explore your endpoints, try requests, and see response schemas without writing any client code.
Seeing a loading screen or timeout?
Free Render services spin down after 15 minutes of inactivity. The first request after a spin-down can take 30-60 seconds while the server restarts. Wait and refresh.
If you see an application error page, check your Render build logs for details. The most common cause is a typo in the start command.
Help me diagnose my deployment issue
Your Task Manager API is live on the internet. Anyone with the URL can create, read, update, and delete tasks. You have built, tested, and shipped a production API from scratch.
Secret mission
Priority System and Statistics
Your API handles tasks, but in the real world, not all tasks are equal. Some are urgent. Some can wait. Can you add a priority system that sorts tasks by urgency, and a statistics endpoint that aggregates your task data into a dashboard-ready summary?
Clean Up Your Resources
Clean Up Your Resources
This project runs on Render's free tier, which spins down after 15 minutes of inactivity. There are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Render Web Service (free tier, auto-sleeps after inactivity).
- GitHub repository containing your project code.
- Local project folder on your Desktop with a Python virtual environment.
Keep everything running
No action needed. Choose this if you are still actively building or want to share your live API URL with others.
- Your Render service automatically sleeps after 15 minutes of inactivity and wakes up on the next incoming request.
- Your GitHub repository stays available for future reference or collaboration.
- Your local project files remain on your Desktop.
Pause - I'll come back to this later
Suspend your Render service so it does not wake on incoming requests. Keep your local files and GitHub repo intact for when you return.
- Open the Render Dashboard at dashboard.render.com.
- Click the checkbox next to your Task Manager API service.
- Click Suspend in the action menu that appears.
- When you are ready to resume, select the service again and click Resume in the action menu.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Delete your Render Web Service:
- Open the Render Dashboard and click on your Task Manager API service.
- Click Settings in the left sidebar.
- Scroll to the bottom of the Settings page and click the delete option to permanently remove the service.
- Delete your GitHub repository:
- Go to your repository on GitHub.
- Click Settings at the top of the repository page.
- Scroll down to the Danger Zone section.
- Click Delete this repository and follow the confirmation prompts.
- Delete your local project files:
macOS
- Remove your project folder by running this command in your terminal:
cd ~/Desktop
rm -rf task-manager-api
Windows
- Remove your project folder by running this command in PowerShell:
cd ~/Desktop
Remove-Item -Recurse -Force task-manager-api
That's a Wrap!
That's a Wrap!
Nice work! You just built, tested, and deployed a production-grade REST API using FastAPI.
Every Python fundamental you learned (classes, lists, dictionaries, loops, conditionals, functions) just powered a live application on the internet. That's the bridge from coursework to professional software.
You've learned how to:
- Build a multi-file Python project with proper separation of concerns, using Pydantic models for validation, a service layer for business logic, and route handlers for HTTP.
- Write automated tests with pytest that prove your code works across both happy paths and error cases.
- Deploy a live REST API to Render that anyone can access and interact with through auto-generated Swagger documentation.
- Added priority sorting and a statistics endpoint for advanced data aggregation across your task collection.
Ready to quiz yourself?