Build a Task Manager API with FastAPI

Build a typed REST API with full CRUD operations using Python and FastAPI.

Introduction

30 Second Summary

APIs power everything from mobile apps to smart home devices. Every time you check the weather, order food, or scroll social media, a backend API is doing the heavy lifting behind the scenes.

In this project, you will build a Task Manager REST API using Python and FastAPI. You'll define typed data models, implement full CRUD operations, and explore auto-generated interactive documentation.

What You'll Build

A REST API that manages tasks with creation, retrieval, updating, and deletion endpoints, backed by typed Pydantic models and FastAPI's dependency injection system.

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

  • A running FastAPI application with 5 endpoints for complete task management.
  • Typed Pydantic models that automatically validate incoming requests and shape outgoing responses.
  • A dependency injection function for reusable pagination logic across endpoints.
  • Secret Mission: Add filtering by priority level and a task summary statistics endpoint.

Are there any prerequisites?

This project assumes a basic understanding of REST APIs and backend concepts. If you have experience with C#/.NET, you'll find many familiar patterns here mapped to their Python equivalents.

Set Up Your Python Environment

Every Python project benefits from an isolated environment where you install dependencies without affecting the rest of your system.

In this step, you will create a project folder, set up a virtual environment, install FastAPI, and confirm your dev server runs with auto-reload.

In this step, get ready to:
  • Create a project folder and virtual environment.
  • Install FastAPI and run a hello world endpoint.
  • Verify your development loop with auto-reload.
Create a project folder and virtual environment
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (the backtick key, above Tab).
  • 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
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the task-manager-api folder, and click Open.

Why reopen the folder?

