Build a Task Tracker API with FastAPI

Build a FastAPI task tracker with CRUD, validation, docs, and a Python client.

Introduction

30 Second Summary

When you tick off a task, the app sends that change somewhere. A useful system returns a clear answer even when the request is incomplete.

In this project, you will build a local Task Tracker API with FastAPI. You will use interactive documentation plus a separate Python client to watch one task move through its full lifecycle.

What You'll Build

You will watch a task move from creation to deletion through calls from Safari plus a separate Python client.

By the end of this project, you'll have:

  • A live local API that creates a task from a JSON request body. You will see the generated ID in its response.
  • Interactive API documentation that lets you call every route. You will inspect parameters plus response status codes.
  • A complete CRUD lifecycle that a standalone Python client runs from creation through deletion. Its printed statuses show each request succeeded.
  • Secret Mission: Add validated task priorities plus a priority filter to the API.

Are there any prerequisites?

This project assumes basic Python familiarity.

Your Mac needs Visual Studio Code, Terminal, Safari, plus Python 3.10 or newer installed. Step 1 checks your version before installing the project packages.

Before We Start

This opening checkpoint locks in the local Task Tracker API you are about to build. It also connects the project to why creating and calling HTTP APIs matters to you.

Prepare the Python API Workspace

Your Task Tracker API depends on a supported Python runtime. An isolated package environment keeps its dependencies from changing other Python projects.

In this step, you will verify Python 3.10 or newer in Terminal. You will prepare the task-tracker-api folder in Visual Studio Code with a virtual environment containing FastAPI 0.142.2 plus Pydantic 2.13.5.

In this step, get ready to:
  • Verify that Python 3.10 or newer is installed.
  • Prepare an isolated workspace inside the task-tracker-api folder.
  • Install the pinned FastAPI and Pydantic packages inside the virtual environment.
Verify your Python version

FastAPI 0.142.2 requires Python 3.10 or newer. The version check tells you whether this Mac can run the pinned framework.

  • Press Cmd+Space to open Spotlight Search.
  • Type Terminal into the search field.
  • Press Enter to open Terminal.
  • Check the installed Python version by running this command:
python3 --version

What does this command check?

The --version option asks Python to print its installed version. This confirms whether the runtime meets FastAPI's minimum requirement.

Terminal prints a Python version number. Choose the tab that matches your result.

✔️ I see Python 3.10 or newer

Your runtime is ready. Python 3.10 or newer can run this project.

ⓧ I see an older Python version

The installed runtime is older than FastAPI's requirement. Updating Python closes that compatibility gap before package installation.

  • Visit the official Python macOS downloads page.
  • Download the macOS installer for Python 3.14.8.
  • Complete the macOS installation prompts.
  • Quit Terminal after the installer finishes.
  • Press Cmd+Space to reopen Spotlight Search.
  • Type Terminal into the search field.
  • Press Enter to reopen Terminal.
  • Confirm the updated Python version by running this command:
python3 --version

What does this check confirm?

This second check confirms that the reopened Terminal session can find the updated Python installation. You should now see Python 3.14.8.

Still seeing the older version?

Close every Terminal window before opening a new session. This lets the shell discover the updated Python installation.

If the older version remains, help me find why macOS still uses my older Python version.

ⓧ Command not found

Terminal cannot find a Python installation through its command path. Installing the current macOS release provides the runtime this project needs.

  • Visit the official Python macOS downloads page.
  • Download the macOS installer for Python 3.14.8.
  • Complete the macOS installation prompts.
  • Quit Terminal after the installer finishes.
  • Press Cmd+Space to reopen Spotlight Search.
  • Type Terminal into the search field.
  • Press Enter to reopen Terminal.
  • Confirm that Terminal can now find Python by running this command:
python3 --version

What does this check confirm?

The command now asks the installed Python executable for its version. You should see Python 3.14.8.

Still unable to find Python?

Confirm that the macOS installer completed before reopening Terminal. A Terminal window left open during installation may still use the previous command path.

If the command remains unavailable, help me make python3 available in macOS Terminal.

Create the isolated project workspace

A named project folder gives every file a predictable home. Starting from your Desktop also makes the folder easy to find from Visual Studio Code.

  • Switch back to the Terminal window from the version check.
  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

The cd command changes Terminal's current folder. The ~ symbol represents your home folder.

  • Create the task-tracker-api folder by running these commands:
mkdir task-tracker-api
cd task-tracker-api

What do these commands do?

  • The mkdir task-tracker-api command creates the project folder on your Desktop.
  • The cd task-tracker-api command moves Terminal into that new folder.
  • Confirm that Terminal is inside the project folder by running this command:
pwd

What does this command show?

The pwd command prints Terminal's current folder path. This confirms where later environment commands create their files.

You should see a path ending in /Desktop/task-tracker-api.

  • Press Cmd+Space to open Spotlight Search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.
  • Click File in the top menu bar.
  • Select Open Folder....
  • Select Desktop in the folder picker.
  • Select the task-tracker-api folder.
  • Click Open.
  • Choose to trust the folder if Visual Studio Code displays a Workspace Trust dialog.

