Build an Expense Tracker API

Build a REST API with FastAPI to create, retrieve, and filter expenses.

Introduction

30 Second Summary

Every app that stores data needs a way to create, read, update, and delete it. Whether it's a budgeting tool, a to-do list, or a social feed, the backend API is what makes that possible.

In this project, you will build a personal expense tracker API using FastAPI in Python. You will create endpoints that accept, store, validate, and filter expense data, then test everything through an auto-generated interactive interface.

What You'll Build

By the end, you will have a running API where you can fire off requests to create expenses, look them up by ID, filter by category or amount range, and see meaningful error messages when something goes wrong.

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

  • A working POST endpoint that accepts JSON expense data and returns the created expense with a unique ID.
  • A GET endpoint with query parameters that filters expenses by category and amount range, just like a React frontend would call your API.
  • Proper HTTP error handling that returns 404 when an expense does not exist and 422 when submitted data is invalid.
  • Secret Mission: Complete the full CRUD cycle by adding PUT and DELETE endpoints to update and remove expenses.

Are there any prerequisites?

No prior experience with FastAPI or REST APIs is needed. This guide includes setup for all required tools. You just need a Mac with VS Code and Python 3.11 installed.

Before We Start

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

Set Up Your FastAPI Project

Before you can write any API code, you need a working Python project with the right tools installed.

In this step, you will install uv (a modern Python package manager) and use it to set up a FastAPI project. uv handles the virtual environment and dependencies in one step, so you skip the manual virtual environment dance.

In this step, get ready to:
  • Install uv on macOS using the official installer.
  • Create a new project directory and add FastAPI with standard dependencies.
  • Create a minimal main.py with a hello world endpoint, run the dev server, and verify it works.
Install uv

uv is a fast Python package manager that replaces pip and virtual environment commands with a single tool. It creates and manages your project environment automatically.

  • Install uv by running this command in your terminal:
curl -LsSf https://astral.sh/uv/install.sh | sh

What does this command do?

This downloads the official uv installer script from Astral and pipes it to your shell to execute. It adds uv to $HOME/.local/bin and updates your shell config so the command is available.

  • Close and reopen your terminal so your shell picks up the new path.
  • Verify the installation by running:
uv --version

✔️ I see a version number

uv is installed and ready to use. Move on to the next substep.

ⓧ Command not found

Your shell has not picked up the new path yet.

  • Make sure you fully closed and reopened your terminal (not just opened a new tab).
  • If that does not work, run this to manually load the path:
source $HOME/.local/bin/env
  • Run uv --version again to confirm it works.

Why uv instead of pip?

FastAPI's official documentation recommends uv as the project and package manager. It handles virtual environments automatically (no manual activate/deactivate), creates a pyproject.toml for reproducible installs, and runs significantly faster than pip.

Create your project and install FastAPI
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create a new project called expense-tracker by running:
uv init expense-tracker --bare
  • Confirm the project was created by running:
ls expense-tracker

You should see pyproject.toml listed in the output. This file defines your project and its dependencies.

  • Move into your project folder by running:
cd expense-tracker
  • Add FastAPI and its standard dependencies by running:
uv add "fastapi[standard]"

You will see uv create a virtual environment and install packages. This takes a few seconds.

What does fastapi[standard] mean?

The [standard] part installs FastAPI along with the dependencies you need for development, including the Uvicorn server and the fastapi CLI tool. Without it, you would need to install those extras separately.

Seeing a resolution error?

Make sure you are inside the expense-tracker folder (not still on your Desktop). Run pwd to check your current directory.

If you see a Python version error, confirm your Python version with python3 --version and ensure it shows 3.10 or higher.

Still stuck? Help me fix a uv add error for FastAPI

Create your first endpoint and start the server

Your project has FastAPI installed, but no application code yet. You will create a minimal endpoint that proves everything is wired up correctly.

  • In VS Code, open the expense-tracker folder by clicking **File** in the top menu bar.
  • Click **Open Folder**.
  • Select the expense-tracker folder on your Desktop, then click **Open**.
  • Create a new file called main.py by clicking the new file icon in the **Explorer** sidebar (or pressing **Cmd+N** then **Cmd+S** and naming it main.py).
  • Paste the following code into main.py:
from fastapi import FastAPI

# Create the FastAPI application instance
app = FastAPI()


# Define a root endpoint that returns a welcome message
@app.get("/")
def read_root():
    return {"message": "Expense Tracker API"}

What does this code do?

  • from fastapi import FastAPI imports the framework you just installed.
  • app = FastAPI() creates your application instance. This is the object that receives and routes all HTTP requests.
  • @app.get("/") registers the function below it as the handler for GET requests to the root URL (/).
  • The function returns a Python dictionary, which FastAPI automatically converts to a JSON response.
  • Save main.py with **Cmd+S**.

Now it is time to start the development server and see your endpoint live.

  • Switch back to your terminal (make sure you are still inside the expense-tracker folder).

Before you run the next command, what do you think will happen? Will it start a server, show an error, or do nothing?

  • Start the FastAPI development server by running:
uv run fastapi dev

You will see output showing Uvicorn starting on http://127.0.0.1:8000 with auto-reload enabled. The terminal stays occupied running the server.

  • Open your browser and go to http://127.0.0.1:8000/docs.

You should see the Swagger UI with your root endpoint (GET /) listed. This is FastAPI's auto-generated interactive documentation for your API.

Not seeing the Swagger UI?

Make sure the server is still running in your terminal. If you see an error mentioning the app could not be found, confirm your file is named exactly main.py (not Main.py or main.py.py).

Check that your app = FastAPI() line uses the variable name app. The fastapi dev command auto-detects an object named app in main.py.

Still stuck? Help me get my FastAPI dev server running

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete main.py at this point:

from fastapi import FastAPI

# Create the FastAPI application instance
app = FastAPI()


# Define a root endpoint that returns a welcome message
@app.get("/")
def read_root():
    return {"message": "Expense Tracker API"}

Your FastAPI project is running and serving a live API with interactive documentation. Next up, you will define what an expense looks like and build your first real endpoint to create expenses.

Create the Expense Model and POST Endpoint

Your API has a running server, but it cannot do anything useful yet. A REST API needs two things to accept data: a model that defines the shape of the data, and an endpoint that receives it.

In this step, you will define what an expense looks like using a Pydantic data model, then create a POST endpoint that accepts expense data and stores it in memory.

In this step, get ready to:
  • Define a Pydantic model that describes the shape of an expense.
  • Create an in-memory store and a POST endpoint that accepts new expenses.
  • Test creating expenses through Swagger UI and see them returned in the response.
Define the expense data model

Pydantic models let you declare the exact fields and types your API expects. FastAPI uses these models to automatically validate incoming requests and generate documentation.

  • In main.py, find this import at the top of your file:
from fastapi import FastAPI
  • Add a new import directly below it:
from pydantic import BaseModel
  • Now find the app = FastAPI() line. Add the following two model classes below it, leaving a blank line between app = FastAPI() and the first class:
class ExpenseCreate(BaseModel):
    # Fields the user provides when creating an expense
    amount: float
    category: str
    description: str = ""
    date: str


class Expense(ExpenseCreate):
    # Adds a unique ID assigned by the server
    id: int

What do these models do?

ExpenseCreate defines the fields a user sends when creating an expense. amount is a float, category and date are strings, and description defaults to an empty string if not provided.

Expense inherits everything from ExpenseCreate and adds an id field. The server assigns this ID, so the user never sends it.

  • Save main.py.
  • Check the terminal where your dev server is running. It should restart automatically without any errors.

Seeing an error in the terminal?

  • Check that from pydantic import BaseModel is on its own line directly below from fastapi import FastAPI.
  • Make sure both classes are indented correctly. Each field inside a class must be indented by 4 spaces.
  • Confirm that class Expense(ExpenseCreate): references ExpenseCreate exactly (capital E, capital C).

Why is my FastAPI server showing an import or syntax error?

Add the POST endpoint and in-memory storage

