Build a Task Manager REST API

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

Introduction

30 Second Summary

Every time you open an app, check a to-do list, or save a note, your device is talking to a backend server through an API. These invisible conversations power every modern application you use.

In this project, you will build a Task Manager REST API using Python and FastAPI. You will create endpoints that let clients create, read, update, and delete tasks through HTTP requests.

What You'll Build

You will run your API locally and test every endpoint through auto-generated interactive documentation right in your browser, sending requests and seeing real responses come back.

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

  • A running REST API with full CRUD operations where you can create a task, fetch it back by ID, update its title or status, and delete it.
  • Automatic data validation that rejects invalid requests with clear error messages before your handler code even runs.
  • Persistent storage so your tasks survive server restarts, just like a real backend.
  • Secret Mission: Add filtering and search so you can find tasks by completion status or title keyword.

Are there any prerequisites?

You should be comfortable with basic Python concepts like functions, dictionaries, lists, and type hints. No prior backend or API experience is needed.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters for your backend engineering career.

Set Up Your FastAPI Project

You need a working Python environment with FastAPI installed before writing any API code. FastAPI comes with its own development server and auto-generated documentation, but these only work once everything is properly set up.

In this step, you will create a dedicated project folder, set up an isolated Python environment, and install FastAPI with all its extras.

In this step, get ready to:
  • Create a project folder and virtual environment.
  • Install FastAPI and verify it works.
  • Create the project file and open it in VS Code.
Create a project folder and virtual environment

A virtual environment is an isolated Python installation specific to your project. It keeps your project's packages separate from other Python projects on your machine.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open a terminal inside VS Code by clicking Terminal in the top menu bar, then New Terminal.
  • 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
  • Create a virtual environment by running:
python -m venv .venv
  • Activate the virtual environment so your terminal uses its isolated Python. The command differs by operating system:

macOS

  • Activate the virtual environment by running:
source .venv/bin/activate

Windows

  • Activate the virtual environment by running:
.venv\Scripts\activate

Your terminal prompt now shows (.venv) at the beginning. This confirms the virtual environment is active and any packages you install will stay inside this project.

Don't see (.venv) in your prompt?

  • Make sure you ran the activation command for your operating system. macOS uses source while Windows uses a direct path to the Scripts folder.
  • If you see a permissions error on Windows, try running Set-ExecutionPolicy -Scope CurrentUser RemoteSigned in PowerShell, then retry the activation command.
  • Confirm the .venv folder exists by running ls (macOS) or dir (Windows). If it is missing, re-run the python -m venv .venv command.

help me activate my Python virtual environment

Install FastAPI and verify

With your virtual environment active, any packages you install stay contained in this project. Now you will install FastAPI along with its bundled server and CLI tools.

  • Install FastAPI and its extras by running:
pip install "fastapi[standard]"

What does fastapi[standard] include?

The [standard] extras bundle two important tools alongside FastAPI:

  • Uvicorn is the server that runs your API and listens for incoming requests.
  • The FastAPI CLI gives you the fastapi command for starting your development server with a single command.

You will see a stream of download and installation messages in the terminal ending with a "Successfully installed" line.

Before you run the next command, what do you expect to see if FastAPI installed correctly?

  • Verify the installation by running:
fastapi --version

You should see output starting with FastAPI CLI version: followed by a version number. This confirms FastAPI and its CLI are installed and working.

Seeing command not found?

  • Make sure your virtual environment is still active. Your prompt should show (.venv) at the beginning. If not, re-run the activation command from the previous substep.
  • If the pip install command showed errors, check you typed the command exactly as shown, including the double quotes around fastapi[standard].

help me fix fastapi --version not working

Create the project file and open in VS Code

Your environment is ready. Now you need the file where your API code will live.

  • 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.

Trust the authors prompt?

VS Code may ask if you trust the authors of the files in this folder. Click Yes, I trust the authors to enable all VS Code features for this project.

  • In the Explorer sidebar on the left, hover over the TASK-MANAGER-API heading.
  • Click the New File icon (a page with a small plus sign).
  • Type main.py and press Enter.

You should see main.py appear in the Explorer sidebar and open as an empty file in the editor. This is where all of your API code will live.

Don't see the Explorer sidebar?

  • Press Cmd+Shift+E (macOS) or Ctrl+Shift+E (Windows) to toggle the Explorer panel.
  • If you see "No folder opened", repeat the File > Open Folder step and select task-manager-api from your Desktop.

