Build a Personal Finance REST API

Build a FastAPI REST API that tracks income and expenses with SQLite.

Introduction

30 Second Summary

Everyone tracks their spending. Whether it's a spreadsheet or a budgeting app, behind every one of those tools is an API that stores, validates, and retrieves financial data.

In this project, you will build WalletWatch, a personal finance REST API powered by FastAPI. You'll record transactions, validate all input, persist data to a local database, and see your spending broken down by category.

What You'll Build

When you're done, you'll open interactive Swagger UI docs in your browser, create transactions like "Groceries: $47.50" and "Netflix: $15.99", filter them by category or date range, and watch a summary endpoint break down your total spending by category.

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

  • An API that validates every transaction automatically, rejecting negative amounts and empty descriptions with clear error messages.
  • Filtered retrieval that lets you narrow transactions by category, date range, or both, with built-in pagination.
  • A spending summary endpoint that totals your expenses per category over any date range you specify.
  • Secret Mission: Add UPDATE and DELETE endpoints to complete the full CRUD lifecycle.

Are there any prerequisites?

You'll need basic Python knowledge: writing functions, using classes, and type hints. No web development or database experience required.

Before We Start

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

Taking a moment to articulate the goal gives you a mental anchor for every decision in the project ahead.

Set Up Your Project with uv and FastAPI

Before you can write any API code, you need a working project environment.

In this step, you will install uv, a modern Python package manager that handles virtual environments automatically. Then you will create your WalletWatch project, add FastAPI, and confirm that your dev server is running with live interactive docs.

In this step, get ready to:
  • Install uv and verify it works.
  • Create the walletwatch project and add FastAPI.
  • Create a minimal FastAPI app and see it running in your browser.
Install uv

uv is a Python package manager that replaces pip, venv, and pip-tools with a single fast tool. It creates virtual environments automatically when you add packages, so you never have to manage them yourself.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Install uv by running this command:

macOS

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. It installs the uv binary and adds it to your PATH.

Windows

powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

What does this command do?

This downloads the official uv installer script from Astral and runs it in PowerShell. The -ExecutionPolicy ByPass flag allows the script to run without changing your system's execution policy permanently.

  • Close your terminal completely, then reopen it.
  • Verify uv is installed by running:
uv --version

✔️ I see a version number

You are all set. uv is installed and ready to use.

ⓧ Command not found

Your terminal cannot find the uv binary. This usually means the PATH was not updated after installation.

  • Make sure you fully closed and reopened your terminal after the install.
  • On macOS, try opening a new terminal tab or window.
  • On Windows, close all PowerShell windows and open a fresh one.
  • If it still does not work, run the install command again and watch for error messages.

Still seeing command not found?

The installer adds uv to your shell profile, but some shells need a manual reload. On macOS, try running source ~/.zshrc or source ~/.bashrc depending on your shell.

Help me fix my PATH

Create the project and add FastAPI

Now you will create a new Python project folder and add FastAPI as a dependency. uv handles the virtual environment creation behind the scenes.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
uv init walletwatch --bare
cd walletwatch

What does this command do?

uv init walletwatch --bare creates a new folder called walletwatch with only a pyproject.toml file inside. The --bare flag skips generating sample Python files you do not need.

  • Add FastAPI with its standard extras by running:
uv add "fastapi[standard]"

You will see uv create a .venv folder, download FastAPI and its dependencies, and generate a uv.lock file.

What is fastapi[standard]?

The [standard] extra installs everything you need for local development, including the uvicorn server and the fastapi CLI. Without it, you would have to install those pieces separately.

  • Confirm your project files exist by running:
ls

You should see pyproject.toml and uv.lock listed in the output.

Don't see pyproject.toml?

Make sure you ran cd walletwatch after the init command. If you skipped it, you are still in the Desktop directory looking at the folder from outside.

Help me find my project files

Create main.py and run the dev server

FastAPI needs a Python file with an app object to serve. You will create the smallest possible API and launch it.

  • Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the walletwatch folder, and click Open.
  • Create a new file called main.py by right-clicking in the Explorer sidebar and selecting New File.
  • Name it main.py and press Enter.
  • Paste the following code into main.py:
from fastapi import FastAPI

# Create the FastAPI app instance with a title and version
app = FastAPI(title="WalletWatch", version="0.1.0")

# A simple root endpoint to confirm the API is running
@app.get("/")
def root():
    return {"message": "WalletWatch API is running"}

What does this code do?

  • from fastapi import FastAPI imports the framework class.
  • app = FastAPI(...) creates your API application. The title and version show up in the auto-generated docs.
  • @app.get("/") registers a route at the root URL that responds to GET requests.
  • The function returns a Python dictionary, which FastAPI automatically converts to JSON.
  • Save main.py with Cmd+S (macOS) or Ctrl+S (Windows).
  • Open a terminal inside VS Code by pressing Ctrl+` (backtick key, same on both platforms).
  • Start the FastAPI dev server by running:
uv run fastapi dev

What does this command do?

uv run executes the command inside your project's virtual environment without you needing to activate it manually. fastapi dev launches the development server with auto-reload enabled, so changes to your code take effect immediately.

You will see output in your terminal showing the server is running at http://127.0.0.1:8000.

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

Before you look, what do you think you will see at this URL?

You should see the FastAPI interactive documentation page (Swagger UI) with your app title "WalletWatch" at the top and the GET / endpoint listed below it.

Seeing a connection error in your browser?

  • Check your terminal for error messages. If you see a module not found error, make sure you ran uv add "fastapi[standard]" earlier.
  • Make sure the server is still running. Look for the "Uvicorn running" message in your terminal.
  • Confirm the URL is exactly http://127.0.0.1:8000/docs with no typos.

Help me troubleshoot my dev server

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.py and leave the dev server running in your terminal.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point:

from fastapi import FastAPI

app = FastAPI(title="WalletWatch", version="0.1.0")

@app.get("/")
def root():
    return {"message": "WalletWatch API is running"}

Your environment is ready and your API is alive. Next up, you will build your first real endpoints that create and retrieve financial transactions.

Build Your First Transactions Endpoint

Your project environment is ready and FastAPI is serving docs at /docs. Now it is time to make the API actually do something.

The fastest way to understand a REST API is to see one respond to your requests in real time. In this step, you will build a POST endpoint to create financial transactions and a GET endpoint to retrieve them.

In this step, get ready to:
  • Define a Transaction data model with Pydantic.
  • Create POST and GET endpoints for /transactions/.
  • Test both endpoints live in Swagger UI.
Define the Transaction model

Before your API can accept transaction data, it needs to know what a transaction looks like. A Pydantic model defines the exact shape of the data your endpoint will accept.

  • In main.py, find the line from fastapi import FastAPI and add this import directly below it:
from pydantic import BaseModel
  • Now find the line app = FastAPI(title="WalletWatch", version="0.1.0") and add the Transaction model below it:
class Transaction(BaseModel):
    amount: float
    category: str
    description: str
    date: str

transactions_db: list[dict] = []

What does this code do?

The Transaction class inherits from Pydantic's BaseModel. This means FastAPI will automatically validate incoming data against these field types before your endpoint code runs.

The transactions_db list acts as a simple in-memory store. Every transaction you create gets appended here.

  • Save main.py.
  • Check your terminal. You should see a reload message confirming the dev server picked up your changes without errors.

Seeing an error in the terminal?

Make sure class Transaction(BaseModel): is placed below the app = FastAPI(...) line, not inside another function. Check that the import from pydantic import BaseModel is on its own line directly below the FastAPI import.

Help me debug this issue

Create the POST and GET endpoints

With the model defined, you can now create endpoints that use it. A POST endpoint accepts new data from the client. A GET endpoint returns stored data back.

  • In main.py, find the root() function and add the POST endpoint below it:
@app.post("/transactions/")
def create_transaction(transaction: Transaction):
    # Convert the model to a dict and assign a simple incrementing ID
    new_transaction = transaction.model_dump()
    new_transaction["id"] = len(transactions_db) + 1
    transactions_db.append(new_transaction)
    return new_transaction

What does this endpoint do?

  • The @app.post decorator tells FastAPI this function handles POST requests to /transactions/.
  • The transaction: Transaction parameter means FastAPI will parse the request body and validate it against your model automatically.
  • The .model_dump() method converts the Pydantic model into a plain dictionary so you can add the id field before storing it.
  • Save main.py and refresh http://127.0.0.1:8000/docs in your browser.
  • You should see a new green POST /transactions/ row in the Swagger docs.

Don't see the POST endpoint?

Make sure your code is placed after the root() function, not inside it. The @app.post decorator must be at the top level of the file, aligned with @app.get("/").

Help me figure out what's wrong

  • Now add the GET endpoint directly below the create_transaction function:
@app.get("/transactions/")
def read_transactions():
    # Return everything in our in-memory store
    return transactions_db

What does this endpoint do?

This returns the entire transactions_db list as a JSON array. FastAPI automatically serializes Python lists and dicts into JSON responses.

  • Save main.py and refresh /docs again.
  • You should now see both POST /transactions/ and a second GET /transactions/ row listed beneath your original GET / endpoint.

Only see one endpoint?

Check that @app.get("/transactions/") has a different path from @app.get("/"). Both trailing slashes matter. Also confirm the function name read_transactions is unique and not a duplicate of root.

Help me debug my endpoints

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py before moving on to testing.

ⓧ I'd like to double check the full code

Here is what your entire main.py should look like at this point:

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(title="WalletWatch", version="0.1.0")

class Transaction(BaseModel):
    amount: float
    category: str
    description: str
    date: str

transactions_db: list[dict] = []

@app.get("/")
def root():
    return {"message": "WalletWatch API is running"}

@app.post("/transactions/")
def create_transaction(transaction: Transaction):
    new_transaction = transaction.model_dump()
    new_transaction["id"] = len(transactions_db) + 1
    transactions_db.append(new_transaction)
    return new_transaction

@app.get("/transactions/")
def read_transactions():
    return transactions_db
Test your endpoints in Swagger UI

Your endpoints are wired up. Time to send real requests and see them respond.

  • In the Swagger docs at /docs, click the POST /transactions/ row to expand it.
  • Click Try it out.
  • Replace the example JSON in the request body with this:
{
  "amount": 47.50,
  "category": "food",
  "description": "Grocery shopping",
  "date": "2026-08-12"
}
  • Click Execute.
  • In the response body below, you should see the same transaction data returned with an "id": 1 field added.

Before you test the GET endpoint, think about this: what do you expect it to return right now?

  • Collapse the POST section and expand GET /transactions/.
  • Click Try it out, then Execute.

You should see a JSON array containing the grocery transaction you just created, complete with its id, amount, category, description, and date fields.

What just happened?

You sent an HTTP POST request carrying JSON data. FastAPI validated it against your Transaction model, converted it to a dictionary, assigned an ID, and stored it in memory.

The GET request then retrieved everything from that same in-memory list. This is the core of CRUD (Create, Read, Update, Delete). You just built the C and the R.

Your endpoints are live and responding to requests. Next up, you will add validation rules that automatically reject bad data like negative amounts and empty descriptions.

Add Data Validation

Your API can create and list transactions through Swagger UI. But right now, nothing stops someone from posting a transaction with a negative amount or a completely empty description.

Pydantic's Field class lets you add constraints directly to your model. FastAPI uses these constraints to automatically reject invalid input with helpful error messages.

In this step, get ready to:
  • Add Field constraints to the Transaction model.
  • Test that invalid data gets rejected automatically with a 422 response.
  • Confirm valid data still works and Swagger UI shows the new constraints.
Add Field constraints to the Transaction model

Pydantic's Field function lets you set rules on each model attribute. You'll use gt=0 to require positive amounts and min_length=1 to prevent empty strings.

  • In main.py, find the Pydantic import line at the top of the file:
from pydantic import BaseModel
  • Add Field to the import so the line looks like this:
from pydantic import BaseModel, Field
  • Now find the Transaction class below:
class Transaction(BaseModel):
    amount: float
    category: str
    description: str
    date: str
  • Replace the first three field definitions with Field() constraints, so the class looks like this:
class Transaction(BaseModel):
    amount: float = Field(gt=0, description="Must be greater than zero")
    category: str = Field(min_length=1, description="Category of the transaction")
    description: str = Field(min_length=1, description="What the transaction was for")
    date: str

What do these constraints do?

  • gt=0 means "greater than zero." Any amount of 0 or below gets rejected.
  • min_length=1 means the string must have at least one character. Empty strings get rejected.
  • description adds human-readable documentation that shows up automatically in the Swagger UI.
  • Save main.py.

Your dev server auto-reloads when you save, so the changes are already live.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI(title="WalletWatch", version="0.1.0")

class Transaction(BaseModel):
    amount: float = Field(gt=0, description="Must be greater than zero")
    category: str = Field(min_length=1, description="Category of the transaction")
    description: str = Field(min_length=1, description="What the transaction was for")
    date: str

transactions_db: list[dict] = []

@app.get("/")
def root():
    return {"message": "WalletWatch API is running"}

@app.post("/transactions/")
def create_transaction(transaction: Transaction):
    new_transaction = transaction.model_dump()
    new_transaction["id"] = len(transactions_db) + 1
    transactions_db.append(new_transaction)
    return new_transaction

@app.get("/transactions/")
def read_transactions():
    return transactions_db
Test with invalid data

Before you send invalid data, what do you think will happen if you POST a transaction with a negative amount?

  • Open your browser to http://127.0.0.1:8000/docs.
  • Click on the POST /transactions/ endpoint to expand it.
  • Click Try it out.
  • Replace the request body with this invalid data:
{
  "amount": -10,
  "category": "",
  "description": "",
  "date": "2026-08-12"
}
  • Click Execute.

You get a 422 Unprocessable Entity response. The response body contains a detail array listing exactly which fields failed and why.

What is a 422 response?

HTTP status code 422 means the server understood your request, but the data inside it broke the validation rules. FastAPI automatically generates field-by-field error messages explaining what went wrong.

You did not write any error-handling code. Pydantic and FastAPI produced these detailed messages from the Field() constraints alone.

Still seeing a 200 success response?

  • Make sure you saved main.py after adding the Field constraints. The dev server only reloads on save.
  • Check that your import line reads from pydantic import BaseModel, Field with a capital F.
  • Look at your terminal for error messages. If you see a red traceback, there may be a typo in the model.

Why is my API not rejecting invalid data?

Confirm valid data still works
  • In the same POST /transactions/ panel, replace the request body with valid data:
{
  "amount": 47.50,
  "category": "food",
  "description": "Grocery shopping",
  "date": "2026-08-12"
}
  • Click Execute.
  • You should see a 200 response with the transaction returned, including an id field.
  • Scroll up in the Swagger UI to the Schema section of the POST endpoint.
  • Notice that the field descriptions you added (like "Must be greater than zero") now appear in the docs automatically.

Why do descriptions show up in Swagger?

FastAPI reads the description parameter from each Field() and includes it in the generated OpenAPI schema. Swagger UI renders that schema as interactive documentation. Your validation rules become self-documenting.

Your API now rejects garbage data automatically. Next up, you'll tackle a bigger problem: what happens to your transactions when the server restarts?

Persist Data with SQLModel and SQLite

Your API handles transactions and rejects bad data. But there is a critical weakness hiding in plain sight.

Right now, every transaction lives in a Python list that exists only while the server is running. The moment you stop the process, everything vanishes. SQLModel with SQLite gives you a real database with zero setup while keeping the same Pydantic-style validation you already built.

In this step, get ready to:
  • Experience the data loss problem that motivates using a database.
  • Replace the Pydantic model with SQLModel equivalents using the multiple-models pattern.
  • Persist transactions to a SQLite database that survives server restarts.
Install SQLModel and define the data models

Before you install anything, you need to see the problem with your own eyes. Your dev server should still be running from the previous step.

  • Stop the dev server by pressing Ctrl+C in the terminal.
  • Restart the dev server by running:
uv run fastapi dev

Before you test, think about this: what do you think happens to your transactions when the server restarts?

  • Open http://127.0.0.1:8000/docs in your browser.
  • Expand the GET /transactions/ endpoint, click Try it out, then click Execute.

The response body is an empty list. Every transaction you created in the previous steps is gone. That is exactly the problem you will fix now.

  • Stop the dev server again by pressing Ctrl+C.
  • Add SQLModel to your project by running:
uv add sqlmodel

What is SQLModel?

SQLModel is a library by the same creator as FastAPI. It combines Pydantic validation with SQLAlchemy database access in a single model definition.

Instead of defining your data shape twice (once for validation, once for the database), SQLModel lets one class do both jobs.

Now you will rewrite main.py from scratch using SQLModel's multiple-models pattern.

  • Open main.py in VS Code.
  • Select all the existing code (Ctrl+A on Windows, Cmd+A on macOS) and delete it.
  • Paste the following imports and model definitions:
from datetime import date

from fastapi import Depends, FastAPI, HTTPException, Query
from sqlmodel import Field, Session, SQLModel, create_engine, select


# --- Models ---


class TransactionBase(SQLModel):
    amount: float = Field(gt=0)
    category: str = Field(min_length=1)
    description: str = Field(min_length=1)
    date: date


class Transaction(TransactionBase, table=True):
    id: int | None = Field(default=None, primary_key=True)


class TransactionCreate(TransactionBase):
    pass


class TransactionPublic(TransactionBase):
    id: int

What is the multiple-models pattern?

Instead of one model doing everything, you split responsibilities across four classes:

  • TransactionBase holds the shared fields and validation rules.
  • Transaction adds table=True so SQLModel maps it to a database table, plus an auto-generated id primary key.
  • TransactionCreate is what the client sends (no id field, because the database assigns that).
  • TransactionPublic is what the API returns (includes id so the client knows which record it got).

Notice that date is now Python's date type instead of a plain string. SQLModel stores it properly and validates the format automatically.

  • Save main.py.
Set up the database engine and session

The models define the shape of your data. Now you need the machinery that connects those models to an actual SQLite file on disk.

  • In main.py, add the following code below the TransactionPublic class:
# --- Database ---

sqlite_url = "sqlite:///walletwatch.db"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


def get_session():
    with Session(engine) as session:
        yield session


# --- App ---

app = FastAPI(title="WalletWatch", version="0.1.0")


@app.on_event("startup")
def on_startup():
    create_db_and_tables()

What does this code do?

  • sqlite_url points to a file called walletwatch.db in your project folder. SQLite creates it automatically on first use.
  • check_same_thread: False is required because FastAPI handles requests across multiple threads, but SQLite by default only allows access from the thread that created the connection.
  • get_session() is a dependency that gives each request its own database session and automatically closes it when the request finishes.
  • on_startup() runs once when the server starts and creates the database tables based on your model definitions.
  • Save main.py and start the dev server by running:
uv run fastapi dev

You should see the server start successfully. Open http://127.0.0.1:8000/docs and notice the Swagger UI loads with no endpoints listed yet.

  • Confirm the database file was created by opening a second terminal, navigating to your walletwatch folder, and running:
ls

You should see walletwatch.db in the output. SQLite created the database file when the server started.

Server won't start?

  • Check that main.py has the app = FastAPI(...) line. Without it, FastAPI cannot find your application.
  • Make sure the imports at the top match exactly. A missing import like Session or create_engine causes an immediate error.

Help me debug my FastAPI server startup error with SQLModel.

Rewrite the endpoints and verify persistence

The database is ready. Now you need endpoints that actually use it instead of the old in-memory list.

  • In main.py, add the following code below the on_startup() function:
# --- Endpoints ---


@app.post("/transactions/", response_model=TransactionPublic)
def create_transaction(
    *, session: Session = Depends(get_session), transaction: TransactionCreate
):
    db_transaction = Transaction.model_validate(transaction)
    session.add(db_transaction)
    session.commit()
    session.refresh(db_transaction)
    return db_transaction


@app.get("/transactions/", response_model=list[TransactionPublic])
def read_transactions(
    *,
    session: Session = Depends(get_session),
):
    transactions = session.exec(select(Transaction)).all()
    return transactions

What changed in the endpoints?

  • session: Session = Depends(get_session) injects a fresh database session into each request automatically.
  • Transaction.model_validate(transaction) converts the incoming TransactionCreate data into a full Transaction database record.
  • session.add(), session.commit(), and session.refresh() write the record to disk and reload it with its auto-generated id.
  • response_model=TransactionPublic tells FastAPI exactly which fields to include in the JSON response.
  • Save main.py. The dev server auto-reloads.

Now for the real test. You will create a transaction, restart the server, and check if the data survives.

  • Open http://127.0.0.1:8000/docs in your browser.
  • Expand POST /transactions/, click Try it out, and paste this request body:
{
  "amount": 47.50,
  "category": "food",
  "description": "Grocery shopping",
  "date": "2026-08-12"
}
  • Click Execute. You should see a 200 response with your transaction and an auto-assigned id of 1.

Before you restart, predict: will your transaction still be there after a server restart?

  • Stop the dev server with Ctrl+C in your terminal.
  • Restart it by running:
uv run fastapi dev
  • Open http://127.0.0.1:8000/docs again.
  • Expand GET /transactions/, click Try it out, then click Execute.

Your grocery shopping transaction is still there. The data survived the restart because it lives in walletwatch.db on disk, not in memory.

Still seeing an empty list?

  • Make sure you clicked Execute on the POST endpoint before restarting. The transaction needs to be created first.
  • Check that walletwatch.db exists in your project folder. If it does not, the on_startup function may not be registered correctly.
  • Verify your create_transaction function includes session.commit(). Without it, changes are never written to disk.

Help me debug why my SQLModel transactions are not persisting across server restarts.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and your server is running without errors.

ⓧ I'd like to double check the full code

Your main.py should contain the following sections in order. Scroll up and confirm each one is present:

  • Imports: from datetime import date, FastAPI imports (Depends, FastAPI, HTTPException, Query), and SQLModel imports (Field, Session, SQLModel, create_engine, select).
  • Models section: TransactionBase with four fields, Transaction with table=True and id primary key, TransactionCreate (empty pass), TransactionPublic with id: int.
  • Database section: sqlite_url, connect_args, engine, create_db_and_tables(), and get_session().
  • App section: app = FastAPI(...) and @app.on_event("startup") calling create_db_and_tables().
  • Endpoints section: POST /transactions/ with response_model=TransactionPublic and GET /transactions/ with response_model=list[TransactionPublic].

Your data now lives on disk. Next up, you will add query parameter filtering so API consumers can search by category and date range, plus a spending summary endpoint that aggregates totals by category.

Add Filtering and a Spending Summary

Your API now persists data to a real database. But right now, calling GET /transactions/ dumps every single record with no way to narrow the results.

A flat list of every transaction is useless at scale. In this step, you'll add query parameters that let callers filter by category and date range, a lookup endpoint for individual transactions, and an aggregation endpoint that summarizes spending by category.

In this step, get ready to:
  • Add optional query parameters for filtering and pagination to your transactions list.
  • Create a single-transaction lookup endpoint with 404 handling.
  • Build a spending summary endpoint that aggregates totals by category.
Add query parameter filtering to GET /transactions/

Query parameters are optional key-value pairs appended to a URL (like ?category=food). They let API consumers request exactly the slice of data they need without you building a separate endpoint for every possible filter combination.

  • In main.py, find the existing read_transactions function:
@app.get("/transactions/", response_model=list[TransactionPublic])
def read_transactions(
    *,
    session: Session = Depends(get_session),
):
    transactions = session.exec(select(Transaction)).all()
    return transactions
  • Replace the entire read_transactions function with this updated version that accepts optional filters:
@app.get("/transactions/", response_model=list[TransactionPublic])
def read_transactions(
    *,
    session: Session = Depends(get_session),
    # Optional filters - None means "don't filter on this"
    category: str | None = None,
    start_date: date | None = None,
    end_date: date | None = None,
    # Pagination controls
    offset: int = 0,
    limit: int = Query(default=100, le=100),
):
    # Start with all transactions
    statement = select(Transaction)
    # Chain filters only when the caller provides them
    if category:
        statement = statement.where(Transaction.category == category)
    if start_date:
        statement = statement.where(Transaction.date >= start_date)
    if end_date:
        statement = statement.where(Transaction.date <= end_date)
    transactions = session.exec(statement.offset(offset).limit(limit)).all()
    return transactions

What does this code do?

Each query parameter defaults to None. When a caller omits a parameter, that filter is skipped entirely.

  • The if category: block chains a .where() clause onto the select statement only when the caller provides a category value.
  • The same pattern applies for start_date and end_date. Each condition narrows the results further.
  • The Query(default=100, le=100) caps pagination at 100 results per page. FastAPI automatically rejects requests where limit exceeds 100.
  • Save main.py.
  • Switch back to the Swagger docs at http://127.0.0.1:8000/docs in your browser.
  • First, POST a couple of transactions in different categories so you have data to filter. Use the POST /transactions/ endpoint to create one with "category": "food" and another with "category": "entertainment".
  • Now expand GET /transactions/ and enter food in the category field. Click Execute.

You should see only your food transactions in the response. The entertainment transactions are filtered out.

Still seeing all transactions?

  • Make sure you replaced the entire read_transactions function, not just the signature. The body must use the statement variable with chained .where() clauses.
  • Check that you typed food in the category field exactly as you used it in your POST request. Category matching is case-sensitive.
  • Confirm the dev server auto-reloaded after you saved. Look for a reload message in the terminal.

Why aren't my filters working in Swagger?

Add a single transaction lookup endpoint

Sometimes you need to fetch one specific transaction by its ID. A dedicated endpoint with proper 404 handling is how professional APIs expose individual resources.

  • In main.py, add this new endpoint directly below the read_transactions function:
@app.get("/transactions/{transaction_id}", response_model=TransactionPublic)
def read_transaction(*, session: Session = Depends(get_session), transaction_id: int):
    # Fetch a single transaction by its primary key
    transaction = session.get(Transaction, transaction_id)
    if not transaction:
        raise HTTPException(status_code=404, detail="Transaction not found")
    return transaction

What does this code do?

  • The path parameter {transaction_id} captures the ID from the URL (e.g. /transactions/3).
  • The session.get(Transaction, transaction_id) fetches that row by primary key. It returns None if the ID does not exist.
  • The HTTPException with status code 404 tells the caller the resource was not found, which is the standard REST convention.
  • Save main.py.
  • In Swagger, expand the new GET /transactions/{transaction_id} endpoint.
  • Enter 1 for the transaction_id parameter and click Execute. You should see that single transaction returned.
  • Now try an ID that does not exist, like 9999. You should see a 404 response with the message Transaction not found.

Getting a 422 instead of 404?

  • A 422 means FastAPI could not parse the transaction_id as an integer. Make sure you entered a plain number (like 9999) with no quotes or extra characters.
  • If you see a 500 error, check that HTTPException is imported at the top of your file. It should already be in the from fastapi import line.

Why am I getting an unexpected status code from my endpoint?

Create the spending summary endpoint

Listing and looking up transactions is useful, but the real power of an API is business logic built on top of raw data. A summary endpoint proves you can aggregate and transform records into insights.

  • In main.py, add this new endpoint below the read_transaction function:
@app.get("/summary/")
def spending_summary(
    *,
    session: Session = Depends(get_session),
    start_date: date | None = None,
    end_date: date | None = None,
):
    # Fetch transactions within the optional date range
    statement = select(Transaction)
    if start_date:
        statement = statement.where(Transaction.date >= start_date)
    if end_date:
        statement = statement.where(Transaction.date <= end_date)
    transactions = session.exec(statement).all()
    # Aggregate totals per category
    summary: dict[str, float] = {}
    for t in transactions:
        summary[t.category] = summary.get(t.category, 0) + t.amount
    return {"total": sum(summary.values()), "by_category": summary}

What does this code do?

  • The endpoint accepts optional start_date and end_date parameters to scope the summary to a time window.
  • The for loop iterates over every matching transaction and accumulates the total amount per category into a dictionary.
  • The response includes a total (sum of all amounts) and a by_category breakdown so the caller can see where their money went.
  • Save main.py.
  • Before testing the summary, seed a few more transactions so you have variety. In Swagger, use POST /transactions/ to create these:
{
  "amount": 47.50,
  "category": "food",
  "description": "Grocery shopping",
  "date": "2026-08-10"
}
  • Click Execute to create that transaction.
  • Change the request body to create a second transaction:
{
  "amount": 15.99,
  "category": "entertainment",
  "description": "Netflix subscription",
  "date": "2026-08-11"
}
  • Click Execute to create that transaction.
  • Create one more:
{
  "amount": 32.00,
  "category": "food",
  "description": "Takeout dinner",
  "date": "2026-08-12"
}
  • Click Execute.

Now you have multiple transactions across two categories. Before you call the summary endpoint, predict what you expect to see. Which categories will appear, and what will the totals be?

  • In Swagger, expand GET /summary/ and click Execute with no date filters.

You should see a JSON response with a total summing all transaction amounts and a by_category object showing the breakdown per category. Your food total includes every food transaction you have created across this project.

Summary returning empty or zero?

  • If by_category is an empty object, confirm your POST requests above returned 200 responses. If they returned errors, the transactions were not saved.
  • If you entered start_date or end_date values in the form, make sure they include the dates you used when creating transactions. Clear both fields and try again without filters first.

Why is my summary endpoint returning unexpected results?

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and all three new endpoint behaviors are working in Swagger.

ⓧ I'd like to double check the full code

Your main.py is now too long to show in one block. Scroll up and confirm these sections are present, in this order:

  • Imports: from datetime import date, from fastapi import Depends, FastAPI, HTTPException, Query, from sqlmodel import Field, Session, SQLModel, create_engine, select.
  • Models section: TransactionBase, Transaction, TransactionCreate, TransactionPublic.
  • Database section: sqlite_url, engine, create_db_and_tables, get_session.
  • App section: app = FastAPI(...) and on_startup event.
  • Endpoints: create_transaction (POST), read_transactions (GET with filters), read_transaction (GET by ID), spending_summary (GET /summary/).

Your API now supports targeted queries and spending insights. Next up, you'll take on the Secret Mission and complete the full CRUD lifecycle with update and delete endpoints.

Secret mission

Complete the CRUD with Update and Delete

Your API can create and read transactions, but what happens when a user makes a typo or wants to remove a duplicate? A professional API supports the full lifecycle. Add PATCH and DELETE endpoints to finish the job.

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:

  • The walletwatch project folder on your Desktop (contains main.py, walletwatch.db, .venv, pyproject.toml, uv.lock).
  • uv (the Python package manager installed globally).

Keep everything running

No action needed. Choose this if you plan to keep building on WalletWatch or want to reference the code later.

  • Your walletwatch folder stays on your Desktop.
  • uv remains installed globally and is useful for any future Python projects.

Pause - I'll come back to this later

Stop the dev server to free up your terminal. Your files and database stay intact for when you return.

  • If the dev server is still running, press Ctrl+C in the terminal to stop it.
  • Your walletwatch.db file retains all transactions you created. Next time you run uv run fastapi dev, everything picks up where you left off.

Delete - I don't want to use this again

Remove all project files and optionally uninstall uv from your system.

  • If the dev server is still running, press Ctrl+C in the terminal to stop it.
  • Delete the project folder by running this command in PowerShell:
Remove-Item -Recurse -Force ~\Desktop\walletwatch
  • To uninstall uv, first clean its cached data:
uv cache clean
  • Then remove the uv binaries:
rm $HOME\.local\bin\uv.exe
rm $HOME\.local\bin\uvx.exe
rm $HOME\.local\bin\uvw.exe

Keep uv for future projects

uv is useful for any Python project. If you plan to do more Python development, consider keeping it installed even if you delete the WalletWatch project.

Nice Work!

Nice Work!

Nice work! You just built WalletWatch, a fully functional personal finance REST API powered by FastAPI, SQLModel, and SQLite.

You've learned how to:

  • Build a REST API with FastAPI that validates input data using Pydantic field constraints, automatically rejecting invalid transactions with detailed error messages.
  • Persist financial transactions to SQLite using SQLModel's multiple-models pattern, so data survives server restarts without any external database setup.
  • Implement query parameter filtering by category and date range, plus a spending summary endpoint that aggregates transactions by category with totals.
  • Secret Mission: Added PATCH (partial update) and DELETE endpoints with proper 404 error handling to complete the full CRUD lifecycle.

Ready to quiz yourself?