Your models define the data shape, but you still need somewhere to store expenses and an endpoint that accepts them. The POST endpoint is how a frontend sends new data to your API.

  • In main.py, find the @app.get("/") decorator. Add these two storage variables above it (between your Expense class and the @app.get("/") line):
# In-memory storage for expenses
expenses: list[Expense] = []
next_id: int = 1
  • Now scroll to the bottom of main.py, below the read_root function. Add the POST endpoint:
@app.post("/expenses")
def create_expense(expense_data: ExpenseCreate):
    global next_id
    # Create an Expense with the next available ID
    expense = Expense(id=next_id, **expense_data.model_dump())
    expenses.append(expense)
    next_id += 1
    return expense

What does this endpoint do?

  • expenses is a Python list that holds all created expenses in memory. When you stop the server, this data disappears.
  • next_id is a counter that assigns each new expense a unique ID, starting at 1.
  • expense_data.model_dump() converts the Pydantic model into a dictionary, then ** unpacks it so you can pass all fields to the Expense constructor along with the new id.
  • Save main.py.
  • Return to Swagger UI at http://127.0.0.1:8000/docs in your browser and refresh the page.
  • You should see a new green POST /expenses section in the endpoint list.

Don't see the POST endpoint?

  • Make sure you saved main.py after adding the endpoint.
  • Check the terminal for errors. A missing comma or wrong indentation will prevent the server from reloading.
  • Confirm the @app.post("/expenses") decorator is directly above def create_expense with no blank lines between them.

Why isn't my new POST endpoint showing up in Swagger UI?

Test creating an expense in Swagger UI

Your endpoint is live. Time to send it some data and see what comes back.

  • In Swagger UI, click on the POST /expenses section to expand it.
  • Click the Try it out button on the right side.

Before you hit Execute, what do you think the response will contain? Will it have an id field even though you did not send one?

  • Replace the example JSON in the request body with this:
{
  "amount": 25.50,
  "category": "food",
  "description": "Lunch",
  "date": "2026-08-11"
}
  • Click Execute.

You should see a response with status 200 containing your expense data plus an id of 1 that the server assigned automatically.

  • Try creating a second expense. Click Execute again with different values (change the category to transport or the amount to 12.00).
  • Notice the id in the response increments to 2.

Why does the ID increment?

The next_id counter increases by 1 each time you create an expense. This gives every expense a unique identifier, which you will use later to retrieve individual expenses by their ID.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and your Swagger UI shows the POST endpoint working.

ⓧ I'd like to double check the full code

Scroll up and confirm your main.py contains each of these sections in order:

  • Two imports at the top: from fastapi import FastAPI and from pydantic import BaseModel.
  • app = FastAPI() line.
  • ExpenseCreate class with four fields (amount, category, description, date).
  • Expense class inheriting from ExpenseCreate with one additional field (id).
  • expenses list and next_id variable.
  • The original read_root function with @app.get("/").
  • The new create_expense function with @app.post("/expenses").

Your API can now accept and store expense data. Next up, you will add GET endpoints to retrieve those expenses by ID and as a list.

Add GET Endpoints and Hit the Wall

Your API can now create expenses and store them in memory. The next challenge is retrieving that data.

In this step, you will add two GET endpoints. One returns all expenses, and the other looks up a single expense by its ID. Along the way, you will discover a subtle bug that would confuse any frontend developer consuming your API.

In this step, get ready to:
  • Add a GET endpoint that returns all stored expenses.
  • Add a GET endpoint that retrieves a single expense by its ID.
  • Discover what happens when you request an expense that does not exist.
Add the list all expenses endpoint

Right now, the only way to see an expense is through the POST response at the moment you create it. A dedicated GET endpoint lets you retrieve all expenses at once.

  • In main.py, add the following code below your existing create_expense function:
# Return all expenses in the list
@app.get("/expenses")
def list_expenses():
    return expenses