help me create a file in VS Code

Your project environment is locked and loaded. Next up, you will write your first API endpoint and see FastAPI respond to a real HTTP request in the browser.

Build Your First API Endpoint

Your environment is ready. FastAPI is installed and you have an empty main.py waiting for code.

Before building anything complex, you need to see a server start and respond to a request. This proves your setup works and shows you the feedback loop you will use for the rest of the project.

In this step, get ready to:
  • Write your first API endpoint in main.py.
  • Start the development server and see a live response.
  • Explore auto-generated interactive documentation.
Write a root endpoint

A minimal FastAPI app needs four things: an import, an app instance, a route decorator, and a handler function. You will write all four now.

  • Add the following code to main.py:
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def root():
    return {"message": "Task Manager API"}

What does this code do?

  • from fastapi import FastAPI imports the FastAPI class from the library.
  • app = FastAPI() creates your application instance. This object handles all incoming requests.
  • @app.get("/") is a decorator that registers the function below as the handler for GET requests to the / path.
  • def root() is the handler function that runs when someone visits the root URL.
  • The dictionary you return is automatically converted to a JSON response.
  • Save main.py.
Start the development server

With your code saved, you can start the development server. The server watches your file for changes and automatically reloads when you edit it.

  • Start the FastAPI development server by running this command in your terminal:
fastapi dev main.py

What does this command do?

  • fastapi dev launches Uvicorn (the server bundled with FastAPI) in development mode.
  • It auto-detects the app object in main.py and starts serving at http://127.0.0.1:8000.
  • Auto-reload is enabled, so any time you save changes to your code, the server restarts automatically.

You should see output in your terminal that includes Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit). This means the server is running.

Before you open the URL in your browser, predict what you think the response will look like. Will it be HTML, plain text, or something else?

  • Open http://127.0.0.1:8000 in your browser.

You should see the JSON response {"message":"Task Manager API"} displayed in your browser. Your server is alive and responding to HTTP requests.

Not seeing the JSON response?

  • Make sure the server is still running in your terminal. Look for the Uvicorn running on http://127.0.0.1:8000 line in the output.
  • Check that you saved main.py before starting the server.
  • If you see an error about the port being in use, another process is running on port 8000. Close other terminals or restart your computer.
  • Why isn't my FastAPI server responding?
Explore the auto-generated docs

FastAPI generates interactive API documentation automatically from your code. You can test your endpoints directly in the browser without any extra tools.

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

You should see the Swagger UI documentation page listing your GET / endpoint.

  • Click the GET / endpoint to expand it.
  • Click Try it out.
  • Click Execute.

The response body shows {"message":"Task Manager API"} along with status code 200. You just tested your first endpoint without leaving the browser.

Why do auto-generated docs matter?

  • You get a built-in testing interface without installing tools like Postman.
  • The docs update automatically as you add new endpoints.
  • Anyone consuming your API can see exactly how to use it.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and that your server is still running.

ⓧ I'd like to double check the full code

Here is the complete main.py at the end of this step:

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def root():
    return {"message": "Task Manager API"}

Your server is running and responding to requests. Next up, you will build the full set of CRUD endpoints to create, read, update, and delete tasks.

Build CRUD Endpoints with In-Memory Storage

Your server is running and responding at the root endpoint. Now it is time to make it actually do something useful.

Every backend application revolves around four core operations: creating data, reading data, updating data, and deleting data. Together these are called CRUD. In this step, you will build all four operations so tasks can be managed through HTTP requests.

In this step, get ready to:
  • Add a POST endpoint to create new tasks.
  • Add GET endpoints to retrieve tasks individually and as a list.
  • Add PUT and DELETE endpoints to update and remove tasks.
Add a POST endpoint to create tasks

Before you can create tasks, you need somewhere to store them. For now, a simple Python list will hold all your tasks in memory while the server is running.

  • In main.py, find the line that says app = FastAPI(). Directly below it, add these storage variables:
tasks = []
next_id = 1
  • Now scroll down past the root() function. Below it, add the POST endpoint that creates a new task:
@app.post("/tasks")
def create_task(task: dict):
    global next_id
    # Build a new task with an auto-incremented ID
    new_task = {
        "id": next_id,
        "title": task.get("title"),
        "description": task.get("description"),
        "completed": task.get("completed", False),
    }
    tasks.append(new_task)
    next_id += 1
    return new_task

What does this code do?