You should see task-tracker-api at the top of the Explorer sidebar. The folder is now your Visual Studio Code workspace.

A requirements file records the exact packages that belong to this project. Pinning each version makes the environment reproducible.

  • Click the Explorer view in the Activity Bar.
  • Click the New File... button in the Explorer toolbar.
  • Enter requirements.txt as the file name.
  • Press Enter to create the file.
  • Add the pinned dependencies by pasting this content into requirements.txt:
fastapi[standard]==0.142.2
pydantic==2.13.5

What do these requirements define?

  • The fastapi[standard] package installs FastAPI with its documented command-line tooling and server support.
  • The ==0.142.2 pin selects the exact FastAPI version for this project.
  • The pydantic==2.13.5 line installs the exact Pydantic version used for task validation.
  • Press Cmd+S to save requirements.txt.
  • Confirm that requirements.txt appears beneath task-tracker-api in the Explorer sidebar.

Cannot find requirements.txt?

Check that the Explorer heading shows task-tracker-api. Opening a different folder places the file outside this project.

If the file still does not appear, help me create requirements.txt in my Visual Studio Code workspace.

The virtual environment stores this project's Python packages inside .venv. Activating it makes later Python commands use that isolated package set.

  • Switch back to the Terminal window inside task-tracker-api.
  • Create the virtual environment by running this command:
python3 -m venv .venv

What does this command create?

Python's venv module creates an isolated environment in the .venv folder. Packages installed there stay scoped to this project.

  • Return to Visual Studio Code after the command finishes.
  • Confirm that the Explorer sidebar now shows a .venv folder.
  • Switch back to Terminal.
  • Activate the virtual environment by running this command:
source .venv/bin/activate

What does activation change?

The activation script updates the current Terminal session to use the environment's Python tools. Future package installations now go into .venv.

  • Confirm that the Terminal prompt now includes .venv before your usual prompt text.

Do not see .venv in the prompt?

Run the activation command from inside task-tracker-api. The path only works when Terminal is in the folder that contains .venv.

If activation still fails, help me activate .venv in macOS Terminal.

Install and verify the pinned packages

The active environment is ready to receive the dependencies listed in requirements.txt. Installing from that file keeps your local packages aligned with the project code.

  • Install the project dependencies into the active environment by running this command:
python -m pip install -r requirements.txt

What does this command install?

The -r option tells pip to read package specifications from requirements.txt. The active environment keeps those packages inside .venv.

Terminal prints download and installation progress for FastAPI, Pydantic, and their supporting packages.

Seeing an installation error?

Confirm that .venv appears in the Terminal prompt. Also confirm that requirements.txt is saved inside task-tracker-api.

A network failure can also interrupt package downloads. If the installation still fails, help me troubleshoot my FastAPI requirements installation.

Before you run the final check, which two pinned versions do you expect Terminal to list?

  • Verify the packages installed inside the active environment by running this command:
python -m pip list

What does this list prove?

The package list shows what the active Python environment can import. Finding both pinned versions confirms that the installation used the correct .venv.

You should see a row for fastapi with version 0.142.2. You should also see a row for pydantic with version 2.13.5.

That is the workspace ready. Your project now has the exact framework and validation library it needs.

Missing a pinned package?

Check that .venv still appears in the Terminal prompt. Run the installation command again if either package is absent.

If the listed versions differ, confirm that requirements.txt matches the reference below. You can also ask for help checking the active environment.

✔️ Awesome, I've got everything!

Your requirements.txt file is saved. Your active environment contains both pinned packages.

ⓧ I'd like to double check the full code

Compare your complete requirements.txt file with this reference.

fastapi[standard]==0.142.2
pydantic==2.13.5

Your Python workspace is isolated and ready for API development. Next, you will serve your first JSON response from the Task Tracker API.

Serve Your First JSON Response

Your isolated Python workspace is ready. Now you need proof that your API can answer a real HTTP request.

In this step, FastAPI connects a Python function to a route. You will call that route from Safari before inspecting the same JSON exchange in interactive documentation.

In this step, get ready to:
  • Create a main.py application with one root route.
  • Run the FastAPI development server.
  • Verify the root response through Safari plus interactive documentation.
Create the root API file

The app variable represents your API. Its first path operation connects a request for / to the read_root() function.

  • In Visual Studio Code, select Explorer in the Activity Bar.
  • Select New File... in the Explorer view.
  • Enter main.py and press Enter.

You will see main.py in the Explorer view. The empty file opens in the editor.

  • Build the FastAPI app plus its root path operation by pasting this code into main.py:
from fastapi import FastAPI

app = FastAPI(title="Task Tracker API")


@app.get("/")
def read_root() -> dict[str, str]:
    return {"message": "Task Tracker API is running"}

What does this code do?

  • The FastAPI import provides the class used to create the application.
  • The app variable stores the application with the title Task Tracker API.
  • The @app.get("/") decorator connects root GET requests to read_root().
  • The returned Python dictionary becomes the JSON response sent to the client.
  • Save main.py by pressing Cmd+S on macOS.
  • Confirm the main.py editor tab no longer shows its unsaved-change indicator.