What does this code do?

  • @app.get("/expenses") registers a GET endpoint at the /expenses path.
  • return expenses sends back the entire in-memory list as a JSON array. FastAPI automatically serializes your Pydantic models to JSON.
  • Save main.py.
  • Switch back to Swagger UI at http://127.0.0.1:8000/docs. The dev server auto-reloads on save, which resets the in-memory data.
  • Use the POST /expenses endpoint to create an expense (click **Try it out**, paste the JSON body, then click **Execute**).
  • Now expand the new GET /expenses endpoint, click **Try it out**, then **Execute**.
  • You should see a JSON array containing the expense you just created.

Seeing an empty array?

The dev server restarts every time you save main.py. This wipes the in-memory expenses list. Make sure you create an expense via POST before calling GET.

If the endpoint does not appear at all, check that your new function is outside the create_expense function body (not indented under it).

help me debug my GET /expenses endpoint

Add the get single expense endpoint

A list endpoint returns everything at once. You also need a way to fetch one specific expense by its ID. This uses a path parameter, which is a variable embedded in the URL itself.

  • In main.py, add the following code below the list_expenses function you just wrote:
# Look up a single expense by its ID
@app.get("/expenses/{expense_id}")
def get_expense(expense_id: int):
    for expense in expenses:
        if expense.id == expense_id:
            return expense
    return None

What does this code do?

{expense_id} in the path tells FastAPI this is a path parameter. When someone requests /expenses/3, FastAPI passes 3 as the expense_id argument.

The function loops through the expenses list looking for a match. If it finds one, it returns that expense. If the loop finishes without finding anything, the function returns None.

  • Save main.py.
  • Return to Swagger UI. The server restarted, so create a new expense via POST /expenses first.
  • Expand the new GET /expenses/{expense_id} endpoint, click **Try it out**, enter 1 for the expense_id field, and click **Execute**.
  • You should see your expense returned as a JSON object with all its fields.

Getting a validation error?

Make sure you type a number (like 1) in the expense_id field, not text. FastAPI validates that the path parameter matches the int type hint you declared.

help me fix my GET /expenses/{expense_id} endpoint

Request an expense that does not exist

Your endpoint works perfectly for expenses that exist. But what happens when a frontend requests an expense with an ID that was never created?

Before you try this, think about what you expect to happen. If you request GET /expenses/999 and no expense has ID 999, what should the API return? What status code would be appropriate?

  • In Swagger UI, expand GET /expenses/{expense_id} again.
  • Click **Try it out**, enter 999 for the expense_id, and click **Execute**.

Look at the response. You will see a response body of null with a status code of 200.

Why is this a problem?

A 200 status code means "everything is fine." But everything is not fine. The expense does not exist. Your API is lying to the client.

In Python, when a function reaches the end without an explicit return statement (or returns None), FastAPI serializes that as null in JSON and wraps it in a 200 OK response. A React frontend calling this endpoint would need to check if the response body is null before using it. That is fragile and error-prone.

The correct behavior is to return a 404 Not Found status code so the client knows immediately that the resource does not exist. You will fix this in the next step.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and both GET endpoints appear in Swagger UI.

ⓧ I'd like to double check the full code

Your main.py should contain, in order:

  • Imports (FastAPI and BaseModel).
  • The app = FastAPI() instance.
  • The ExpenseCreate and Expense model classes.
  • The expenses list and next_id variable.
  • The GET / root endpoint (read_root).
  • The POST /expenses endpoint (create_expense).
  • The GET /expenses endpoint (list_expenses).
  • The GET /expenses/{expense_id} endpoint (get_expense) ending with return None.

Scroll up and confirm each chunk is present in your file, in this order.

You have hit the wall. Your API happily returns null with a 200 when data does not exist. Next up, you will fix this with proper HTTP error handling that tells the client exactly what went wrong.

Fix Errors with HTTPException and Validation

In the last step, you discovered the bug: requesting an expense that does not exist returns null with a 200 status code. Any frontend consuming this API would have no idea something went wrong.

A proper API tells the truth. It returns a 404 when a resource does not exist, and rejects invalid data before it reaches your logic. In this step, you will add both of these safeguards using HTTPException and Pydantic Field validation.