The storage variables create a holding place for your tasks:

  • tasks = [] is an empty list that will hold all task dictionaries.
  • next_id = 1 is a counter that gives each new task a unique ID and increments after every creation.

The create_task function receives a dictionary from the request body, wraps it into a structured task with an ID, appends it to the list, and returns the created task as JSON.

  • Save main.py.
  • Switch back to the Swagger UI at http://127.0.0.1:8000/docs in your browser. You should see a new green POST /tasks endpoint listed.
  • Click POST /tasks, then click Try it out.
  • Replace the request body with this JSON:
{
  "title": "Buy groceries",
  "description": "Milk, eggs, bread"
}
  • Click Execute. You should see a response body containing your task with "id": 1 added automatically.

Don't see the POST endpoint in docs?

  • Make sure you saved main.py. The dev server auto-reloads on save.
  • Check that tasks = [] and next_id = 1 are at the module level (not indented inside a function).
  • Confirm the @app.post("/tasks") decorator is directly above the def create_task line with no blank line between them.

Why isn't my POST endpoint showing up in the FastAPI docs?

Add GET endpoints to read tasks

You can create tasks, but you have no way to see them. You need read endpoints so clients can fetch the full list or a specific task by its ID.

  • First, update the import at the top of main.py. Find this line:
from fastapi import FastAPI
  • Replace it with this line to add HTTPException (you will need it for the 404 error shortly):
from fastapi import FastAPI, HTTPException
  • Now find the root() function. Below it (but above the create_task function), add the endpoint that returns all tasks:
@app.get("/tasks")
def get_tasks():
    # Return the full list of tasks
    return tasks

What does this code do?

This endpoint responds to GET requests at /tasks by returning the entire tasks list as a JSON array. FastAPI automatically converts the Python list to JSON for the response.

  • Save main.py.
  • In the Swagger UI, use POST /tasks to create a task (since the server restarted on save, the in-memory list is empty again).
  • Now click GET /tasks, hit Try it out, then Execute. You should see a JSON array containing the task you just created.

GET /tasks returns an empty list?

Every time you save main.py, the server restarts and the in-memory list resets to empty. You need to POST a new task after each save before GET will show anything.

Why does my task list reset every time I save the file?

  • Below the get_tasks() function (still above create_task), add the endpoint that fetches a single task by ID:
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    # Search for the task matching the given ID
    for task in tasks:
        if task["id"] == task_id:
            return task
    # No match found, raise a 404 error
    raise HTTPException(status_code=404, detail="Task not found")

What does this code do?

  • {task_id} in the path is a path parameter. FastAPI extracts the value from the URL and passes it to your function.
  • Declaring task_id: int tells FastAPI to validate that the value is an integer. If someone sends /tasks/abc, FastAPI rejects it automatically.
  • HTTPException with status_code=404 returns a proper "Not Found" response when no task matches the given ID.
  • Save main.py.
  • In the Swagger UI, POST a new task to populate the list.
  • Click GET /tasks/{task_id}, hit Try it out, enter 1 for the task_id, then click Execute. You should see your task returned.
  • Now try entering 999 as the task_id. You should see a 404 response with "detail": "Task not found".

Seeing a 422 error instead of 404?

A 422 means the path parameter failed type validation. Make sure you are entering a number (like 999) and not text. FastAPI rejects non-integer values automatically because of the task_id: int type hint.

Help me debug my GET /tasks/{task_id} endpoint.

Add PUT and DELETE endpoints

You can create and read tasks. Now you need to complete the CRUD cycle with update and delete operations.

  • Below the create_task() function at the bottom of main.py, add the PUT endpoint to update a task:
@app.put("/tasks/{task_id}")
def update_task(task_id: int, task_update: dict):
    for task in tasks:
        if task["id"] == task_id:
            # Update only the fields that were provided
            if "title" in task_update:
                task["title"] = task_update["title"]
            if "description" in task_update:
                task["description"] = task_update["description"]
            if "completed" in task_update:
                task["completed"] = task_update["completed"]
            return task
    raise HTTPException(status_code=404, detail="Task not found")

What does this code do?

The PUT endpoint accepts both a path parameter (task_id) and a request body (task_update). It loops through the tasks list, finds the matching task, and only updates the fields that appear in the request body.

This means you can update just the title without touching the description, or mark a task complete without changing anything else.

  • Save main.py.
  • In the Swagger UI, POST a new task to create one.
  • Click PUT /tasks/{task_id}, hit Try it out, enter 1 for task_id, and use this body:
{
  "completed": true
}
  • Click Execute. You should see the task returned with "completed": true while the title and description remain unchanged.