Does the file look different?

  • Check that the filename is exactly main.py inside the task-tracker-api folder.
  • Check that the indented return line sits inside read_root().
  • Help me compare my main.py file with the required FastAPI root route.

✔️ Awesome, I've got everything!

Your main.py file now defines the application plus its first route.

ⓧ I'd like to double check the full code

This is the exact main.py file for this step.

from fastapi import FastAPI

app = FastAPI(title="Task Tracker API")


@app.get("/")
def read_root() -> dict[str, str]:
    return {"message": "Task Tracker API is running"}

How this file fits together

This structure gives the development server an app variable to discover. It also gives the server one function to run when a client requests /.

Run the development server

A development server listens for local requests while you build the API. The server stays active in Terminal so Safari can reach your Python code.

  • Switch back to the Terminal window from the previous step.
  • Confirm that .venv still appears at the start of the Terminal prompt.
  • Start the FastAPI development server by running this command:
fastapi dev

What does this command do?

The command finds the app variable in main.py. It starts a local server that reloads when you save code changes.

The process keeps control of this Terminal window while the server runs. Keep it active for the rest of the step.

You will see the local server address http://127.0.0.1:8000 in the Terminal output. Your API is now waiting for requests.

  • Identify the server address by finding http://127.0.0.1:8000 in the Terminal output.
  • Leave the server process running in this Terminal window.

Did the server fail to start?

  • Return to the Terminal window where the .venv environment is active if the command is unavailable.
  • Check that main.py is saved directly inside task-tracker-api if the application cannot be found.
  • Help me troubleshoot why fastapi dev cannot start my main.py application.
Call the API in Safari

Safari acts as the client for your first request. The server receives the requested path before returning the value from read_root().

  • Click the search icon in the macOS menu bar.
  • Type Safari and press Return to open the browser.

Before you load the address, what response do you expect the root route to return?

  • Enter http://127.0.0.1:8000/ in the Smart Search field.
  • Press Return to call the root route.

You will see {"message":"Task Tracker API is running"} in Safari. That is your first endpoint working.

The interactive documentation provides a second way to call the same route. It also separates the request details from the returned response.

  • Enter http://127.0.0.1:8000/docs in the Smart Search field.
  • Press Return to load the interactive documentation.
  • Expand the operation showing GET with the path /.

Before you send the documented request, which status code do you expect a successful root call to return?

  • Click Try it out inside the expanded root operation.
  • Click Execute to send the request.

The request targets http://127.0.0.1:8000/. The response status is 200.

The response body matches {"message":"Task Tracker API is running"}. Both Safari views reached the same root route.

What just happened?

Safari sent a GET request for /. FastAPI matched that path to read_root().

The function returned a Python dictionary. FastAPI serialized that value into the JSON response shown by both clients.

Cannot see the root response?

  • Confirm that the Terminal window still shows the running development server.
  • Check that the root address ends with / while the documentation address ends with /docs.
  • Help me troubleshoot why Safari cannot reach my local FastAPI root route or interactive documentation.

Your first JSON route is live through both a browser request and interactive documentation. Next, you will add in-memory task creation to see how an API changes data.

Create and List Unvalidated Tasks

Your FastAPI server already answers GET / with a JSON health message. The next milestone lets clients create task resources.

The quickest first version accepts each request body as a Python dictionary. An in-memory dictionary keeps the resulting tasks available to later requests.

How much protection does that loose contract provide? You will test its guarantees after the create and list operations work.

In this step, get ready to:
  • Add in-memory storage for task dictionaries.
  • Create routes that store tasks and return the stored collection.
  • Test which task requirements the dictionary request body enforces.
Add in-memory task storage

The tasks dictionary holds each task while the server process runs. The next_id counter provides the number for the next task.

  • In main.py, find app = FastAPI(title="Task Tracker API").
  • Insert the storage variables below that line by pasting this code:
tasks: dict[int, dict] = {}
next_id = 1

How Does the Storage Work?

  • The key in tasks is an integer ID.
  • The value in tasks is the dictionary received from a client.
  • The initial next_id value makes the first generated ID 1.
  • Save main.py.
  • Return to the root API page in Safari.
  • Refresh the page.

You should still see {"message":"Task Tracker API is running"}. This confirms that the new storage variables load without breaking the existing route.

Did the Root Page Stop Loading?

  • Check that both storage lines start at the far left of main.py.
  • Confirm that next_id = 1 uses an underscore in the variable name.
  • Ask for help with the storage error in your FastAPI app.
Add create and list routes

An HTTP route connects a method with a path. The first new route receives one dictionary through POST /tasks.

  • In main.py, find the closing line of read_root().
  • Add the task creation route below read_root() by pasting this code:
@app.post("/tasks")
def create_task(task: dict) -> dict:
    global next_id
    task["id"] = next_id
    tasks[next_id] = task
    next_id += 1
    return task

What Does the Create Route Do?

  • The task parameter receives the request body as a dictionary.
  • The global next_id line lets the function advance the shared counter.
  • The function adds the generated ID to the received dictionary.
  • The function stores the task under that ID before returning it.
  • Save main.py.
  • Return to the http://127.0.0.1:8000/docs tab in Safari.
  • Refresh the interactive documentation.