In this step, get ready to:
  • Add proper 404 errors with HTTPException when an expense is not found.
  • Add input validation so invalid data (negative amounts, empty categories) gets rejected.
  • Test that the API returns correct HTTP status codes for both cases.
Import HTTPException and fix the error response

When a Python function reaches the end without an explicit return, it returns None. FastAPI serialises that as null in JSON with a 200 status. The fix is to raise an HTTPException instead, which tells FastAPI to stop and return an error response.

  • In main.py, find the first import line at the top of the file:
from fastapi import FastAPI
  • Add HTTPException to the import so the line now looks like this:
from fastapi import FastAPI, HTTPException
  • Now scroll down to the @app.post("/expenses") decorator line:
@app.post("/expenses")
  • Add status_code=201 so the decorator returns the correct HTTP status for resource creation:
@app.post("/expenses", status_code=201)
  • Finally, scroll down to the last line of the get_expense function. Find this line:
    return None
  • Replace it with a proper 404 error:
    raise HTTPException(status_code=404, detail="Expense not found")

What does raise HTTPException do?

raise stops the function immediately. FastAPI catches the HTTPException and converts it into a JSON response with the status code and detail message you specified.

Instead of null with a 200, the client now receives {"detail": "Expense not found"} with a 404 status code.

  • Save main.py. Your dev server auto-reloads.
  • Switch back to Swagger UI at http://127.0.0.1:8000/docs in your browser.
  • Expand the GET /expenses/{expense_id} endpoint and click Try it out.
  • Enter 999 in the expense_id field and click Execute.

You should see a response with status code 404 and a body of {"detail": "Expense not found"}.

Still seeing null with a 200?

  • Make sure you saved main.py. Check your terminal for the auto-reload confirmation message.
  • Confirm you replaced return None with raise HTTPException(...). The keyword raise is critical.
  • Check that HTTPException appears in your import line at the top of the file.

Help me debug my HTTPException not returning a 404

Add Field validation to the expense model

Right now, your API happily accepts an expense with a negative amount or an empty category. Pydantic's Field function lets you add constraints directly to your model so invalid data gets rejected before your endpoint logic even runs.

  • In main.py, find the second import line:
from pydantic import BaseModel
  • Add Field to the import so it looks like this:
from pydantic import BaseModel, Field
  • Now find the ExpenseCreate class. It currently looks like this:
class ExpenseCreate(BaseModel):
    amount: float
    category: str
    description: str = ""
    date: str
  • Replace the amount and category lines with Field validation so the class looks like this:
class ExpenseCreate(BaseModel):
    amount: float = Field(gt=0, description="Expense amount, must be positive")
    category: str = Field(min_length=1, description="Expense category")
    description: str = ""
    date: str

What do these Field constraints do?

  • gt=0 means "greater than 0". Any expense with a zero or negative amount gets rejected automatically.
  • min_length=1 means the category string must have at least one character. An empty string gets rejected.
  • FastAPI returns a 422 Unprocessable Entity response with a detailed error message explaining exactly which field failed and why.
  • Save main.py.
  • In Swagger UI, expand the POST /expenses endpoint and click Try it out.
  • Replace the request body with this invalid expense:
{
  "amount": -5,
  "category": "food",
  "description": "test",
  "date": "2026-08-11"
}
  • Click Execute.

You should see a 422 response with a detailed validation error explaining that amount must be greater than 0.

Not seeing a 422 error?

  • Make sure the Field import is on the pydantic line, not the fastapi line.
  • Check that amount: float = Field(gt=0, ...) uses a single equals sign. A double equals (==) is a comparison, not an assignment.
  • Confirm the server reloaded by checking your terminal for the reload log line.

Help me debug why my Field validation is not rejecting invalid data

Verify the complete error handling

Before you confirm everything works together, predict what will happen: if you send a GET request for expense ID 999 and a POST with an empty category, do you think both will be rejected?

  • In Swagger UI, expand GET /expenses/{expense_id}, click Try it out, enter 999, and click Execute.
  • Confirm you see status 404 with the body {"detail": "Expense not found"}.
  • Now expand POST /expenses, click Try it out, and submit this body:
{
  "amount": 10,
  "category": "",
  "description": "test",
  "date": "2026-08-11"
}
  • Click Execute.
  • Confirm you see a 422 response with a validation error about category needing at least 1 character.