PUT returns a 404 even though you just created the task?

Remember, saving main.py restarts the server and clears the in-memory list. Always POST a fresh task before testing PUT or DELETE.

Why does my PUT endpoint say Task not found?

  • Below the update_task() function, add the DELETE endpoint:
@app.delete("/tasks/{task_id}")
def delete_task(task_id: int):
    for i, task in enumerate(tasks):
        if task["id"] == task_id:
            # Remove the task from the list
            tasks.pop(i)
            return {"message": "Task deleted"}
    raise HTTPException(status_code=404, detail="Task not found")

What does this code do?

The DELETE endpoint uses enumerate() to track both the index and the task while looping. When it finds the matching task, tasks.pop(i) removes it from the list by index. If no match is found, it raises the same 404 error.

  • Save main.py.

Before you test, think about this: if you POST a task, then DELETE it, then try to GET it by ID, what status code do you expect to see?

  • In the Swagger UI, run the full lifecycle: POST a new task, GET /tasks to see it listed, PUT to update it, DELETE to remove it, then GET /tasks again.
  • After deleting, the GET /tasks response should return an empty list. Trying to GET the deleted task by ID returns a 404.

DELETE seems to work but GET /tasks still shows the task?

Make sure the task_id you pass to DELETE matches the ID shown when you created the task. If you restarted the server between creating and deleting, the IDs reset back to 1.

Help me debug my DELETE endpoint.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and that all six endpoints (root, GET /tasks, GET /tasks/{task_id}, POST /tasks, PUT /tasks/{task_id}, DELETE /tasks/{task_id}) appear in your Swagger UI.

ⓧ I'd like to double check the full code

Your main.py should now have these sections, in this order. Scroll up and confirm each is present:

  • Import line: from fastapi import FastAPI, HTTPException.
  • App instance: app = FastAPI().
  • Storage variables: tasks = [] and next_id = 1.
  • root() function returning the welcome message.
  • get_tasks() function returning the tasks list.
  • get_task() function with path parameter and 404 handling.
  • create_task() function with global next_id and task assembly.
  • update_task() function with field-level updates and 404 handling.
  • delete_task() function with enumerate and pop and 404 handling.

You now have a fully working REST API with all four CRUD operations. There is one problem though. Try stopping the server with Ctrl+C and restarting it. All your tasks vanish. In the next step, you will add data validation so invalid requests get rejected before they can create broken tasks.

Add Pydantic Validation and Proper Status Codes

Your CRUD endpoints work, but they have a serious flaw. Right now the API accepts absolutely any data you throw at it.

Try posting a task with no title and the API happily creates it. Send numbers where strings belong and there is no complaint. Every successful response returns a generic 200 status code whether a resource was created, updated, or deleted. Real APIs validate input and use specific status codes to communicate exactly what happened.

In this step, get ready to:
  • Define Pydantic models that enforce a schema on incoming request data.
  • Apply those models to your POST and PUT endpoints so invalid data is rejected automatically.
  • Add proper HTTP status codes (201, 204) to communicate what your API did.
Try sending invalid data to your API

Before fixing anything, see the problem for yourself.

  • Switch back to the Swagger UI at http://127.0.0.1:8000/docs in your browser.
  • Expand the POST /tasks endpoint and click Try it out.
  • Replace the request body with just an empty object:
{}

What do you think will happen when you hit Execute? Will the API reject this empty request, or accept it?

  • Click Execute.

The API returns 200 OK and creates a task with a null title. That is garbage data sitting in your task list. Time to fix this.

Define Pydantic models for request validation

Pydantic models define the exact shape of data your API accepts. If a request does not match the schema, FastAPI rejects it before your handler function even runs.

  • In main.py, find the first import line:
from fastapi import FastAPI, HTTPException
  • Replace it with these two import lines:
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
  • Now find the line next_id = 1. Add the following model classes below it (with a blank line between next_id = 1 and the first class):
class TaskCreate(BaseModel):
    # Required field - must be a string
    title: str
    # Optional field - can be a string or None
    description: str | None = None
    # Defaults to False if not provided
    completed: bool = False


class TaskUpdate(BaseModel):
    # All fields optional for partial updates
    title: str | None = None
    description: str | None = None
    completed: bool | None = None

What do these models do?

TaskCreate requires a title string. The description and completed fields are optional with sensible defaults. Any request missing a title gets rejected.