You should see POST /tasks alongside the existing root operation. The running fastapi dev process has loaded your new route.

Is the Create Route Missing?

  • Confirm that you saved main.py before refreshing the documentation.
  • Check that @app.post("/tasks") starts at the far left of the file.
  • Ask for help if the create route does not appear.

The second route returns every stored task as a list. Clients call it through GET /tasks.

  • Add the listing route below create_task() by pasting this code:
@app.get("/tasks")
def list_tasks() -> list[dict]:
    return list(tasks.values())

What Does the List Route Do?

  • The tasks.values() expression selects the stored task dictionaries.
  • The list() call converts those values into a JSON array for the response.
  • Save main.py.
  • Refresh the interactive documentation in Safari.

You should now see separate operations for POST /tasks and GET /tasks.

The two routes are ready. Start by sending a normal task through the create operation.

  • Expand the POST /tasks operation.
  • Select Try it out.
  • Replace the request body with this JSON:
{"title": "Learn API basics"}

What Does This Request Contain?

This JSON object contains one title field. Its value describes the task you want the API to store.

  • Select Execute.

You should see a 200 response. Its body contains the title plus a generated id.

  • Expand the GET /tasks operation.
  • Select Try it out.
  • Select Execute.

Your first resource loop now works. The response array contains the task created through the separate POST request.

Is the Task List Empty?

  • Run the POST /tasks operation again before calling GET /tasks.
  • Avoid saving main.py between the two requests because a development reload clears the in-memory dictionary.
  • Ask for help if the created task is missing from the list.
Expose the validation gap

The create route works for a normal task. Now you can test whether the dictionary contract requires any task fields.

Before you send the next request, do you think the route will reject a body with no task fields?

  • Return to the POST /tasks operation.
  • Select Try it out.
  • Replace the request body with this empty JSON object:
{}

What Does This Body Represent?

The braces form an empty JSON object. The request supplies no title or completion value.

  • Select Execute.

You will see a 200 response. The returned object contains only a generated id.

Why Was the Empty Task Accepted?

The dict annotation asks for a JSON object. It defines no required task fields.

JSON validity covers the body structure. Task validity requires explicit rules for fields such as title.

  • Return to the GET /tasks operation.
  • Select Try it out.
  • Select Execute.

You should see the normal task plus the id-only task in the response array. This visible gap proves that the current API stores structurally incomplete task data.

✔️ Awesome, I've got everything!

Your main.py file now stores dictionaries in memory. Its create and list routes are visible in the interactive documentation.

ⓧ I'd like to double check the full code

Compare your main.py file with this complete version for the current step.

from fastapi import FastAPI

app = FastAPI(title="Task Tracker API")


tasks: dict[int, dict] = {}
next_id = 1


@app.get("/")
def read_root() -> dict[str, str]:
    return {"message": "Task Tracker API is running"}


@app.post("/tasks")
def create_task(task: dict) -> dict:
    global next_id
    task["id"] = next_id
    tasks[next_id] = task
    next_id += 1
    return task


@app.get("/tasks")
def list_tasks() -> list[dict]:
    return list(tasks.values())

You have created a working API with a visible validation gap. Next, you will define task schemas that reject incomplete input and document the expected response shape.

Add Schemas and Error Handling

Your API can create tasks and list them from memory. However, the empty task from the previous step exposed a serious gap because the API accepted malformed data.

You will close that gap with Pydantic schemas. You will also give your FastAPI routes documented response shapes plus a clear error for missing tasks.

In this step, get ready to:
  • Define schemas for creating, updating, and returning tasks.
  • Validate task creation and document successful responses.
  • Retrieve one task by its ID or return a clear error.
Define your task schemas

A schema describes the fields that a request or response may contain. Pydantic uses these schemas to validate incoming JSON before your route changes any stored data.

  • Switch back to main.py in Visual Studio Code.
  • Replace the existing FastAPI import with the two import lines below:
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field

What do these imports provide?

  • HTTPException lets a route stop and return a deliberate client error.
  • status provides named HTTP status constants for route decorators.
  • BaseModel provides the foundation for each task schema.
  • Field adds constraints to individual values.
  • Find the line that creates app near the top of main.py.
  • Paste the following schema classes directly below that line:
class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=100)
    completed: bool = False


class TaskUpdate(BaseModel):
    title: str | None = Field(default=None, min_length=1, max_length=100)
    completed: bool | None = None


class Task(TaskCreate):
    id: int

What do these schemas control?

  • TaskCreate requires a title between 1 and 100 characters. It gives new tasks a default completed value of False.
  • TaskUpdate makes both fields optional. A future partial update can send only the values that need to change.
  • Task inherits the creation fields. It adds the generated id returned by the API.
  • Save main.py.
  • Switch to the Terminal window from earlier.

You will see the development server reload and finish starting again. This confirms that Python accepted the new schema definitions.

Does the server fail to reload?

  • Check that both import lines appear above the app definition.
  • Check that each field inside the three classes is indented by four spaces.
  • Help me fix my task schema error.
Validate create and list responses

