Lesson 5 - Guided Project: Read-Only Task Manager API
Welcome to the Read-Only Task Manager API
You’ve met FastAPI, written your first app, captured values from the URL with path parameters, and read optional settings from the query string. Now you’ll put all of it together into something that actually feels like a product: a Task Manager API. This is the very first version of the project you’ll build for the rest of the course — today it’s read-only and stores its tasks in a plain Python list, but in later modules you’ll let clients create and validate tasks, store them in a real database, lock them behind a login, test everything, and deploy it. Starting small and read-only means you can focus on getting the shape right before adding power.
By the end of this project, you will be able to:
- Build a small API around an in-memory list of task records
- List tasks with optional query-param filters (
done,priority) and alimit - Fetch a single task by id and return a proper
404when it doesn’t exist - Verify the whole API with FastAPI’s
TestClientand try it live in the docs
Let’s build it stage by stage.
Stage 1: The App and the Tasks
Create a file called main.py. Every endpoint we add will live in this one file. We start with two things: the FastAPI application object, and the data it will serve. Because we don’t have a database yet, our “data” is just a Python list of dictionaries — one dictionary per task.
from fastapi import FastAPI, HTTPException
app = FastAPI(title="Task Manager")
TASKS = [
{"id": 1, "title": "Write the report", "done": False, "priority": "high"},
{"id": 2, "title": "Email the team", "done": True, "priority": "low"},
{"id": 3, "title": "Plan the sprint", "done": False, "priority": "high"},
{"id": 4, "title": "Update the docs", "done": True, "priority": "medium"},
]A few things to notice. We import HTTPException alongside FastAPI now, because we’ll need it in Stage 3 to signal “not found.” The title="Task Manager" argument gives the app a name that shows up at the top of the interactive docs page. And each task has four fields: a unique id, a title, a done flag (True or False), and a priority string. That’s our whole “database” for this module.
Right now this app has no endpoints — it would start, but it has nothing to say. Let’s give it a way to list tasks.
Stage 2: Listing Tasks with Filters and a Limit
The first endpoint is GET /tasks, which returns the list of tasks. But a bare list isn’t very useful — a real client wants to ask things like “show me only the unfinished tasks” or “only the high-priority ones.” We offer those as optional query parameters, exactly the pattern from Lesson 4. Add this function to main.py:
@app.get("/tasks")
def get_tasks(done: bool | None = None, priority: str | None = None, limit: int = 10):
results = TASKS
if done is not None:
results = [t for t in results if t["done"] == done]
if priority is not None:
results = [t for t in results if t["priority"] == priority]
return results[:limit]Read the parameters carefully, because each one does a specific job:
done: bool | None = None— an optional filter. If the client leaves it out, it staysNoneand we skip the filter. If they pass?done=false, FastAPI parses that text into the Python booleanFalse, and we keep only the matching tasks.priority: str | None = None— the same idea for the priority string.limit: int = 10— a cap on how many tasks come back. It defaults to10, so by default the client sees everything we have. We apply it last, withresults[:limit], which is just a list slice.
The logic is a small pipeline: start with all tasks, narrow them down for each filter that was actually provided, then trim to the limit. Because the filters are independent if blocks, the client can combine them freely.
Here’s what each request returns, confirmed by running the API:
GET /tasks -> all 4 tasks (list length 4)
GET /tasks?done=false -> ['Write the report', 'Plan the sprint']
GET /tasks?priority=high -> ['Write the report', 'Plan the sprint']
GET /tasks?done=false&priority=high -> ['Write the report', 'Plan the sprint']
GET /tasks?limit=2 -> ['Write the report', 'Email the team']Notice that combining done=false and priority=high applies both filters in sequence — you get only the tasks that are both unfinished and high priority. That’s the power of optional query parameters: one endpoint quietly supports many different questions.
Stage 3: Fetching One Task by Id (with a 404)
Listing is half of a read-only API; the other half is fetching a single item. For that we use a path parameter — the id is part of the URL itself, as in GET /tasks/1. We loop through our tasks, return the one whose id matches, and if none match, we tell the client the task doesn’t exist. Add this to main.py:
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
for t in TASKS:
if t["id"] == task_id:
return t
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")The task_id: int hint means FastAPI parses the URL segment into an integer for us — a request to /tasks/banana is rejected before our code even runs. Inside the function, we scan the list for a matching id and return it the moment we find one. If the loop finishes without finding anything, we reach the last line and raise HTTPException.
That raise is the important new idea. Returning a value means “success — here’s the data.” Raising an HTTPException means “stop, and send the client an error response instead.” We set status_code=404 (the standard HTTP code for “not found”) and a detail message, and FastAPI turns that into a proper JSON error response. This is far better than returning an empty body or, worse, crashing — the client gets a clear, machine-readable signal that the task isn’t there.
Here are both outcomes, confirmed by running the API:
GET /tasks/1 -> {'id': 1, 'title': 'Write the report', 'done': False, 'priority': 'high'}
GET /tasks/99 -> status 404, body {'detail': 'Task 99 not found'}A found task comes back as a clean JSON object; a missing one comes back as a 404 with a helpful message. That’s exactly the behavior a well-behaved API should have.
This data lives in memory — for now
Our TASKS list lives in the program’s memory, so every change disappears when the server restarts — and there’s no way to add or edit tasks yet anyway, since this version is read-only. That’s intentional: it keeps Module 1 focused on the request/response shape without the distraction of a database. In Module 5 you’ll swap this list for a real database so tasks persist, and along the way you’ll add the ability to create, update, and delete them. The endpoints you’re writing today are the foundation everything else attaches to.
Stage 4: Verifying the API
Before you trust an API, prove it works. FastAPI ships with a TestClient that calls your endpoints in-process — no server, no network, just fast, repeatable checks. Create a second file, test_main.py, next to main.py:
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_list_all_tasks():
response = client.get("/tasks")
assert response.status_code == 200
assert len(response.json()) == 4
def test_filter_not_done():
response = client.get("/tasks?done=false")
titles = [t["title"] for t in response.json()]
assert titles == ["Write the report", "Plan the sprint"]
def test_filters_combine():
response = client.get("/tasks?done=false&priority=high")
titles = [t["title"] for t in response.json()]
assert titles == ["Write the report", "Plan the sprint"]
def test_get_one_task():
response = client.get("/tasks/1")
assert response.status_code == 200
assert response.json()["title"] == "Write the report"
def test_missing_task_returns_404():
response = client.get("/tasks/99")
assert response.status_code == 404
assert response.json() == {"detail": "Task 99 not found"}Each test sends a request and asserts on the response — both the status code and the body. The last test is the most valuable: it confirms that asking for a task that doesn’t exist returns a 404 with the right message, not a crash. These five tests together describe the entire behavior of your API in a way you can re-run any time you change the code.
Running it for real
Tests prove the logic; now see it live. From the folder containing main.py, start the development server:
uvicorn main:app --reloadmain:app means “the app object inside main.py,” and --reload restarts the server automatically whenever you save a change. Once it’s running, open your browser to:
http://127.0.0.1:8000/docsThat’s the interactive documentation FastAPI built for you. You’ll see both endpoints listed under the “Task Manager” title. Expand GET /tasks, click Try it out, fill in done or priority (or leave them blank), and hit Execute to watch the filtered results come back. Then try GET /tasks/{task_id} with 1 to see a task, and with 99 to see your 404 in action. You wrote no documentation code — the docs come straight from your function signatures.
Extend the Project
The Task Manager is yours to grow. Try these on your own copy of main.py.
Exercise 1: Add a count summary endpoint
Add a GET /tasks/count endpoint that returns a small summary like {"total": 4, "done": 2, "not_done": 2} instead of the full list. This is handy for dashboards that just need the numbers.
Hint
You don’t need any parameters. Compute total = len(TASKS) and done = len([t for t in TASKS if t["done"]]), then return a dictionary: return {"total": total, "done": done, "not_done": total - done}. One detail to watch: define this route before GET /tasks/{task_id}, or FastAPI may try to read “count” as a task_id.
Exercise 2: Filter by a second field
Add an optional title query parameter to GET /tasks that returns only tasks whose title contains the given text, so ?title=team matches “Email the team.”
Hint
Add title: str | None = None to the function signature, then another filter block: if title is not None: results = [t for t in results if title.lower() in t["title"].lower()]. Using .lower() on both sides makes the search case-insensitive.
Exercise 3: Sort the results
Add an optional sort query parameter that, when set to "title", returns the tasks sorted alphabetically by title before the limit is applied.
Hint
Add sort: str | None = None, and just before the return, write: if sort == "title": results = sorted(results, key=lambda t: t["title"]). Sorting these four tasks by title gives ['Email the team', 'Plan the sprint', 'Update the docs', 'Write the report']. Apply the sort before the results[:limit] slice so you keep the right items.
Summary
You combined everything from Module 1 into your first real API. You built main.py around an in-memory list of task dictionaries, wrote GET /tasks that supports optional done and priority filters plus a limit, and wrote GET /tasks/{task_id} that returns one task by id or raises an HTTPException with a 404 when it isn’t found. Then you verified the whole thing with TestClient and learned to run it live with uvicorn and explore it in the /docs page. This read-only version is the seed of the project you’ll grow for the rest of the course.
Key Concepts
- In-memory data — a plain Python list of dicts stands in for a database; it resets on restart.
- Optional query filters — parameters that default to
Noneare skipped unless the client provides them. - Combining filters — independent
ifblocks let one endpoint answer many questions. HTTPException— raise it to send a proper error response (like404) instead of returning data.TestClient— calls your endpoints in-process so you can assert on status codes and JSON.
Why This Matters
Almost every API you’ll ever build comes down to these same moves: list a collection with filters, fetch one item by id, and respond cleanly when something isn’t there. By assembling them into one coherent project — rather than isolated examples — you now have a mental template you can reuse for any resource, whether it’s tasks, users, products, or orders. And because this exact code is the foundation the rest of the course builds on, every new skill ahead will feel like extending something you already understand.
Next Steps
Continue to Module 2 - Request Bodies and Pydantic
So far clients can only read tasks. Next you'll let them create tasks by sending data in the request body, and use Pydantic to validate every field automatically.
Back to Module Overview
Return to the Getting Started with FastAPI module overview
Continue Building Your Skills
You’ve shipped the first version of your Task Manager API — it lists tasks, filters them, fetches them by id, and fails gracefully when one is missing. So far everything is read-only. In the next module you’ll open it up: clients will be able to create tasks by sending JSON, and Pydantic will check every field for you before it ever touches your data. The project keeps growing, and so do you.