TaskUpdate makes every field optional. This allows partial updates where a client sends only the fields they want to change.

  • Save main.py.
  • Check your terminal where the dev server is running. You should see a line indicating the server reloaded without errors.

Seeing an error in the terminal?

Make sure both import lines are at the very top of the file. Check that the model classes are indented correctly (4 spaces for each field under the class definition).

help me fix a syntax error in my Pydantic model definitions

Apply models to your endpoints

The models exist but nothing uses them yet. Now you will connect them to the POST and PUT endpoints.

  • In main.py, find your existing create_task function (the POST endpoint). Replace the entire function including its decorator with this code:
@app.post("/tasks")
def create_task(task: TaskCreate):
    global next_id
    # Access fields as attributes, not dict keys
    new_task = {
        "id": next_id,
        "title": task.title,
        "description": task.description,
        "completed": task.completed,
    }
    tasks.append(new_task)
    next_id += 1
    return new_task

What changed here?

The function parameter changed from task: dict to task: TaskCreate. FastAPI now validates the request body against your TaskCreate schema before the function runs.

You access fields with dot notation (task.title) instead of dictionary access (task.get("title")). This gives you autocompletion and type safety.

  • Save main.py and switch back to the Swagger UI at http://127.0.0.1:8000/docs.
  • Refresh the page.
  • Expand POST /tasks, click Try it out, replace the body with {}, and click Execute.

You should see a 422 Unprocessable Entity response. The body shows exactly which field is missing and why. That empty request is now rejected before your code runs.

Still seeing 200 OK for the empty body?

Make sure you refreshed the docs page. The Swagger UI caches the old endpoint definitions. Also confirm the server reloaded successfully in the terminal (no error messages).

help me figure out why my Pydantic validation is not rejecting invalid requests

  • Now test with a valid body. Replace the request body with this and click Execute:
{"title": "Buy groceries"}

You should see a 200 OK response with your new task, including a generated ID, a null description, and completed set to false. The optional fields filled in their defaults.

  • Now replace the update_task function (the PUT endpoint) with this code:
@app.put("/tasks/{task_id}")
def update_task(task_id: int, task_update: TaskUpdate):
    for task in tasks:
        if task["id"] == task_id:
            # Only update fields that were actually sent
            if task_update.title is not None:
                task["title"] = task_update.title
            if task_update.description is not None:
                task["description"] = task_update.description
            if task_update.completed is not None:
                task["completed"] = task_update.completed
            return task
    raise HTTPException(status_code=404, detail="Task not found")

Why check for None instead of checking if a key exists?

With a TaskUpdate model, every field always exists on the object. Fields the client did not send default to None. Checking is not None tells you whether the client explicitly provided that field.

  • Save main.py and refresh the docs page.
  • Expand PUT /tasks/{task_id}, click Try it out, enter 1 for task_id, and send {"completed": true} as the body.

You should see the task with its completed field updated to true while title and description remain unchanged. Partial updates work.

Add proper HTTP status codes

HTTP status codes tell the client exactly what happened. A 200 means the request succeeded, but there are more specific codes. A 201 means a new resource was created. A 204 means success with no response body.

  • In main.py, find the POST endpoint decorator line:
@app.post("/tasks")
  • Replace it with:
@app.post("/tasks", status_code=status.HTTP_201_CREATED)
  • Now find your delete_task function. Find these two lines (the decorator and the return statement inside the if block):
@app.delete("/tasks/{task_id}")
...
            return {"message": "Task deleted"}
  • Replace the decorator line with:
@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
  • Replace return {"message": "Task deleted"} with just:
            return

Why return nothing for DELETE?

A 204 No Content status means the action succeeded but there is nothing to send back. The resource is gone. Returning an empty response communicates this clearly to the client.

  • Save main.py and refresh the docs page.

What status code do you expect when you create a new task now? And what about when you delete one?

  • Expand POST /tasks, click Try it out, send {"title": "Test status codes"}, and click Execute.

The response now shows 201 Created instead of 200. The status code tells you a new resource was born.

  • Expand DELETE /tasks/{task_id}, click Try it out, enter 1 for task_id, and click Execute.

You should see a 204 No Content response with an empty body. The task is gone.

  • For the final check, try posting an invalid request one more time. Send {"title": 123} to POST /tasks.

You get a 422 Unprocessable Entity error with a message explaining that the title field must be a string. Your API now communicates clearly through both validation errors and status codes.

Seeing 200 instead of 201 on POST?