The route still accepts a plain dictionary until you connect it to TaskCreate. Its response also needs a declared Task shape so clients know exactly what a successful creation returns.

  • Find the line tasks: dict[int, dict] = {} below the schema classes.
  • Replace that line with tasks: dict[int, Task] = {}.
  • Delete the existing create_task route from its decorator through its return statement.
  • Replace the deleted route by pasting this code:
@app.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
def create_task(task: TaskCreate) -> Task:
    global next_id
    new_task = Task(id=next_id, **task.model_dump())
    tasks[next_id] = new_task
    next_id += 1
    return new_task

How does creation work now?

  • TaskCreate makes FastAPI validate the request body before the function runs.
  • task.model_dump() converts the validated input into field values for the stored task.
  • Task combines those values with the generated ID.
  • status.HTTP_201_CREATED makes a successful creation return status 201.
  • Save main.py.
  • Switch back to the interactive documentation in Safari.
  • Refresh the documentation page.
  • Expand the POST /tasks operation.
  • Replace its request body with this empty JSON object:
{}

Why test an empty object again?

This is the same malformed body that the previous route accepted. Running it again reveals whether the new schema closes that validation gap.

Before you run the operation, do you think the route function will receive this empty object?

  • Run the POST /tasks operation with the empty object.

You will see a validation error that identifies title as missing. The request is rejected before create_task can store it.

  • Replace the request body with this valid task:
{"title": "Learn API basics"}

What will the schema add?

The body supplies the required title. The schema supplies the default completed value before the task is stored.

Before you run the operation, which fields do you expect the response to contain?

  • Run the POST /tasks operation with the valid task.

You will see status 201 with id, title, and completed in the response. That is the validation gap closed: your create route now protects the task contract.

  • Delete the existing list_tasks route at the bottom of main.py.
  • Replace the deleted route by pasting this code:
@app.get("/tasks", response_model=list[Task])
def list_tasks() -> list[Task]:
    return list(tasks.values())

What does the response model add?

The list[Task] response model declares that every item in the returned list follows the task schema. FastAPI uses that contract in the interactive documentation.

  • Save main.py.
  • Switch to the Terminal window from earlier.

You will see another successful server reload. The reload starts a fresh process, so the tasks stored only in memory are reset.

  • Return to the interactive documentation in Safari.
  • Refresh the documentation page.
  • Use POST /tasks to create the valid task again.
  • Run the GET /tasks operation.

You will see status 200 with a list containing the task you created. Every returned item now follows the documented Task shape.

Does task creation still accept an empty object?

  • Check that the create_task parameter is typed as TaskCreate.
  • Refresh the documentation after saving main.py so it uses the reloaded route definition.
  • Help me diagnose why task validation is not running.
Return one task or a 404 error

A path parameter places a resource identifier inside the URL. The new route will validate task_id as an integer before looking for that task in memory.

A missing ID needs an explicit HTTP error. Returning status 404 tells a client that the requested task does not exist.

  • Find the list_tasks function at the bottom of main.py.
  • Paste the following route directly below it:
@app.get("/tasks/{task_id}", response_model=Task)
def get_task(task_id: int) -> Task:
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    return tasks[task_id]

How does this route handle both outcomes?

  • task_id: int makes FastAPI convert and validate the value from the URL.
  • if task_id not in tasks checks whether the requested resource exists.
  • HTTPException returns status 404 with the detail Task not found.
  • return tasks[task_id] sends the stored task when the ID exists.
  • Save main.py.
  • Switch to the Terminal window from earlier.

You will see the server reload successfully. The new path operation is now available in the interactive documentation.

  • Return to the interactive documentation in Safari.
  • Refresh the documentation page.
  • Use POST /tasks to create a valid task.
  • Record the generated ID here: your generated task ID.
  • Expand the GET /tasks/{task_id} operation.
  • Enter your generated task ID in the path parameter field.
  • Run the operation.

You will see status 200 with the matching task. This proves that the path value reaches the route as an integer and selects one stored resource.

Before you try a missing ID, do you think the route will return an empty object or a client error?

  • Replace the path parameter value with 999.
  • Run the operation again.

You will see status 404 with the detail Task not found. You now have separate contracts for malformed input and a missing resource.

Does the missing ID cause a server error?

  • Check that raise HTTPException is indented inside the missing-task condition.
  • Check that HTTPException appears in the FastAPI import at the top of main.py.
  • Help me fix my missing-task error response.

✔️ Awesome, I've got everything!

Your schemas, validated create route, documented list response, and missing-task error are working. Save main.py one more time before moving on.

ⓧ I'd like to double check the full code

Compare your main.py with this complete version for the current step.

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field

app = FastAPI(title="Task Tracker API")


class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=100)
    completed: bool = False


class TaskUpdate(BaseModel):
    title: str | None = Field(default=None, min_length=1, max_length=100)
    completed: bool | None = None


class Task(TaskCreate):
    id: int


tasks: dict[int, Task] = {}
next_id = 1


@app.get("/")
def read_root() -> dict[str, str]:
    return {"message": "Task Tracker API is running"}


@app.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
def create_task(task: TaskCreate) -> Task:
    global next_id
    new_task = Task(id=next_id, **task.model_dump())
    tasks[next_id] = new_task
    next_id += 1
    return new_task