Both checks pass. Your API now returns honest, meaningful error responses that any frontend can handle in a catch block.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete main.py at this point:

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

app = FastAPI()


class ExpenseCreate(BaseModel):
    amount: float = Field(gt=0, description="Expense amount, must be positive")
    category: str = Field(min_length=1, description="Expense category")
    description: str = ""
    date: str
class Expense(ExpenseCreate):
    id: int


expenses: list[Expense] = []
next_id: int = 1


@app.get("/")
def read_root():
    return {"message": "Expense Tracker API"}
@app.post("/expenses", status_code=201)
def create_expense(expense_data: ExpenseCreate):
    global next_id
    expense = Expense(id=next_id, **expense_data.model_dump())
    expenses.append(expense)
    next_id += 1
    return expense
@app.get("/expenses")
def list_expenses():
    return expenses


@app.get("/expenses/{expense_id}")
def get_expense(expense_id: int):
    for expense in expenses:
        if expense.id == expense_id:
            return expense
    raise HTTPException(status_code=404, detail="Expense not found")

Your API now rejects bad requests with clear, structured errors. Next up, you will add query parameter filtering so your endpoints work the way a React frontend would actually call them.

Filter Expenses with Query Parameters

Your API now returns proper 404 errors and rejects invalid data with meaningful status codes. The next piece to add is filtering.

A real frontend never fetches all data at once. Your React app would call GET /expenses?category=food&min_amount=10 to show filtered results. Query parameters let you add this filtering without changing your URL structure.

In this step, get ready to:
  • Add optional query parameters to the GET /expenses endpoint.
  • Write filtering logic that chains each parameter when provided.
  • Test various filter combinations in Swagger UI.
Add query parameters and filtering logic

In FastAPI, you make a parameter optional by giving it a default value of None. FastAPI automatically reads these from the URL query string rather than the path.

  • In main.py, find the list_expenses function. It currently looks like this:
@app.get("/expenses")
def list_expenses():
    return expenses
  • Replace the entire list_expenses function with this updated version that accepts query parameters and filters the results:
@app.get("/expenses")
def list_expenses(
    category: str | None = None,
    min_amount: float | None = None,
    max_amount: float | None = None,
):
    # Start with all expenses
    result = expenses

    # Apply each filter only when the parameter is provided
    if category is not None:
        result = [e for e in result if e.category == category]
    if min_amount is not None:
        result = [e for e in result if e.amount >= min_amount]
    if max_amount is not None:
        result = [e for e in result if e.amount <= max_amount]

    return result

What does this code do?

  • category: str | None = None declares an optional query parameter. When the caller includes ?category=food in the URL, FastAPI passes it in. Otherwise it stays None.
  • result = expenses starts with the full list. Each if block narrows the list only when that specific filter is provided.
  • Filters chain together. If someone passes both category and min_amount, both conditions apply to the result.
  • Save main.py.
  • Switch back to your browser at http://127.0.0.1:8000/docs and refresh the page.
  • Click on the GET /expenses endpoint to expand it. You should see three new parameter fields: category, min_amount, and max_amount.

Don't see the parameter fields?

  • Make sure you saved main.py. The dev server auto-reloads on save, but unsaved changes won't take effect.
  • Check that your function signature has the three parameters separated by commas, each on its own line.
  • Look at your terminal for error messages. A syntax error in main.py will prevent the server from reloading.

Why aren't my query parameters showing in Swagger UI?

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

Your main.py is now longer than 30 lines. Scroll through your file and confirm these sections are present in order:

  • Imports: FastAPI, HTTPException, BaseModel, and Field.
  • ExpenseCreate model with Field validation on amount and category.
  • Expense model extending ExpenseCreate with id.
  • In-memory expenses list and next_id counter.
  • GET /, POST /expenses, GET /expenses (with query params and filtering), GET /expenses/{expense_id} (with 404).