Confirm your decorator reads @app.post("/tasks", status_code=status.HTTP_201_CREATED). Make sure you imported status in the first line of the file. Refresh the docs page after saving.

help me fix my FastAPI status codes

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.py and the server reloaded without errors.

ⓧ I'd like to double check the full code

Here is the full main.py at this point. Scroll up and confirm each section is present in order: imports, models, root endpoint, CRUD endpoints with Pydantic types and status codes.

Your file should contain these sections in this order:

  • Import lines (fastapi with status and pydantic with BaseModel).
  • App instance, tasks list, and next_id counter.
  • TaskCreate and TaskUpdate model classes.
  • Root GET endpoint.
  • GET /tasks and GET /tasks/{task_id} endpoints.
  • POST /tasks with TaskCreate and status 201.
  • PUT /tasks/{task_id} with TaskUpdate.
  • DELETE /tasks/{task_id} with status 204 and bare return.

Your API now validates every request and speaks the language of HTTP status codes. Next up, you will tackle a bigger problem: all your tasks vanish the moment you restart the server.

Persist Data to a JSON File

Your API can now create, read, update, and delete tasks with proper validation and status codes. There is one problem you have not noticed yet.

In this step, you will discover a critical flaw in your current approach and fix it by adding file-based persistence. Your tasks will survive server restarts.

In this step, get ready to:
  • Create helper functions for reading and writing tasks to a file.
  • Update all endpoints to use file-based storage instead of memory.
  • Verify that tasks persist across server restarts.
Discover the data loss problem

You created tasks in the last step using the docs UI. Those tasks live in your server's memory.

  • Switch to the terminal where your server is running.
  • Stop the server by pressing Ctrl+C.
  • Restart the server by running:
fastapi dev main.py
  • Before you check the result, predict: do you think your tasks from earlier survived the restart?
  • Open http://127.0.0.1:8000/docs in your browser.
  • Click the GET /tasks endpoint, hit Try it out, then Execute.

You get back an empty list. Every task you created is gone.

Why did this happen?

Your tasks list lives in Python's memory. When you stop the server process, that memory is released and everything in it disappears.

This is the fundamental problem with in-memory storage. To keep data across restarts, you need to write it to a file or database.

Create helper functions for file storage

You will write three helper functions that handle all file I/O for your tasks. These functions separate your data access logic from your endpoint logic.

  • In main.py, find the imports at the very top of the file:
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel

app = FastAPI()

tasks = []
next_id = 1
  • Replace everything above the TaskCreate class with this code:
import json
from pathlib import Path

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

app = FastAPI()

DATA_FILE = Path("tasks.json")

What does this code do?

  • json lets you convert Python data to JSON strings and back.
  • Path from pathlib gives you a convenient way to work with file paths.
  • DATA_FILE points to tasks.json in your project folder. This replaces the old in-memory tasks list and next_id counter.
  • Now add three helper functions after the TaskUpdate class and before the @app.get("/") decorator by pasting this code:
def load_tasks() -> list[dict]:
    # Read tasks from the JSON file, or return empty list if file missing
    if DATA_FILE.exists():
        return json.loads(DATA_FILE.read_text())
    return []


def save_tasks(tasks: list[dict]) -> None:
    # Write the full task list to the JSON file
    DATA_FILE.write_text(json.dumps(tasks, indent=2))


def get_next_id(tasks: list[dict]) -> int:
    # Calculate the next available ID based on existing tasks
    if not tasks:
        return 1
    return max(task["id"] for task in tasks) + 1

What do these functions do?

  • load_tasks() reads the JSON file and returns a Python list. If the file does not exist yet, it returns an empty list.
  • save_tasks() writes the full task list to the file as formatted JSON.
  • get_next_id() finds the highest existing ID and adds 1. This replaces the old next_id counter that was lost on every restart.
  • Save main.py.

Your server will auto-reload. Check your terminal for any syntax errors.

Seeing an error in the terminal?

  • Make sure you removed the old tasks = [] and next_id = 1 lines when you replaced the top of the file.
  • Check that DATA_FILE = Path("tasks.json") is placed after app = FastAPI().
  • help me fix a syntax error in my FastAPI main.py file after adding helper functions
Update all endpoints to use file storage

Your helper functions are ready but your endpoints still reference the old in-memory list. You will now update each endpoint to load from and save to the file.

  • In main.py, find your get_tasks function:
@app.get("/tasks")
def get_tasks():
    return tasks
  • Replace it with this version that loads tasks from the file:
@app.get("/tasks")
def get_tasks():
    tasks = load_tasks()
    return tasks
  • Find your get_task function:
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    for task in tasks:
        if task["id"] == task_id:
            return task
    raise HTTPException(status_code=404, detail="Task not found")
  • Replace it with this version:
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    tasks = load_tasks()
    for task in tasks:
        if task["id"] == task_id:
            return task
    raise HTTPException(status_code=404, detail="Task not found")
  • Save main.py and test GET /tasks in the docs UI.
  • You should see an empty list returned with no errors.
  • Now find your create_task function and replace it with this version:
@app.post("/tasks", status_code=status.HTTP_201_CREATED)
def create_task(task: TaskCreate):
    # Load existing tasks, add the new one, save back to file
    tasks = load_tasks()
    new_task = {
        "id": get_next_id(tasks),
        "title": task.title,
        "description": task.description,
        "completed": task.completed,
    }
    tasks.append(new_task)
    save_tasks(tasks)
    return new_task

What changed in create_task?

  • tasks = load_tasks() reads the current tasks from the file instead of a global variable.
  • get_next_id(tasks) calculates the ID from existing data instead of a counter.
  • save_tasks(tasks) writes the updated list back to the file after appending.
  • The global next_id line is gone. No more global state.
  • Save main.py and test POST /tasks in the docs UI with this request body:
{
  "title": "Buy groceries",
  "description": "Milk, eggs, bread"
}

You should see a 201 response with your new task. A tasks.json file now exists in your project folder.

  • Find your update_task function and replace it with:
@app.put("/tasks/{task_id}")
def update_task(task_id: int, task_update: TaskUpdate):
    tasks = load_tasks()
    for task in tasks:
        if task["id"] == task_id:
            if task_update.title is not None:
                task["title"] = task_update.title
            if task_update.description is not None:
                task["description"] = task_update.description
            if task_update.completed is not None:
                task["completed"] = task_update.completed
            save_tasks(tasks)
            return task
    raise HTTPException(status_code=404, detail="Task not found")
  • Find your delete_task function and replace it with:
@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int):
    tasks = load_tasks()
    for i, task in enumerate(tasks):
        if task["id"] == task_id:
            tasks.pop(i)
            save_tasks(tasks)
            return
    raise HTTPException(status_code=404, detail="Task not found")

What is the pattern?

Every endpoint now follows the same pattern: load the current data from the file, do its work, then save the updated data back. No endpoint relies on global variables.

  • Save main.py.
  • Test the full lifecycle in the docs UI: use PUT /tasks/1 to mark your task as completed, then DELETE /tasks/1 to remove it.

Getting a NameError or unexpected behavior?

  • Make sure you removed the old global next_id line from create_task and the next_id += 1 line at the end.
  • Confirm every function now starts with tasks = load_tasks() instead of referencing a global tasks variable.
  • help me fix a NameError in my FastAPI endpoints after switching from in-memory storage to file-based storage

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and that your server reloaded without errors.

ⓧ I'd like to double check the full code

Your main.py should now look exactly like this. Scroll through and confirm each section matches:

  • Imports at the top (json, Path, FastAPI, HTTPException, status, BaseModel).
  • DATA_FILE constant after app = FastAPI().
  • Two Pydantic models (TaskCreate and TaskUpdate).
  • Three helper functions (load_tasks, save_tasks, get_next_id).
  • Six endpoint functions, each starting with tasks = load_tasks() (except root).
import json
from pathlib import Path

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

app = FastAPI()

DATA_FILE = Path("tasks.json")


class TaskCreate(BaseModel):
    title: str
    description: str | None = None
    completed: bool = False


class TaskUpdate(BaseModel):
    title: str | None = None
    description: str | None = None
    completed: bool | None = None
def load_tasks() -> list[dict]:
    if DATA_FILE.exists():
        return json.loads(DATA_FILE.read_text())
    return []


def save_tasks(tasks: list[dict]) -> None:
    DATA_FILE.write_text(json.dumps(tasks, indent=2))


def get_next_id(tasks: list[dict]) -> int:
    if not tasks:
        return 1
    return max(task["id"] for task in tasks) + 1


@app.get("/")
def root():
    return {"message": "Task Manager API"}


@app.get("/tasks")
def get_tasks():
    tasks = load_tasks()
    return tasks
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    tasks = load_tasks()
    for task in tasks:
        if task["id"] == task_id:
            return task
    raise HTTPException(status_code=404, detail="Task not found")