@app.get("/tasks", response_model=list[Task])
def list_tasks() -> list[Task]:
    return list(tasks.values())


@app.get("/tasks/{task_id}", response_model=Task)
def get_task(task_id: int) -> Task:
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    return tasks[task_id]

This file now contains the exact schemas and routes introduced in this step. It does not include filtering, updates, deletion, or the external client yet.

Your requirements.txt file remains unchanged:

fastapi[standard]==0.142.2
pydantic==2.13.5

These pins keep the project on the FastAPI and Pydantic versions installed in your active virtual environment.

Your API now enforces task data and returns useful errors for missing resources. Next, you will add filtering, partial updates, deletion, and a separate Python client that calls the complete lifecycle.

Complete and Call the CRUD API

Your FastAPI service now validates new tasks. It also returns a clear error when a requested task does not exist.

A complete CRUD lifecycle lets an HTTP client create, read, update, filter, and delete resources. In this step, you will finish that lifecycle and call it from a separate Python program.

In this step, get ready to:
  • Finish the missing CRUD behavior in main.py.
  • Build a standalone HTTP client in client.py.
  • Run the complete task lifecycle from a second terminal.
Filter and update tasks

A query parameter narrows a collection without changing the route path. The optional completed value lets clients request tasks with one completion state.

  • In main.py, find the existing list_tasks function.
  • Replace its decorator and function with the following code:
@app.get("/tasks", response_model=list[Task])
def list_tasks(completed: bool | None = None) -> list[Task]:
    task_list = list(tasks.values())
    if completed is None:
        return task_list
    return [task for task in task_list if task.completed == completed]

What Does This Filter Do?

  • The optional completed parameter becomes a query parameter because it does not appear in the route path.
  • A request without the parameter returns every stored task.
  • A request with the parameter returns tasks whose completed value matches it.
  • Save main.py.
  • Return to the interactive documentation in Safari.
  • Expand POST /tasks.
  • Enter a fresh task by replacing the request body with this JSON:
{"title": "Learn API basics"}

What Does This Request Body Do?

The JSON body supplies the required title. The model gives the task its default incomplete state.

  • Run the create request.

Good progress. The response contains a generated ID with the title you supplied.

  • Expand GET /tasks.
  • Set the completed query parameter to true.
  • Run the list request.

The response is an empty JSON array. This proves the filter excludes the incomplete task.

Filter Returning the Wrong Tasks?

  • Check that the filter compares task.completed with the completed parameter.
  • Check that the unfiltered path returns task_list when completed is None.
  • Help me troubleshoot my completed task filter.

Filtering selects existing resources. A PATCH operation changes only the fields supplied by the client.

  • Switch back to main.py.
  • Add the following route below get_task:
@app.patch("/tasks/{task_id}", response_model=Task)
def update_task(task_id: int, changes: TaskUpdate) -> Task:
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    stored_task = tasks[task_id]
    update_data = changes.model_dump(exclude_unset=True)
    updated_task = stored_task.model_copy(update=update_data)
    tasks[task_id] = updated_task
    return updated_task

What Does the Update Route Do?

  • The route checks that the requested task exists before attempting an update.
  • The Pydantic method model_dump(exclude_unset=True) keeps only the fields supplied by the client.
  • The model_copy(update=update_data) call creates the updated task while preserving fields the client omitted.
  • The final assignment stores the updated task under the same ID.
  • Save main.py.
  • Return to the interactive documentation in Safari.
  • Use POST /tasks to create a fresh task.
  • Capture the returned ID as the created task ID.
  • Expand PATCH /tasks/{task_id}.
  • Enter the created task ID in the path parameter field.
  • Remove the title field from the request body.
  • Set the completed field to true.
  • Run the update request.

The response shows the same task ID with completed set to true. Your API can now apply a partial update without losing the title.

Update Request Not Working?

  • Check that the path uses the ID returned by your latest create request.
  • Check that exclude_unset=True appears inside model_dump().
  • Help me fix my partial task update.
Delete tasks and confirm missing IDs

Deletion completes the server-side lifecycle. The same missing-resource check keeps delete requests consistent with task retrieval and updates.

  • Switch back to main.py.
  • Add the following route below update_task:
@app.delete("/tasks/{task_id}")
def delete_task(task_id: int) -> dict[str, str]:
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    del tasks[task_id]
    return {"message": "Task deleted"}

What Does the Delete Route Do?

  • The route raises an HTTPException when the requested ID is absent.
  • The del statement removes the matching entry from the in-memory dictionary.
  • The returned message gives the client a clear success response.
  • Save main.py.
  • Return to the interactive documentation in Safari.
  • Use POST /tasks to create another fresh task.
  • Update the created task ID with the ID from the new response.
  • Expand DELETE /tasks/{task_id}.
  • Enter the created task ID in the path parameter field.
  • Run the delete request.

The response confirms that the task was deleted. That resource is no longer present in the in-memory dictionary.

  • Expand GET /tasks/{task_id}.
  • Enter the created task ID in the path parameter field.
  • Run the retrieval request.

You see a 404 response with the detail Task not found. This proves deletion changed the resource state.