Test filter combinations in Swagger UI

Since the server restarted when you saved, your in-memory expense list is empty. You need fresh test data to verify filtering works.

  • In Swagger UI, use the POST /expenses endpoint to create these three expenses one at a time. Click **Try it out**, paste the JSON, and click **Execute** for each:
{"amount": 25.50, "category": "food", "description": "Lunch", "date": "2026-08-11"}
{"amount": 8.00, "category": "food", "description": "Coffee", "date": "2026-08-11"}
{"amount": 55.00, "category": "transport", "description": "Taxi", "date": "2026-08-11"}

You should see each expense returned with a 201 status and an assigned id.

Now test the filtering. Before you run each query, predict what you expect to see in the response.

  • Expand the GET /expenses endpoint and click **Try it out**.
  • Enter food in the category field. Leave the other fields empty.
  • How many expenses do you expect to see returned?
  • Click **Execute**.

You should see only the two food expenses (Lunch and Coffee) in the response. The transport expense is filtered out.

  • Clear the category field.
  • Enter 20 in the min_amount field and 100 in the max_amount field.
  • Click **Execute**.

You should see only the Lunch (25.50) and Taxi (55.00) expenses. The Coffee (8.00) is below the minimum amount of 20.

Why optional parameters instead of required ones?

Making every filter optional means the endpoint stays flexible. A frontend can call GET /expenses with no parameters to get everything, or combine any filters it needs.

This is the same pattern you see in production APIs. Each filter is independently useful, and they compose together without requiring the client to always provide all of them.

Your expense tracker API is complete. You can create expenses, retrieve them individually with proper 404 handling, and filter the list with any combination of query parameters. Next up is the Secret Mission, where you will add PUT and DELETE endpoints to round out the full CRUD.

Secret mission

Complete the CRUD with PUT and DELETE

Your API can create and read expenses, but what about updating or removing them? A production API needs all four CRUD operations. In this challenge, you will add PUT and DELETE endpoints that combine everything you have learned so far.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine with no cloud resources or paid services. Decide whether to keep your project files on disk or delete them entirely.

Resources you used:

  • The expense-tracker project directory on your Desktop (contains main.py, pyproject.toml, uv.lock, and the .venv virtual environment).

Keep everything running

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

  • Your project directory stays on your Desktop. Run uv run fastapi dev from inside the expense-tracker folder anytime to restart the API.
  • uv remains installed on your system at ~/.local/bin/uv. You can use it for future Python projects.

Pause - I'll come back to this later

Stop the dev server to free up your terminal. Your project files stay on disk for when you return.

  • If the dev server is still running, press Ctrl+C in the terminal to stop it.
  • Your expense-tracker folder and all its files remain on your Desktop. When you are ready to resume, open a terminal, cd into the folder, and run uv run fastapi dev again.

Delete - I don't want to use this again

Remove all project files from your machine. This cannot be undone.

  • If the dev server is still running, press Ctrl+C in the terminal to stop it.
  • Delete the project folder by running this command from your Desktop:
rm -rf ~/Desktop/expense-tracker

On Windows, use this command instead:

Remove-Item -Recurse -Force ~\Desktop\expense-tracker
  • Confirm the folder is gone by running:
ls ~/Desktop | grep expense-tracker

You should see no output, confirming the folder has been removed.

Nice Work!

Nice Work!

You just built a personal expense tracker API from scratch using FastAPI. Your API handles the full lifecycle of expense data with proper validation, meaningful error codes, and flexible filtering.

Here's what you learned:

  • Built a REST API with POST and GET endpoints that create, store, and retrieve expense data through FastAPI.
  • Implemented proper HTTP error handling with HTTPException for 404 responses and Pydantic Field validation for 422 errors that reject invalid data before it reaches your logic.
  • Added query parameter filtering by category and amount range, mirroring how a React frontend would consume your API.
  • Secret Mission: Completed the full CRUD cycle by adding PUT and DELETE endpoints to update and remove expenses.

Ready to quiz yourself?