@app.post("/tasks", status_code=status.HTTP_201_CREATED)
def create_task(task: TaskCreate):
    tasks = load_tasks()
    new_task = {
        "id": get_next_id(tasks),
        "title": task.title,
        "description": task.description,
        "completed": task.completed,
    }
    tasks.append(new_task)
    save_tasks(tasks)
    return new_task
@app.put("/tasks/{task_id}")
def update_task(task_id: int, task_update: TaskUpdate):
    tasks = load_tasks()
    for task in tasks:
        if task["id"] == task_id:
            if task_update.title is not None:
                task["title"] = task_update.title
            if task_update.description is not None:
                task["description"] = task_update.description
            if task_update.completed is not None:
                task["completed"] = task_update.completed
            save_tasks(tasks)
            return task
    raise HTTPException(status_code=404, detail="Task not found")


@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int):
    tasks = load_tasks()
    for i, task in enumerate(tasks):
        if task["id"] == task_id:
            tasks.pop(i)
            save_tasks(tasks)
            return
    raise HTTPException(status_code=404, detail="Task not found")
Test persistence across restarts

This is the moment of truth. You will recreate the exact scenario that lost your data earlier.

  • In the docs UI, use POST /tasks to create two tasks:
{
  "title": "Buy groceries",
  "description": "Milk, eggs, bread"
}
{
  "title": "Finish project",
  "description": null
}
  • Confirm both tasks exist by running GET /tasks in the docs. You should see both tasks in the response.
  • Now stop the server by pressing Ctrl+C in your terminal.
  • Before you restart, predict: will your tasks still be there this time?
  • Restart the server by running:
fastapi dev main.py
  • Open http://127.0.0.1:8000/docs and run GET /tasks.

Both tasks are still there. Your data survived the restart.

  • Open tasks.json in VS Code to see the raw data your API persisted.

You should see your tasks stored as formatted JSON with IDs, titles, descriptions, and completion status.

Tasks still disappearing after restart?

  • Check that save_tasks(tasks) is called inside create_task, update_task, and delete_task.
  • Confirm tasks.json appears in your project folder after creating a task. If it does not exist, save_tasks is never being called.
  • help me debug why my tasks are not persisting to tasks.json in my FastAPI project

Your API now has real persistence. Next up, you will extend this API with filtering and search in the Secret Mission.

Secret mission

Add Filtering and Search

Real APIs rarely return all records. Users need to filter by status and search by keyword. Can you extend your GET /tasks endpoint with query parameters that let users find exactly the tasks they need?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • A Python virtual environment in .venv (local, no cost).
  • A tasks.json data file in your project folder (local, no cost).
  • A main.py application file (local, no cost).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep this project as a portfolio piece.

  • Your development server keeps running at http://127.0.0.1:8000.
  • Your tasks persist in tasks.json and survive restarts.
  • There are no cloud resources or subscriptions. Everything is local and free.

Pause - I'll come back to this later

Shut down the running server to free up your terminal. Your files stay on disk for when you return.

  • In the terminal where the server is running, press Ctrl+C to stop the development server.
  • Deactivate your virtual environment by running:
deactivate

When you come back, reactivate the virtual environment and restart the server:

.venv\Scripts\activate
fastapi dev main.py

Delete - I don't want to use this again

Remove all project files and start fresh. There are no cloud resources to worry about.

  • In the terminal where the server is running, press Ctrl+C to stop the development server.
  • Deactivate the virtual environment by running:
deactivate
  • Navigate up to your Desktop and delete the project folder.

macOS

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

Windows

cd ~\Desktop
rmdir /s /q task-manager-api
  • Confirm the folder is gone by running:
ls

You should no longer see your project folder listed.

Nice Work!

Nice Work!

You just built a fully functional REST API from scratch using FastAPI and Python. You can now demo every endpoint live in the browser, create and manage tasks, and explain how HTTP requests flow through a backend.

You've learned how to:

  • Build a complete REST API with full CRUD operations (Create, Read, Update, Delete) that handles tasks through HTTP endpoints.
  • Validate incoming requests with Pydantic models that automatically reject bad data and return proper HTTP status codes (201, 204, 404, 422) to communicate outcomes.
  • Add data persistence so tasks survive server restarts, separating your data layer from your route handlers.
  • Secret Mission: Added query parameter filtering and search to your API so users can find tasks by completion status or title keyword.

Ready to quiz yourself?