Opening the folder as a VS Code workspace means the integrated terminal will start in task-manager-api by default. It also enables VS Code features like file navigation and IntelliSense within your project.

  • Open a new terminal in the reopened window by pressing Ctrl+`.

A virtual environment is an isolated Python installation inside your project folder. Packages you install here stay local to this project.

  • Create a virtual environment by running this command:

macOS

python3 -m venv venv

Windows

python -m venv venv
  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate
  • Confirm your terminal prompt now shows (venv) at the beginning of the line.

Don't see (venv) in your prompt?

Make sure you ran the activate command for your operating system. On Windows, if you see a permissions error, run this command in PowerShell and try again:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

Help me troubleshoot virtual environment activation

Install FastAPI and run a hello world endpoint
  • Install FastAPI with its standard dependencies by running:
pip install "fastapi[standard]"

What does fastapi[standard] include?

The [standard] extra bundles Uvicorn (the server that runs your app) and other utilities that make development easier. This is similar to how .NET project templates include common packages automatically.

  • Confirm the installation finished without errors. You should see a success message mentioning Successfully installed followed by a list of packages.

Seeing a permissions error or install failure?

Make sure your virtual environment is active. The (venv) prefix must appear in your terminal prompt before running pip install. If you accidentally closed the terminal, reopen it and re-run the activation command from the previous substep.

Help me fix my FastAPI installation

Now you will create a minimal FastAPI app with a single endpoint that returns a JSON message.

  • Create a new file called main.py in the root of your task-manager-api folder.
  • Copy the following code into main.py:
from fastapi import FastAPI

# Create the FastAPI application instance
app = FastAPI(title="Task Manager API")

# Define a GET endpoint at the root path
@app.get("/")
def read_root():
    return {"message": "Hello, FastAPI!"}

What does this code do?

FastAPI is the class that creates your web application. Think of it like WebApplication.CreateBuilder() in ASP.NET.

The @app.get("/") decorator maps the root URL to the read_root() function. This is equivalent to an ASP.NET controller method with [HttpGet("/")]. The function returns a Python dictionary, which FastAPI automatically converts to JSON.

  • Save main.py.
  • Start the development server by running:
fastapi dev main.py
  • You should see output indicating Uvicorn is running at http://127.0.0.1:8000.
  • Open your browser and navigate to http://127.0.0.1:8000.

You should see the JSON response {"message": "Hello, FastAPI!"} displayed in your browser.

  • Now navigate to http://127.0.0.1:8000/docs in your browser.

This is FastAPI's auto-generated Swagger UI. It documents every endpoint in your API and lets you test them interactively. If you have used Swashbuckle in ASP.NET, this is the same concept built in by default.

Browser showing an error or blank page?

Check your terminal. If you see a traceback or error, make sure you saved main.py and that the file is in the root of your task-manager-api folder (not inside venv).

Help me troubleshoot my FastAPI dev server

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.py before continuing.

ⓧ I'd like to double check the full code

from fastapi import FastAPI

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

@app.get("/")
def read_root():
    return {"message": "Hello, FastAPI!"}
Verify your development loop

The fastapi dev command runs with auto-reload enabled. Any time you save a file, the server restarts automatically so you can see changes instantly.

  • Make sure the server is running. If you stopped it, start it again by running fastapi dev main.py.
  • In main.py, find this line:
    return {"message": "Hello, FastAPI!"}
  • Change "Hello, FastAPI!" to any other string, for example "Hello, World!".
  • Save main.py.
  • Refresh http://127.0.0.1:8000 in your browser.

You should see your updated message in the JSON response. This confirms auto-reload is working.

  • Change the message back to "Hello, FastAPI!" and save the file.
  • Stop the server by pressing Ctrl+C in the terminal.

Browser still showing the old message?

Check your terminal output. If you see an error after saving, you may have introduced a syntax issue. Make sure the string is wrapped in quotes and the dictionary braces are correct.

Help me fix auto-reload

Your development environment is ready. Next up, you will define typed data models with Pydantic and create your first real endpoint.

Define Your Data Models with Pydantic

Your development environment is ready and your first endpoint is serving JSON. Now it's time to define the data your API will work with.

Every API needs a clear contract for what data it accepts and returns. FastAPI uses Pydantic models to define this contract. Pydantic validates incoming data automatically, so invalid requests get rejected before your code ever sees them.

In this step, get ready to:
  • Define a TaskStatus enum and Pydantic models for your API's data contract.
  • Create an in-memory data store and a POST endpoint for creating tasks.
  • Test automatic validation by sending valid and invalid data through Swagger UI.
Create Pydantic models for tasks

If you've used C# record types or data transfer objects, Pydantic models are the Python equivalent. They define the shape of your data and enforce validation rules declaratively through type hints.

  • In main.py, select all the existing code and delete it.
  • You'll rebuild this file from scratch since the imports and structure change significantly.
  • Add the following imports and status enum at the top of the empty file:
from enum import Enum
from typing import Annotated

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


# --- Enums ---

class TaskStatus(str, Enum):
    pending = "pending"
    in_progress = "in_progress"
    completed = "completed"

What does this code do?

The imports pull in everything you'll need for this step. You won't use all of them immediately, but having them ready avoids interrupting your flow later.

TaskStatus inherits from both str and Enum. This makes it a string enum, meaning each value serializes as a plain string in JSON responses. It restricts task status to exactly three valid states.

  • You won't see a change yet. The next chunk defines the models that use this enum.
  • Below the TaskStatus enum, add the following Pydantic model definitions:
# --- Pydantic Models ---

class TaskCreate(BaseModel):
    title: str
    description: str | None = None
    priority: int = Field(default=3, ge=1, le=5)
    status: TaskStatus = TaskStatus.pending


class Task(TaskCreate):
    id: int


class TaskUpdate(BaseModel):
    title: str | None = None
    description: str | None = None
    priority: int | None = Field(default=None, ge=1, le=5)
    status: TaskStatus | None = None

What do these models do?

  • TaskCreate is the input schema. It defines what a client sends when creating a task. Field(default=3, ge=1, le=5) sets the default priority to 3 and constrains it between 1 and 5.
  • Task extends TaskCreate with an id field. This is the response schema. Think of it like a C# class inheriting from its DTO.
  • TaskUpdate makes every field optional. This enables partial updates where the client only sends the fields they want to change.
  • You won't see a change yet. The next substep uses these models in an endpoint you can test.
Set up the in-memory store and create your first endpoint

Your models define the shape of the data. Now you need somewhere to store it and an endpoint that puts it all together.

  • Below the models, add the app instance, data store, and endpoints:
# --- App and Data Store ---

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

# In-memory dictionary to store tasks (replaces a database for now)
tasks: dict[int, Task] = {}
# Auto-incrementing counter for task IDs
id_counter: int = 0


# --- Endpoints ---

@app.get("/")
def read_root():
    return {"message": "Hello, FastAPI!"}


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

What does this code do?

  • tasks is a dictionary that maps integer IDs to Task objects. It acts as your in-memory database.
  • status_code=status.HTTP_201_CREATED tells FastAPI to return a 201 status code instead of the default 200. This signals to clients that a resource was successfully created.
  • task_data.model_dump() converts the Pydantic model to a dictionary. The ** operator unpacks it into keyword arguments for the Task constructor.
  • The -> Task return type annotation tells FastAPI what response schema to show in the auto-generated docs.
  • Save main.py.
  • Start the dev server by running this command in your terminal:
fastapi dev main.py
  • Open http://127.0.0.1:8000/docs in your browser. You should see the Swagger UI with both GET / and POST /tasks listed.

Seeing an import error or the server won't start?

  • Check that your virtual environment is still active. You should see (venv) at the start of your terminal prompt.
  • Make sure you saved main.py after adding all the code. Unsaved changes won't be picked up by the server.
  • Verify that the class definitions are not indented under another block. Each class keyword should start at column 0.

Help me debug my FastAPI server startup error

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and the Swagger UI shows both endpoints.

ⓧ I'd like to double check the full code

Here's the complete main.py at this point:

from enum import Enum
from typing import Annotated

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


# --- Enums ---

class TaskStatus(str, Enum):
    pending = "pending"
    in_progress = "in_progress"
    completed = "completed"


# --- Pydantic Models ---

class TaskCreate(BaseModel):
    title: str
    description: str | None = None
    priority: int = Field(default=3, ge=1, le=5)
    status: TaskStatus = TaskStatus.pending


class Task(TaskCreate):
    id: int


class TaskUpdate(BaseModel):
    title: str | None = None
    description: str | None = None
    priority: int | None = Field(default=None, ge=1, le=5)
    status: TaskStatus | None = None


# --- App and Data Store ---

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

tasks: dict[int, Task] = {}
id_counter: int = 0


# --- Endpoints ---

@app.get("/")
def read_root():
    return {"message": "Hello, FastAPI!"}


@app.post("/tasks", status_code=status.HTTP_201_CREATED)
def create_task(task_data: TaskCreate) -> Task:
    global id_counter
    id_counter += 1
    task = Task(id=id_counter, **task_data.model_dump())
    tasks[task.id] = task
    return task
Test the create endpoint in Swagger UI

Your endpoint is live. Now you'll see Pydantic's validation in action by sending both valid and invalid data.

  • In the Swagger UI at http://127.0.0.1:8000/docs, click the POST /tasks row to expand it.
  • Click Try it out.
  • Replace the example JSON body with this:
{
  "title": "Learn FastAPI",
  "description": "Build a task manager API",
  "priority": 4
}
  • Click Execute.

You should see a 201 response with the task data, including an auto-assigned id of 1 and the default status of pending.

Why didn't I send status or id?

You only sent title, description, and priority. Pydantic filled in status with its default value (pending), and your endpoint logic assigned the id. This is the same pattern as auto-generated IDs in Entity Framework.

Now test the validation. Send a request with an invalid priority value to see how Pydantic rejects it.

  • Click Try it out again and replace the body with:
{
  "title": "Invalid task",
  "priority": 10
}
  • Click Execute.

You should see a 422 validation error response. The response body includes a detail array telling you exactly which field failed and why. You didn't write any validation logic. Pydantic handled it automatically from the Field(ge=1, le=5) constraint.

What is a 422 validation error?

HTTP 422 means the server understood the request format (valid JSON) but the content failed validation rules. FastAPI returns a structured error with the field location, the error type, and a human-readable message. This is similar to ASP.NET's ModelState validation, but it happens automatically without writing any if checks.

Your data models are locked in and your first endpoint is live. Next up, you'll build the remaining CRUD endpoints to complete your API.

Build the Full CRUD Endpoints

In the previous step, you defined your data models and built a POST endpoint to create tasks. Now it is time to complete the full set of CRUD operations so your API can list, retrieve, update, and delete tasks.

A real backend needs more than just "create." Users need to browse all tasks, fetch a specific one by ID, change its fields, and remove it entirely. Each of these maps to an HTTP method your REST API exposes.

In this step, get ready to:
  • Add GET endpoints for listing and retrieving tasks.
  • Add PUT and DELETE endpoints for updating and removing tasks.
  • Test the full CRUD flow in Swagger UI.
Add GET endpoints for listing and retrieving tasks

Your API can create tasks, but there is no way to see them yet. You need two read endpoints: one that lists every task, and one that fetches a single task by its ID.

  • In main.py, find the create_task function at the bottom of your file.
  • Add the following GET endpoint below it to list all tasks:
@app.get("/tasks")
def list_tasks() -> list[Task]:
    # Return all tasks as a list
    return list(tasks.values())

What does this code do?

The @app.get("/tasks") decorator registers a GET endpoint at the /tasks path.

The function converts the tasks dictionary values into a Python list and returns it. FastAPI automatically serializes this list to JSON using your Task model as the response shape.

  • Save main.py.
  • If your dev server is not running, start it by running fastapi dev main.py in the terminal.
  • Refresh http://127.0.0.1:8000/docs in your browser. You should see a new GET /tasks entry in the Swagger UI.

Don't see the new endpoint?

  • Make sure you saved main.py after adding the code. The dev server auto-reloads on save.
  • Check the terminal for syntax errors. A missing colon or indentation issue stops the server from reloading.
  • help me figure out why my GET /tasks endpoint is not showing up in Swagger

Now add the endpoint that retrieves a single task by its ID. This endpoint also needs to handle the case where the ID does not exist.

  • Below the list_tasks function, add this endpoint:
@app.get("/tasks/{task_id}")
def get_task(task_id: int) -> Task:
    # Return 404 if the task does not exist
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    return tasks[task_id]

What does this code do?

The {task_id} in the path is a path parameter. FastAPI automatically parses it as an integer because of the task_id: int type hint.

If the ID is not in the tasks dictionary, the endpoint raises an HTTPException with a 404 status code. This is the equivalent of returning NotFound() in an ASP.NET controller.

  • Save main.py and refresh http://127.0.0.1:8000/docs.
  • You should see a new GET /tasks/{task_id} entry in the Swagger UI.

Seeing a validation error for task_id?

  • Make sure the path uses curly braces: /tasks/{task_id}. Square brackets or angle brackets will not work.
  • Confirm task_id: int in the function signature matches the path parameter name exactly.
  • help me debug my GET /tasks/{task_id} endpoint
Add PUT and DELETE endpoints

With the read operations done, your API needs a way to modify existing tasks and remove them. The PUT endpoint handles partial updates, and the DELETE endpoint removes a task entirely.

  • Below the get_task function in main.py, add the PUT endpoint:
@app.put("/tasks/{task_id}")
def update_task(task_id: int, task_data: TaskUpdate) -> Task:
    # Return 404 if the task does not exist
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    existing_task = tasks[task_id]
    # Only update fields the client explicitly sent
    update_fields = task_data.model_dump(exclude_unset=True)
    updated_task = existing_task.model_copy(update=update_fields)
    tasks[task_id] = updated_task
    return updated_task

What does this code do?

This endpoint accepts a TaskUpdate body where every field is optional. The client only sends the fields they want to change.

Calling model_dump(exclude_unset=True) returns a dictionary of only the fields the client explicitly included in the request. Fields the client did not send are excluded entirely.

Then model_copy(update=update_fields) creates a new copy of the existing task with those fields overwritten. This is the Pydantic v2 pattern for immutable-style updates.

  • Save main.py and refresh Swagger.
  • You should see a PUT /tasks/{task_id} entry with a request body schema showing all fields as optional.

Getting an AttributeError on model_copy?

  • The model_copy method is a Pydantic v2 feature. If you see an error, confirm FastAPI installed Pydantic v2 by checking that pip show pydantic shows version 2.x.
  • Make sure you are calling model_copy on the existing task object, not on the TaskUpdate input.
  • help me fix my update_task endpoint

Finally, add the DELETE endpoint that removes a task from the store.

  • Below the update_task function, add this endpoint:
@app.delete("/tasks/{task_id}")
def delete_task(task_id: int):
    # Return 404 if the task does not exist
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    del tasks[task_id]
    return {"detail": "Task deleted"}

What does this code do?

The DELETE endpoint checks that the task exists, removes it from the dictionary with del, and returns a simple confirmation message.

Notice this function has no return type annotation. That is because it returns a plain dictionary rather than a typed model. FastAPI still serializes it to JSON automatically.

  • Save main.py and refresh http://127.0.0.1:8000/docs.
  • You should see all five endpoints listed: GET /, POST /tasks, GET /tasks, GET /tasks/{task_id}, PUT /tasks/{task_id}, and DELETE /tasks/{task_id}.

Missing the DELETE endpoint?

  • Check your terminal for any error messages. A typo in the decorator (@app.delete) or function body will prevent the server from reloading.
  • Verify the function is outside and below the update_task function, not nested inside it.
  • help me figure out why my DELETE endpoint is not showing up

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and that all six endpoints appear in the Swagger UI.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point:

from enum import Enum
from typing import Annotated

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


# --- Enums ---

class TaskStatus(str, Enum):
    pending = "pending"
    in_progress = "in_progress"
    completed = "completed"


# --- Pydantic Models ---

class TaskCreate(BaseModel):
    title: str
    description: str | None = None
    priority: int = Field(default=3, ge=1, le=5)
    status: TaskStatus = TaskStatus.pending


class Task(TaskCreate):
    id: int


class TaskUpdate(BaseModel):
    title: str | None = None
    description: str | None = None
    priority: int | None = Field(default=None, ge=1, le=5)
    status: TaskStatus | None = None


# --- App and Data Store ---

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

tasks: dict[int, Task] = {}
id_counter: int = 0


# --- Endpoints ---

@app.get("/")
def read_root():
    return {"message": "Hello, FastAPI!"}


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


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


@app.get("/tasks/{task_id}")
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.put("/tasks/{task_id}")
def update_task(task_id: int, task_data: TaskUpdate) -> Task:
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    existing_task = tasks[task_id]
    update_fields = task_data.model_dump(exclude_unset=True)
    updated_task = existing_task.model_copy(update=update_fields)
    tasks[task_id] = updated_task
    return updated_task


@app.delete("/tasks/{task_id}")
def delete_task(task_id: int):
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    del tasks[task_id]
    return {"detail": "Task deleted"}
Test the complete CRUD flow in Swagger

With all endpoints in place, it is time to exercise the full lifecycle of a task through the interactive Swagger documentation.

  • Navigate to http://127.0.0.1:8000/docs in your browser.
  • Expand the POST /tasks section and click Try it out.
  • Create 2-3 tasks by submitting different titles and priorities.
  • Expand GET /tasks, click Try it out, then click Execute. You should see all your tasks returned as a JSON array.
  • Expand GET /tasks/{task_id}, enter 1 as the task_id, and click Execute. You should see the single task with ID 1.
  • Expand PUT /tasks/{task_id}, enter 1 as the task_id, and set the request body to {"status": "completed"}. Click Execute. The response should show the task with its status changed to completed while all other fields remain unchanged.
  • Expand DELETE /tasks/{task_id}, enter 1 as the task_id, and click Execute. You should see {"detail": "Task deleted"}.
  • Test the 404 error by trying to GET a task_id that does not exist (for example, 999). You should see a 404 response with {"detail": "Task not found"}.

Why partial updates matter

The PUT endpoint only modifies fields the client sends. If you only send {"status": "completed"}, the title, description, and priority stay the same.

This works because model_dump(exclude_unset=True) distinguishes between "the client sent null" and "the client did not include this field at all." In C#/.NET, a similar pattern uses nullable properties with a custom model binder or JSON patch.

Your API now supports the full task lifecycle. Next up, you will add pagination using FastAPI's dependency injection system to control how many tasks the list endpoint returns at once.

Add Pagination with Dependency Injection

Your API now supports full CRUD operations. But right now, GET /tasks dumps every single task in the store. In a real application with hundreds of tasks, that response would be massive and slow.

In this step, you'll add pagination using FastAPI's dependency injection system. If you've used constructor injection in ASP.NET, this will feel familiar. Instead of registering a service in a DI container, you declare a function and let FastAPI inject its return value into your endpoint.

In this step, get ready to:
  • Create a reusable pagination dependency function.
  • Apply the dependency to the list endpoint with type-safe injection.
  • Test pagination with skip and limit query parameters in Swagger UI.
Create a pagination dependency

A dependency in FastAPI is just a function whose return value gets injected into your endpoint. You define the function once, and any endpoint that needs its logic simply declares it as a parameter.

  • In main.py, find the # --- Endpoints --- comment.
  • Add the following code directly above that comment:
# --- Dependencies ---

async def common_parameters(skip: int = 0, limit: int = 10):
    return {"skip": skip, "limit": limit}


CommonsDep = Annotated[dict, Depends(common_parameters)]

What does this code do?

The common_parameters function accepts two query parameters with defaults. FastAPI reads the function's type hints and automatically extracts skip and limit from the request's query string.

The CommonsDep type alias combines Annotated and Depends so you can declare the dependency cleanly in any endpoint signature. This is the Python equivalent of registering a service in ASP.NET's DI container.

Why dependency injection instead of regular parameters?

You could add skip and limit directly to each endpoint. But if ten endpoints all need pagination, you'd repeat the same parameters ten times.

A dependency lets you define the logic once and share it across endpoints. If you later add validation (e.g., limit cannot exceed 100), you change it in one place.

Apply the dependency to the list endpoint

Now you'll wire the dependency into your existing GET /tasks endpoint so it uses the injected pagination values to slice the results.

  • In main.py, find the list_tasks function. It currently looks like this:
@app.get("/tasks")
def list_tasks() -> list[Task]:
    return list(tasks.values())
  • Replace the entire list_tasks function (including the decorator) with this updated version:
@app.get("/tasks")
def list_tasks(commons: CommonsDep) -> list[Task]:
    task_list = list(tasks.values())
    skip = commons["skip"]
    limit = commons["limit"]
    return task_list[skip : skip + limit]

What changed here?

  • commons: CommonsDep tells FastAPI to call common_parameters and inject the result as a dict.
  • task_list[skip : skip + limit] uses Python list slicing to return only the requested page of results.
  • Callers can now add ?skip=0&limit=2 to the URL. If they omit these, the defaults (0 and 10) kick in.
  • Save main.py.

Seeing an import error?

If you see an error about Annotated or Depends not being defined, check your imports at the top of the file. You should have from typing import Annotated and from fastapi import Depends. These should already be present from the previous step.

help me fix an import error with Annotated or Depends in my FastAPI app

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on to testing.

ⓧ I'd like to double check the full code

Here is the full main.py at this point in the project:

from enum import Enum
from typing import Annotated

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


# --- Enums ---

class TaskStatus(str, Enum):
    pending = "pending"
    in_progress = "in_progress"
    completed = "completed"


# --- Pydantic Models ---

class TaskCreate(BaseModel):
    title: str
    description: str | None = None
    priority: int = Field(default=3, ge=1, le=5)
    status: TaskStatus = TaskStatus.pending


class Task(TaskCreate):
    id: int


class TaskUpdate(BaseModel):
    title: str | None = None
    description: str | None = None
    priority: int | None = Field(default=None, ge=1, le=5)
    status: TaskStatus | None = None


# --- App and Data Store ---

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

tasks: dict[int, Task] = {}
id_counter: int = 0


# --- Dependencies ---

async def common_parameters(skip: int = 0, limit: int = 10):
    return {"skip": skip, "limit": limit}


CommonsDep = Annotated[dict, Depends(common_parameters)]


# --- Endpoints ---

@app.get("/")
def read_root():
    return {"message": "Hello, FastAPI!"}


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


@app.get("/tasks")
def list_tasks(commons: CommonsDep) -> list[Task]:
    task_list = list(tasks.values())
    skip = commons["skip"]
    limit = commons["limit"]
    return task_list[skip : skip + limit]


@app.get("/tasks/{task_id}")
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.put("/tasks/{task_id}")
def update_task(task_id: int, task_data: TaskUpdate) -> Task:
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    existing_task = tasks[task_id]
    update_fields = task_data.model_dump(exclude_unset=True)
    updated_task = existing_task.model_copy(update=update_fields)
    tasks[task_id] = updated_task
    return updated_task


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

Time to prove that pagination works. You'll create several tasks, then use skip and limit to page through the results.

  • Start the dev server by running this command in your terminal:
fastapi dev main.py
  • Open http://127.0.0.1:8000/docs in your browser.
  • Use the POST /tasks endpoint to create 5 tasks. Give each a different title (e.g., Task 1, Task 2, etc.).
  • Expand the GET /tasks endpoint in Swagger UI. You should see skip and limit listed as query parameters with their default values.
  • Click **Try it out**, set skip to 0 and limit to 2, then click Execute. You should see only the first 2 tasks in the response.
  • Change skip to 2 and keep limit at 2. Click Execute again. You should see the next 2 tasks.

How does this compare to ASP.NET?

In ASP.NET, you'd register a service in Program.cs and inject it via a constructor parameter. In FastAPI, you define a function and declare it in the endpoint's signature with Depends. The effect is the same: shared logic, single source of truth, easy to test.

Not seeing skip and limit in Swagger?

Make sure your list_tasks function has commons: CommonsDep as a parameter. Without it, FastAPI doesn't know to inject the dependency or expose the query parameters.

Also confirm that CommonsDep is defined above the endpoint that uses it. Python reads top to bottom, so the alias must exist before the function references it.

help me debug why skip and limit aren't showing in my Swagger docs

Your API now handles pagination cleanly with a reusable dependency. Any future endpoint that needs skip/limit logic can simply declare CommonsDep as a parameter. Next up, you'll push this further in the Secret Mission by adding filtering and statistics.

Secret mission

Add Filtering and Statistics

Your API can list tasks, but real-world APIs let users filter and slice data. Can you add query parameter filtering by status and priority, plus a statistics endpoint that summarizes your task store?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project folder on disk or delete it entirely.

Resources you used:

  • The task-manager-api project folder (includes virtual environment and all installed packages).
  • The running FastAPI dev server process.

Keep everything running

No action needed. Choose this if you plan to keep building on your Task Manager API or want to revisit it later.

  • Your task-manager-api folder stays on your Desktop.
  • To start the server again later, open the folder in VS Code, activate the virtual environment, and run:
fastapi dev main.py

Pause - I'll come back to this later

Stop the running dev server to free up your terminal, but keep all project files on disk.

  • In your terminal, press Ctrl+C to stop the FastAPI dev server.
  • Your task-manager-api folder and virtual environment remain intact on disk.
  • When you return, reactivate the virtual environment and run fastapi dev main.py to pick up where you left off.

Delete - I don't want to use this again

Remove all project files and start fresh. The virtual environment and all installed packages live inside the project folder, so deleting it removes everything.

  • In your terminal, press Ctrl+C to stop the dev server if it is still running.
  • Close the task-manager-api folder in VS Code.
  • Delete the project folder by running the command for your operating system:

macOS

rm -rf ~/Desktop/task-manager-api

Windows

rmdir /s /q %USERPROFILE%\Desktop\task-manager-api
  • Confirm the folder is gone by checking your Desktop.

Nice Work!

Nice Work!

You just built a fully functional Task Manager REST API with Python and FastAPI. Your API handles typed request validation, proper HTTP status codes, dependency injection, and auto-generated interactive documentation.

You've learned how to:

  • Build a complete REST API with FastAPI using Python type hints and Pydantic models for automatic request/response validation.
  • Implement full CRUD operations with proper HTTP status codes like 201 Created and 404 Not Found.
  • Use FastAPI's dependency injection system with Depends and Annotated to share reusable query logic across endpoints.
  • Secret Mission: Added filtering by status and priority, plus a statistics endpoint that computes task counts and average priority.

Ready to quiz yourself?