Deleted Task Still Available?

  • Check that del tasks[task_id] runs before the success response.
  • Check that your retrieval request uses the same ID as the delete request.
  • Help me troubleshoot my delete route.

✔️ Awesome, I've got everything!

Your main.py file now supports creation, listing, filtering, retrieval, partial updates, and deletion.

ⓧ I'd like to double check the full code

Compare your complete main.py file with this version.

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field

app = FastAPI(title="Task Tracker API")


class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=100)
    completed: bool = False


class TaskUpdate(BaseModel):
    title: str | None = Field(default=None, min_length=1, max_length=100)
    completed: bool | None = None


class Task(TaskCreate):
    id: int


tasks: dict[int, Task] = {}
next_id = 1


@app.get("/")
def read_root() -> dict[str, str]:
    return {"message": "Task Tracker API is running"}


@app.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
def create_task(task: TaskCreate) -> Task:
    global next_id
    new_task = Task(id=next_id, **task.model_dump())
    tasks[next_id] = new_task
    next_id += 1
    return new_task


@app.get("/tasks", response_model=list[Task])
def list_tasks(completed: bool | None = None) -> list[Task]:
    task_list = list(tasks.values())
    if completed is None:
        return task_list
    return [task for task in task_list if task.completed == completed]


@app.get("/tasks/{task_id}", response_model=Task)
def get_task(task_id: int) -> Task:
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    return tasks[task_id]


@app.patch("/tasks/{task_id}", response_model=Task)
def update_task(task_id: int, changes: TaskUpdate) -> Task:
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    stored_task = tasks[task_id]
    update_data = changes.model_dump(exclude_unset=True)
    updated_task = stored_task.model_copy(update=update_data)
    tasks[task_id] = updated_task
    return updated_task


@app.delete("/tasks/{task_id}")
def delete_task(task_id: int) -> dict[str, str]:
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    del tasks[task_id]
    return {"message": "Task deleted"}

How to Compare This File

Check each route in order from the root operation through delete_task. Pay close attention to the indentation inside each missing-ID check.

Build and run the API client

Interactive documentation is one client for your API. A separate Python script proves that any program can send the same requests over HTTP.

The standard-library urllib.request module sends requests without adding another project dependency. The helper will encode request data and decode each response.

  • Create client.py inside the open task-tracker-api folder using the Visual Studio Code file sidebar.
  • Paste the request helper and first create call into client.py using this code:
import json
from urllib import request

BASE_URL = "http://127.0.0.1:8000"


def call_api(method, path, data=None):
    body = json.dumps(data).encode("utf-8") if data is not None else None
    api_request = request.Request(
        f"{BASE_URL}{path}",
        data=body,
        headers={"Content-Type": "application/json"},
        method=method,
    )
    with request.urlopen(api_request, timeout=5) as response:
        response_body = response.read().decode("utf-8")
        return response.status, json.loads(response_body) if response_body else None


created_status, created_task = call_api(
    "POST", "/tasks", {"title": "Learn API basics"}
)
print("Created:", created_status, created_task)

task_id = created_task["id"]

How Does the Client Send Requests?

  • The BASE_URL value points the client to your running local server.
  • The helper converts request data into encoded JSON bytes when a body is present.
  • The Content-Type header identifies the body as application/json.
  • The first call creates a task and captures its generated ID for later requests.
  • Save client.py.
  • Create a second integrated terminal from the terminal panel in Visual Studio Code.
  • Activate the existing virtual environment in the second terminal by running:
source .venv/bin/activate

What Does This Command Do?

The command activates the project-local environment in the second terminal. Your server keeps running in the first terminal while this terminal runs the client.

Before you run the client, what status do you expect from a successful create request?

  • Run the first client request by executing:
python3 client.py

What Does This Run Prove?

The command executes client.py against the server that is already running. The printed response comes from POST /tasks.

You see a line beginning with Created: 201. Your standalone client has successfully created a task through the API.

Client Cannot Reach the API?

  • Confirm that fastapi dev is still running in the first terminal.
  • Confirm that BASE_URL matches the local server address.
  • Help me connect my Python client to my FastAPI server.

The create call proves the connection works. The remaining calls will update, retrieve, filter, and delete the task created by the script.

  • In client.py, place your cursor below the task_id assignment.
  • Append the rest of the lifecycle using this code:
updated_status, updated_task = call_api(
    "PATCH", f"/tasks/{task_id}", {"completed": True}
)
print("Updated:", updated_status, updated_task)

fetched_status, fetched_task = call_api("GET", f"/tasks/{task_id}")
print("Fetched:", fetched_status, fetched_task)

filtered_status, filtered_tasks = call_api("GET", "/tasks?completed=true")
print("Filtered:", filtered_status, filtered_tasks)

deleted_status, deleted_result = call_api("DELETE", f"/tasks/{task_id}")
print("Deleted:", deleted_status, deleted_result)

What Does the Lifecycle Code Do?

  • The update call marks the created task as complete through PATCH /tasks/{task_id}.
  • The fetch call retrieves the same task through its path ID.
  • The filter call requests only completed tasks through the query string.
  • The delete call removes the task after the client has inspected it.
  • Save client.py.

Before you run the complete client, how many successful lifecycle stages do you expect it to print?

  • Run the complete lifecycle in the second terminal by executing:
python3 client.py

What Does This Command Test?

This run sends every lifecycle request in sequence. Each printed status comes from the matching route in main.py.

You see lines beginning with Created: 201, Updated: 200, Fetched: 200, Filtered: 200, and Deleted: 200. The same task has completed its entire lifecycle through HTTP.

Missing a Lifecycle Status?

  • Check that every client call appears below the task_id assignment.
  • Check that the server terminal remains active while the client runs.
  • Help me troubleshoot my CRUD client output.

✔️ Awesome, I've got everything!

Your client now creates, updates, fetches, filters, and deletes a task through the running API.

ⓧ I'd like to double check the full code

Compare your complete client.py file with this version.

import json
from urllib import request

BASE_URL = "http://127.0.0.1:8000"


def call_api(method, path, data=None):
    body = json.dumps(data).encode("utf-8") if data is not None else None
    api_request = request.Request(
        f"{BASE_URL}{path}",
        data=body,
        headers={"Content-Type": "application/json"},
        method=method,
    )
    with request.urlopen(api_request, timeout=5) as response:
        response_body = response.read().decode("utf-8")
        return response.status, json.loads(response_body) if response_body else None


created_status, created_task = call_api(
    "POST", "/tasks", {"title": "Learn API basics"}
)
print("Created:", created_status, created_task)

task_id = created_task["id"]

updated_status, updated_task = call_api(
    "PATCH", f"/tasks/{task_id}", {"completed": True}
)
print("Updated:", updated_status, updated_task)

fetched_status, fetched_task = call_api("GET", f"/tasks/{task_id}")
print("Fetched:", fetched_status, fetched_task)

filtered_status, filtered_tasks = call_api("GET", "/tasks?completed=true")
print("Filtered:", filtered_status, filtered_tasks)

deleted_status, deleted_result = call_api("DELETE", f"/tasks/{task_id}")
print("Deleted:", deleted_status, deleted_result)

How to Compare This File

Check that call_api appears before every request. Check that each later call uses the ID returned by the create response.

That is the full CRUD lifecycle working from a real client. Your API now receives validated requests and returns observable results for every resource operation.

Secret mission

Add Task Priorities

Extend your live task contract with validated priority values. Then update the filter and Python client so high-priority tasks can travel through the complete API lifecycle.

Clean Up Your Resources

Clean Up Your Resources

Everything in this project runs locally on your Mac. Your Task Tracker API creates no cloud resources or ongoing costs.

Decide whether to keep the server running. You can also pause it while preserving your files or delete the local project entirely.

Resources you used:

  • The local FastAPI development server running from main.py.
  • The task-tracker-api folder containing every project file plus the .venv virtual environment.

Keep everything running

No action needed. Choose this if you want to continue testing the API or extending its task schema.

  • Leave the FastAPI development server running in the Terminal window from earlier.
  • Keep task-tracker-api open in Visual Studio Code.

Pause - I'll come back to this later

Stop the running process to free the local port. Your project files remain available for your next session.

  • Switch back to the Terminal window from earlier.
  • Press Control+C to stop the development server.

The Terminal prompt returns when the server stops. Safari can no longer load the local API.

Delete - I don't want to use this again

Deleting this project permanently removes your code plus its virtual environment. The cleanup sequence targets only the local task-tracker-api folder.

  • Switch back to the Terminal window where the development server is running.
  • Press Control+C to stop the development server.
  • Remove the local project folder by running this cleanup sequence:
cd ..
rm -rf task-tracker-api
ls

What Does This Cleanup Do?

  • The first line moves Terminal to the folder containing task-tracker-api.
  • The second line permanently removes the task-tracker-api folder.
  • The final line lists the folders that remain so you can verify the deletion.

You should no longer see task-tracker-api in the output. That confirms the code plus the virtual environment have been deleted.

Still See the Project Folder?

  • Confirm that the Terminal window was running fastapi dev from inside task-tracker-api before you entered the cleanup sequence.
  • Check the remaining folder name for a spelling difference before running another delete command.

Help me remove this folder safely.

Nice Work!

Nice Work!

You did it. Your FastAPI Task Tracker API now handles a complete resource lifecycle through validated HTTP requests.

You've learned how to:

  • Built a complete CRUD lifecycle for task resources. Used POST to create tasks. Used GET to retrieve tasks. Added query filtering to list tasks. Used PATCH to update supplied fields. Used DELETE to remove tasks. Returned JSON responses with meaningful status codes.
  • Exposed a validation gap by sending {} to a raw request body. Closed that gap with Pydantic schemas that require a task title. Returned a deliberate 404 error when a requested task does not exist.
  • Called routes through interactive OpenAPI documentation. Inspected the generated request schemas. Sent task IDs through path parameters. Applied filters through query parameters. Supplied task data through request bodies. Called the same server from an independent Python client.
  • Secret Mission: Evolved the API with a validated priority field. Added priority filtering to the interactive documentation. Updated the Python client to print a successful high-priority filter result.

Ready to quiz yourself?