Daily Expense Tracker with Python OOP

Build a desktop expense tracker app with OOP, Tkinter GUI, and JSON persistence.

Introduction

30 Second Summary

Tracking daily spending is one of those tasks that sounds simple until you actually try to do it consistently. Spreadsheets get cluttered, mental math fails, and most apps want a subscription for basic features.

In this project, you will build your own desktop Expense Tracker application using Python. You will learn Object-Oriented Programming by splitting your code into focused classes, and build a graphical interface with Tkinter so your app looks and feels like real software.

What You'll Build

A desktop application that lets you add, view, edit, and delete daily expenses with categorized tracking, date management, and persistent storage to a JSON file.

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

  • A fully functional Expense Tracker desktop application with a polished graphical interface built using Tkinter.
  • A working dictionary-based data store that persists your expense data to a JSON file between sessions.
  • A well-structured Python codebase demonstrating Object-Oriented Programming principles with distinct classes for data models, storage operations, and UI components.
  • Secret Mission: Add a category filter dropdown and a date range filter so you can view expenses for specific categories or time periods.

Are there any prerequisites?

You need basic Python knowledge (variables, functions, loops, and conditionals). No prior GUI or OOP experience is required.

Before We Start

Before you can build the Expense Tracker, you need a working Python environment and a code editor to write your files in.

In this step, you will install Python, set up VS Code with the Python extension, and create the project folder that holds all your source files.

In this step, get ready to:
  • Install Python 3.12+ and verify it works.
  • Install VS Code and add the Python extension.
  • Create the expense-tracker project folder with three empty Python files.
Install Python 3.12+

Python is the programming language you will use for the entire project. You need version 3.12 or higher installed on your machine.

  • Open your browser and navigate to python.org/downloads.
  • Click the download button for the latest Python 3 release.
  • Run the installer after it finishes downloading.

Windows users: check Add python.exe to PATH

During installation, you will see a checkbox labelled Add python.exe to PATH at the bottom of the installer window. Check this box before clicking Install Now. Without it, your terminal will not recognize the python command.

  • Once installation completes, open a terminal to verify Python is available.

Check your Python version by running the correct command for your operating system:

macOS

  • Check your Python version by running:
python3 --version

Windows

  • Check your Python version by running:
python --version

✔️ I see version 3.12 or higher

You are good to go. Move on to the next substep.

ⓧ I see an older version

Your system has an older Python that does not meet the 3.12+ requirement for this project.

  • Visit python.org/downloads and download the latest Python 3 installer for your OS.
  • Run the installer. On Windows, make sure to check Add python.exe to PATH.
  • On macOS, the new version installs alongside the old one. Use python3 --version to confirm it shows 3.12 or higher.
  • On Windows, close and reopen your terminal, then run python --version again to confirm.

macOS still showing the old version?

macOS may ship an older system Python. After installing the latest version from python.org, the new binary is available as python3.12 or via the full path /Library/Frameworks/Python.framework/Versions/3.12/bin/python3. You can also use Homebrew: brew install python3.

help me upgrade Python to 3.12 on my Mac

ⓧ Command not found

Python is not installed on your machine yet.

  • Visit python.org/downloads and download the latest Python 3 installer for your OS.
  • On Windows, check Add python.exe to PATH during installation, then click Install Now.
  • On macOS, run the downloaded .pkg installer and follow the prompts.
  • Close and reopen your terminal after installation, then re-run the version check command.

Still seeing command not found?

On Windows, make sure you checked the PATH checkbox during install. You may need to restart your computer. On macOS, try python3 --version instead of python --version.

help me troubleshoot Python not being found in my terminal

Install VS Code and the Python extension

VS Code is a free code editor that gives you syntax highlighting, debugging, and a built-in terminal. The Python extension adds language-specific features like autocomplete and linting.

  • Open your browser and navigate to code.visualstudio.com.
  • Download the installer for your operating system and run it.
  • Once installed, open VS Code.
  • Press Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows) to open the Extensions view.
  • Type Python in the search bar.
  • Find the extension named Python by Microsoft and click Install.

What does the Python extension do?

The Microsoft Python extension turns VS Code into a full Python editor. It provides autocomplete suggestions, highlights syntax errors as you type, and lets you run and debug scripts directly from the editor.

  • After installation, you should see a small checkmark on the Python extension tile in the sidebar confirming it is active.

Extension not appearing in search?

Make sure you are connected to the internet. VS Code fetches extensions from the online Marketplace. If your search returns no results, close and reopen VS Code and try again.

help me install the Python extension in VS Code

Create your project folder and files

Every Python project lives in its own folder. You will create a folder called expense-tracker on your Desktop with three empty files that you will fill in over the next steps.

  • Open the terminal inside VS Code by pressing Ctrl+` (Windows) or Cmd+` (macOS).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir expense-tracker
cd expense-tracker
  • Create the three Python files your project needs:

macOS

  • Create the files by running:
touch expense.py storage.py app.py

Windows

  • Create the files by running:
New-Item expense.py, storage.py, app.py
  • Confirm the files exist by running:
ls

You should see expense.py, storage.py, and app.py listed in the output.

  • Now open the project folder in VS Code. In the VS Code menu bar, click File.
  • Click Open Folder.
  • Navigate to Desktop, select the expense-tracker folder, and click Open.

You should see the three files in the VS Code Explorer sidebar on the left.

Don't see your files in VS Code?

Make sure you opened the expense-tracker folder itself, not the Desktop. The Explorer sidebar should show expense.py, storage.py, and app.py at the top level.

help me open my project folder in VS Code

Your environment is ready. Next up, you will build the Expense data model class that defines what an expense looks like in your application.

Build the Expense Data Model

Your environment is ready. Now it's time to define the core building block of your app: the Expense.

Every expense tracker needs a consistent way to represent an expense. In Object-Oriented Programming, you define a class that holds the data for each expense and knows how to validate itself. This keeps your data logic in one place instead of scattered across your application.

In this step, get ready to:
  • Create the Expense class with preset categories and instance attributes.
  • Add methods to convert expenses to and from dictionaries.
  • Build input validation to reject bad data before it reaches storage.
Create the Expense class with categories

A class is a blueprint for creating objects. Your Expense class defines what data every expense holds and what categories are allowed.

  • In VS Code, click on expense.py in the Explorer sidebar to open it.
  • Add the following code to set up the class with its imports and category presets:
import re
from datetime import date


class Expense:
    """Represents a single expense entry."""

    CATEGORIES = (
        "Food",
        "Transport",
        "Housing",
        "Entertainment",
        "Health",
        "Education",
        "Shopping",
        "Other",
    )

What does this code do?

You import re (regular expressions) and date from the standard library. You will use these later for validating expense dates.

The CATEGORIES tuple is a class-level constant shared by every Expense instance. Defining it as a tuple (instead of a list) means it cannot be accidentally modified at runtime.

  • Now add the __init__ and __repr__ methods inside the Expense class, below the CATEGORIES tuple:
    def __init__(self, id, date_str, category, description, amount):
        self.id = id
        self.date_str = date_str
        self.category = category
        self.description = description
        self.amount = amount

    def __repr__(self):
        return (
            f"Expense(id={self.id}, date='{self.date_str}', "
            f"category='{self.category}', description='{self.description}', "
            f"amount={self.amount})"
        )

What does this code do?

The __init__ method runs every time you create a new Expense object. It stores the five pieces of data (id, date, category, description, amount) as instance attributes using self.

The __repr__ method gives you a readable string when you print an Expense object. Instead of seeing something like <Expense object at 0x...>, you will see the actual data inside.

  • Save expense.py.
  • Confirm no red squiggly underlines appear in VS Code. If you see any, check that your indentation uses 4 spaces consistently.

Seeing red underlines?

Make sure every method inside the class is indented with exactly 4 spaces. The def keyword should be indented one level from class Expense:, and the method body is indented one more level.

help me fix indentation errors in my Expense class

Add dictionary conversion methods

Your storage layer will save expenses as dictionaries inside a JSON file. The Expense class needs methods to convert itself to a dictionary and to rebuild itself from one.

  • In expense.py, add the to_dict() and from_dict() methods below __repr__:
    def to_dict(self):
        """Convert the expense to a dictionary."""
        return {
            "id": self.id,
            "date": self.date_str,
            "category": self.category,
            "description": self.description,
            "amount": self.amount,
        }

    @classmethod
    def from_dict(cls, data):
        """Create an Expense instance from a dictionary."""
        return cls(
            id=data["id"],
            date_str=data["date"],
            category=data["category"],
            description=data["description"],
            amount=data["amount"],
        )

What does this code do?

The to_dict() method converts an Expense object into a plain Python dictionary. This is what gets saved to your JSON file.

The @classmethod decorator on from_dict() means you call it on the class itself (Expense.from_dict(data)) rather than on an instance. It reads a dictionary and creates a new Expense object from it. This is how you rebuild expenses when loading from the JSON file.

  • Save expense.py.
  • Verify the file has no red underlines. You should see the class with CATEGORIES, __init__, __repr__, to_dict, and from_dict all cleanly indented.

Seeing a SyntaxError or red underline on from_dict?

Check that @classmethod sits on its own line directly above def from_dict(cls, data):. The decorator must be at the same indentation level as the def line.

help me fix my from_dict classmethod

Add input validation

Users make mistakes. They might enter a negative amount, leave the description blank, or type an invalid date. A validate() method catches these problems before bad data reaches your storage layer.

  • Add the validate() method below from_dict() in your Expense class:
    def validate(self):
        """Validate expense fields. Raises ValueError on invalid data."""
        if not self.description or not self.description.strip():
            raise ValueError("Description cannot be empty.")

        if self.category not in self.CATEGORIES:
            raise ValueError(
                f"Invalid category '{self.category}'. "
                f"Must be one of: {', '.join(self.CATEGORIES)}"
            )

        try:
            amount_val = float(self.amount)
        except (TypeError, ValueError):
            raise ValueError("Amount must be a valid number.")

        if amount_val <= 0:
            raise ValueError("Amount must be a positive number.")

        date_pattern = r"^\d{4}-\d{2}-\d{2}$"
        if not re.match(date_pattern, self.date_str):
            raise ValueError("Date must be in YYYY-MM-DD format.")

        parts = self.date_str.split("-")
        try:
            date(int(parts[0]), int(parts[1]), int(parts[2]))
        except ValueError:
            raise ValueError("Date is not a valid calendar date.")

        self.amount = amount_val

What does this code do?

  • First, it checks the description is not empty or whitespace-only.
  • Next, it confirms the category matches one of the allowed values in CATEGORIES.
  • It tries to convert the amount to a float and rejects non-numeric or negative values.
  • It uses a regex pattern to check the date format (YYYY-MM-DD), then validates the date is a real calendar date using Python's date() constructor.
  • Finally, it stores the cleaned float value back into self.amount so downstream code always works with a number, not a string.
  • Save expense.py.
  • Test the class by running this command in your terminal:
python -c "from expense import Expense; e = Expense(1, '2025-06-05', 'Food', 'Lunch', 12.50); print(e)"

You should see output like: Expense(id=1, date='2025-06-05', category='Food', description='Lunch', amount=12.5).

Seeing a ModuleNotFoundError?

Make sure your terminal is inside the expense-tracker folder. Run cd ~/Desktop/expense-tracker and try again.

Also confirm the file is named exactly expense.py (not expenses.py or Expense.py).

help me fix my Expense import error

✔️ Awesome, I've got everything!

Great. Double check you have saved expense.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete expense.py file. Compare yours against it:

import re
from datetime import date


class Expense:
    """Represents a single expense entry."""

    CATEGORIES = (
        "Food",
        "Transport",
        "Housing",
        "Entertainment",
        "Health",
        "Education",
        "Shopping",
        "Other",
    )

    def __init__(self, id, date_str, category, description, amount):
        self.id = id
        self.date_str = date_str
        self.category = category
        self.description = description
        self.amount = amount

    def __repr__(self):
        return (
            f"Expense(id={self.id}, date='{self.date_str}', "
            f"category='{self.category}', description='{self.description}', "
            f"amount={self.amount})"
        )

    def to_dict(self):
        """Convert the expense to a dictionary."""
        return {
            "id": self.id,
            "date": self.date_str,
            "category": self.category,
            "description": self.description,
            "amount": self.amount,
        }

    @classmethod
    def from_dict(cls, data):
        """Create an Expense instance from a dictionary."""
        return cls(
            id=data["id"],
            date_str=data["date"],
            category=data["category"],
            description=data["description"],
            amount=data["amount"],
        )

    def validate(self):
        """Validate expense fields. Raises ValueError on invalid data."""
        if not self.description or not self.description.strip():
            raise ValueError("Description cannot be empty.")

        if self.category not in self.CATEGORIES:
            raise ValueError(
                f"Invalid category '{self.category}'. "
                f"Must be one of: {', '.join(self.CATEGORIES)}"
            )

        try:
            amount_val = float(self.amount)
        except (TypeError, ValueError):
            raise ValueError("Amount must be a valid number.")

        if amount_val <= 0:
            raise ValueError("Amount must be a positive number.")

        date_pattern = r"^\d{4}-\d{2}-\d{2}$"
        if not re.match(date_pattern, self.date_str):
            raise ValueError("Date must be in YYYY-MM-DD format.")

        parts = self.date_str.split("-")
        try:
            date(int(parts[0]), int(parts[1]), int(parts[2]))
        except ValueError:
            raise ValueError("Date is not a valid calendar date.")

        self.amount = amount_val

Your data model is locked in. Next up, you will build the storage layer that saves these Expense objects to a JSON file so your data persists between sessions.

Create the Storage Manager

Your Expense class knows how to represent and validate a single expense. Now you need somewhere to actually store those expenses between sessions.

In this step, you will build a StorageManager class that uses a Python dictionary as its in-memory data store. The class will read from and write to a JSON file so your expenses persist even after you close the app.

In this step, get ready to:
  • Create the StorageManager class with dictionary-based storage and JSON file loading.
  • Add methods to create, retrieve, and delete expenses.
  • Add methods to update an expense and calculate the running total.
Set up the StorageManager class with loading logic

The StorageManager is responsible for one thing: keeping your expenses dictionary in sync with a JSON file on disk. When the app starts, it loads existing data. When something changes, it saves automatically.

  • In VS Code, click the storage.py tab to open the file.
  • Add the imports, class definition, __init__, and _load() method by copying this code:
import json
import os


class StorageManager:
    """Handles dictionary-based storage with JSON file persistence."""

    def __init__(self, filename="expenses.json"):
        self.filename = filename
        self.expenses = {}
        self.next_id = 1
        self._load()

    def _load(self):
        """Load expenses from the JSON file if it exists."""
        if os.path.exists(self.filename):
            with open(self.filename, "r", encoding="utf-8") as f:
                data = json.load(f)
                self.expenses = data.get("expenses", {})
                self.next_id = data.get("next_id", 1)

What does this code do?

The __init__ method sets up three things: a filename for the JSON file, an empty dictionary to hold expenses in memory, and an ID counter starting at 1. It then immediately calls _load() to populate those from disk.

The _load() method checks if the JSON file exists using os.path.exists(). If it does, it reads the file and populates self.expenses and self.next_id from the stored data. If the file does not exist yet, the dictionary stays empty.

Why are dictionary keys stored as strings?

JSON requires all keys to be strings. When Python writes a dictionary to JSON, integer keys are coerced to strings automatically. Using string keys from the start (str(self.next_id)) keeps the in-memory structure consistent with what gets saved to disk.

  • Save storage.py.
  • You will not see visible output yet. The next chunk adds methods that use this foundation.

Seeing a red underline in VS Code?

A warning under self._load() is expected if your editor analyses the code before you finish typing the _load method. Once both the __init__ and _load methods are saved, the warning disappears.

Help me fix a red underline in my StorageManager class.

Add save, add, retrieve, and delete methods

With the loading logic in place, you need methods to write changes back to the file and perform basic operations on your expenses dictionary.

  • Add the _save(), add_expense(), get_all_expenses(), and delete_expense() methods below _load() by copying this code:
    def _save(self):
        """Save the current expenses dictionary to the JSON file."""
        data = {
            "expenses": self.expenses,
            "next_id": self.next_id,
        }
        with open(self.filename, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)

    def add_expense(self, expense):
        """Add a new expense to the dictionary."""
        expense.id = self.next_id
        self.expenses[str(self.next_id)] = expense.to_dict()
        self.next_id += 1
        self._save()

    def get_all_expenses(self):
        """Retrieve all expenses sorted by date descending."""
        expense_list = list(self.expenses.values())
        expense_list.sort(key=lambda x: x["date"], reverse=True)
        return expense_list

    def delete_expense(self, expense_id):
        """Delete an expense by its ID."""
        key = str(expense_id)
        if key in self.expenses:
            del self.expenses[key]
            self._save()

What does this code do?

  • _save() writes the entire expenses dictionary and the current ID counter to the JSON file. Every mutation calls this method so the file always reflects the latest state.
  • add_expense() assigns the next available ID to the expense, converts it to a dictionary with to_dict(), stores it using a string key, increments the counter, and saves to disk.
  • get_all_expenses() returns a list of all expense dictionaries sorted by date in descending order (newest first).
  • delete_expense() converts the numeric ID to a string key, removes it from the dictionary if it exists, and saves the change.
  • Save storage.py.
  • You will not see visible output yet. The next chunk completes the class with update and total methods.

Seeing an indentation error?

Make sure every method is indented one level (4 spaces) inside the class StorageManager: block. If you accidentally placed a method at the top level (no indent), Python will not recognize it as part of the class.

Help me fix an indentation error in my StorageManager class.

Add update and total methods

Two more methods round out the class: one to modify an existing expense and one to calculate the sum of all amounts.

  • Add the update_expense() and get_total() methods below delete_expense() by copying this code:
    def update_expense(self, expense_id, date_str, category, description, amount):
        """Update an existing expense in the dictionary."""
        key = str(expense_id)
        if key in self.expenses:
            self.expenses[key] = {
                "id": expense_id,
                "date": date_str,
                "category": category,
                "description": description,
                "amount": amount,
            }
            self._save()

    def get_total(self):
        """Return the sum of all expense amounts."""
        if not self.expenses:
            return 0.0
        return sum(exp["amount"] for exp in self.expenses.values())

What does this code do?

  • update_expense() replaces the stored dictionary for a given ID with fresh values. It takes the individual fields rather than an Expense object, making it easy to call from the UI layer.
  • get_total() sums up the amount field across all stored expenses. It returns 0.0 if the dictionary is empty to avoid errors.
  • Save storage.py.
  • Test your class by opening the terminal in VS Code with Ctrl+` (macOS and Windows) and running:
python -c "from storage import StorageManager; sm = StorageManager(); print(sm.expenses, sm.next_id)"
  • You should see {} 1 printed in the terminal. This confirms the class initializes with an empty dictionary and an ID counter of 1.

Seeing a ModuleNotFoundError?

Make sure your terminal is in the expense-tracker folder. If you see an error about importing storage, run cd ~/Desktop/expense-tracker first and try again.

Help me fix a ModuleNotFoundError when importing StorageManager.

✔️ Awesome, I've got everything!

Great. Double check you have saved storage.py and move on to the next step.

ⓧ I'd like to double check the full code

Here is the complete storage.py file. Compare your code against this:

import json
import os


class StorageManager:
    """Handles dictionary-based storage with JSON file persistence."""

    def __init__(self, filename="expenses.json"):
        self.filename = filename
        self.expenses = {}
        self.next_id = 1
        self._load()

    def _load(self):
        """Load expenses from the JSON file if it exists."""
        if os.path.exists(self.filename):
            with open(self.filename, "r", encoding="utf-8") as f:
                data = json.load(f)
                self.expenses = data.get("expenses", {})
                self.next_id = data.get("next_id", 1)

    def _save(self):
        """Save the current expenses dictionary to the JSON file."""
        data = {
            "expenses": self.expenses,
            "next_id": self.next_id,
        }
        with open(self.filename, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)

    def add_expense(self, expense):
        """Add a new expense to the dictionary."""
        expense.id = self.next_id
        self.expenses[str(self.next_id)] = expense.to_dict()
        self.next_id += 1
        self._save()

    def get_all_expenses(self):
        """Retrieve all expenses sorted by date descending."""
        expense_list = list(self.expenses.values())
        expense_list.sort(key=lambda x: x["date"], reverse=True)
        return expense_list

    def delete_expense(self, expense_id):
        """Delete an expense by its ID."""
        key = str(expense_id)
        if key in self.expenses:
            del self.expenses[key]
            self._save()

    def update_expense(self, expense_id, date_str, category, description, amount):
        """Update an existing expense in the dictionary."""
        key = str(expense_id)
        if key in self.expenses:
            self.expenses[key] = {
                "id": expense_id,
                "date": date_str,
                "category": category,
                "description": description,
                "amount": amount,
            }
            self._save()

    def get_total(self):
        """Return the sum of all expense amounts."""
        if not self.expenses:
            return 0.0
        return sum(exp["amount"] for exp in self.expenses.values())

Your storage layer is complete. It can load, save, add, retrieve, update, and delete expenses. Next up, you will build the graphical interface that lets you interact with this storage through buttons and input fields.

Build the Application GUI Layout

Your data model knows how to validate expenses, and your storage manager can persist them to a JSON file. The missing piece is a way for users to actually interact with your app.

In this step, you will build the graphical interface using Tkinter, Python's built-in GUI toolkit. You will create the main application window and an input form where users can enter expense details.

In this step, get ready to:
  • Set up the main application window and wire it to your storage layer.
  • Build an input form with date, category, description, and amount fields.
  • Add action buttons and a live running total display.
Create the application class and main window

Every Tkinter app starts with a root window. Your ExpenseTrackerApp class will own this window and coordinate between the UI and the storage layer.

  • In VS Code, open app.py (it should be empty) and paste the following code:
import tkinter as tk
from tkinter import ttk, messagebox
from datetime import date

from expense import Expense
from storage import StorageManager


class ExpenseTrackerApp:
    """Main application class for the Daily Expense Tracker."""

    def __init__(self):
        self.root = tk.Tk()
        self.root.title("Daily Expense Tracker")
        self.root.geometry("800x600")
        self.root.minsize(700, 500)

        self.storage = StorageManager()
        self.editing_id = None

        self._create_input_form()
        self._create_treeview()
        self._create_action_buttons()
        self._refresh_treeview()

What does this code do?

The imports bring in three modules from Tkinter: tk for the base toolkit, ttk for themed widgets that look native on your OS, and messagebox for pop-up dialogs.

The __init__ method creates the root window, sets its title and size, instantiates your StorageManager, and calls four setup methods. We will build these methods one at a time across this step and the next.

  • Save app.py.
  • You won't see a window yet because the setup methods don't exist. The next chunk adds the first one.

Why ttk instead of plain Tkinter widgets?

The ttk module provides themed versions of common widgets (buttons, entries, labels) that automatically match your operating system's look and feel. It also includes powerful widgets like Treeview and Combobox that don't exist in the base toolkit.

Build the input form with entry fields

The input form is where users type their expense details. You will use Tkinter's grid geometry manager to align labels and inputs in neat rows and columns.

  • In app.py, add the following method directly below the __init__ method:
    def _create_input_form(self):
        """Create the input form for adding/editing expenses."""
        form_frame = ttk.LabelFrame(self.root, text="Add Expense", padding=10)
        form_frame.pack(fill="x", padx=10, pady=(10, 5))

        ttk.Label(form_frame, text="Date:").grid(row=0, column=0, sticky="w", padx=5, pady=5)
        self.date_entry = ttk.Entry(form_frame, width=15)
        self.date_entry.grid(row=0, column=1, padx=5, pady=5)
        self.date_entry.insert(0, date.today().isoformat())

        ttk.Label(form_frame, text="Category:").grid(row=0, column=2, sticky="w", padx=5, pady=5)
        self.category_combo = ttk.Combobox(form_frame, values=Expense.CATEGORIES, state="readonly", width=15)
        self.category_combo.grid(row=0, column=3, padx=5, pady=5)
        self.category_combo.set(Expense.CATEGORIES[0])

        ttk.Label(form_frame, text="Description:").grid(row=1, column=0, sticky="w", padx=5, pady=5)
        self.desc_entry = ttk.Entry(form_frame, width=30)
        self.desc_entry.grid(row=1, column=1, columnspan=3, sticky="ew", padx=5, pady=5)

        ttk.Label(form_frame, text="Amount:").grid(row=1, column=4, sticky="w", padx=5, pady=5)
        self.amount_entry = ttk.Entry(form_frame, width=12)
        self.amount_entry.grid(row=1, column=5, padx=5, pady=5)

What does this code do?

  • ttk.LabelFrame creates a bordered container with the title "Add Expense" that visually groups the form fields.
  • date.today().isoformat() pre-fills the date field with today's date in YYYY-MM-DD format so the user doesn't have to type it each time.
  • ttk.Combobox with state="readonly" gives users a dropdown of your preset categories without allowing freeform input.
  • The grid() calls place each widget at specific row/column positions. Row 0 holds Date and Category. Row 1 holds Description and Amount.
  • Save app.py.
  • You won't see a window yet because the other methods referenced in __init__ still need to be created. The next chunk finishes this form method.
Add buttons and a running total display

Users need an "Add" button to submit their expense and a "Clear" button to reset the form. Below the form, a bold total label shows how much they have spent overall.

  • In app.py, add the following code at the bottom of the _create_input_form method (directly after the self.amount_entry.grid(...) line):
        self.add_button = ttk.Button(form_frame, text="Add Expense", command=self._add_expense)
        self.add_button.grid(row=0, column=5, padx=5, pady=5)

        self.clear_button = ttk.Button(form_frame, text="Clear", command=self._clear_form)
        self.clear_button.grid(row=0, column=6, padx=5, pady=5)

        form_frame.columnconfigure(1, weight=1)

        self.total_label = ttk.Label(self.root, text="Total: $0.00", font=("Arial", 12, "bold"))
        self.total_label.pack(anchor="e", padx=15, pady=5)

What does this code do?

  • The command=self._add_expense parameter tells Tkinter which method to call when the button is clicked. You will write these handler methods in Step 6.
  • form_frame.columnconfigure(1, weight=1) makes column 1 (the description column) stretch to fill available horizontal space.
  • The total_label is packed onto the root window (not the form frame), so it sits below the form. It right-aligns with anchor="e".
  • Save app.py.
  • You won't be able to run the app yet because _create_treeview() and the other methods called in __init__ don't exist. You will add those in the next step.

Why grid and pack in the same app?

Tkinter has two main geometry managers. The grid manager is ideal for forms because it aligns labels and inputs into precise rows and columns. The pack manager is simpler and works well for stacking sections vertically.

You can use both in the same app as long as you don't mix them in the same container. The form frame uses grid internally, while the root window uses pack to stack the form, treeview, and buttons from top to bottom.

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py before moving on.

ⓧ I'd like to double check the full code

Here is the full app.py as it should look at the end of this step:

import tkinter as tk
from tkinter import ttk, messagebox
from datetime import date

from expense import Expense
from storage import StorageManager


class ExpenseTrackerApp:
    """Main application class for the Daily Expense Tracker."""

    def __init__(self):
        self.root = tk.Tk()
        self.root.title("Daily Expense Tracker")
        self.root.geometry("800x600")
        self.root.minsize(700, 500)

        self.storage = StorageManager()
        self.editing_id = None

        self._create_input_form()
        self._create_treeview()
        self._create_action_buttons()
        self._refresh_treeview()

    def _create_input_form(self):
        """Create the input form for adding/editing expenses."""
        form_frame = ttk.LabelFrame(self.root, text="Add Expense", padding=10)
        form_frame.pack(fill="x", padx=10, pady=(10, 5))

        ttk.Label(form_frame, text="Date:").grid(row=0, column=0, sticky="w", padx=5, pady=5)
        self.date_entry = ttk.Entry(form_frame, width=15)
        self.date_entry.grid(row=0, column=1, padx=5, pady=5)
        self.date_entry.insert(0, date.today().isoformat())

        ttk.Label(form_frame, text="Category:").grid(row=0, column=2, sticky="w", padx=5, pady=5)
        self.category_combo = ttk.Combobox(form_frame, values=Expense.CATEGORIES, state="readonly", width=15)
        self.category_combo.grid(row=0, column=3, padx=5, pady=5)
        self.category_combo.set(Expense.CATEGORIES[0])

        ttk.Label(form_frame, text="Description:").grid(row=1, column=0, sticky="w", padx=5, pady=5)
        self.desc_entry = ttk.Entry(form_frame, width=30)
        self.desc_entry.grid(row=1, column=1, columnspan=3, sticky="ew", padx=5, pady=5)

        ttk.Label(form_frame, text="Amount:").grid(row=1, column=4, sticky="w", padx=5, pady=5)
        self.amount_entry = ttk.Entry(form_frame, width=12)
        self.amount_entry.grid(row=1, column=5, padx=5, pady=5)

        self.add_button = ttk.Button(form_frame, text="Add Expense", command=self._add_expense)
        self.add_button.grid(row=0, column=5, padx=5, pady=5)

        self.clear_button = ttk.Button(form_frame, text="Clear", command=self._clear_form)
        self.clear_button.grid(row=0, column=6, padx=5, pady=5)

        form_frame.columnconfigure(1, weight=1)

        self.total_label = ttk.Label(self.root, text="Total: $0.00", font=("Arial", 12, "bold"))
        self.total_label.pack(anchor="e", padx=15, pady=5)

Your input form is ready to collect expense data. Next up, you will build the Treeview table that displays all saved expenses in a scrollable list.

Display Expenses in a Treeview Table

Your input form is ready to capture expense data. Now you need a way to display that data back to the user.

In this step, you will build a table that shows all recorded expenses, a method that keeps the table in sync with your storage, and action buttons for editing and deleting entries.

In this step, get ready to:
  • Create a Treeview table with columns and a scrollbar.
  • Write a refresh method that populates the table from storage.
  • Add Edit and Delete buttons below the table.
Create the Treeview widget

A Treeview is a ttk widget that displays tabular data in rows and columns. It is the closest thing to a spreadsheet view in Tkinter.

  • In app.py, find the end of the _create_input_form method.
  • Add the following _create_treeview method below it:
    def _create_treeview(self):
        """Create the Treeview table for displaying expenses."""
        tree_frame = ttk.Frame(self.root)
        tree_frame.pack(fill="both", expand=True, padx=10, pady=5)

        columns = ("ID", "Date", "Category", "Description", "Amount")
        self.tree = ttk.Treeview(tree_frame, columns=columns, show="headings", selectmode="browse")

        self.tree.heading("ID", text="ID")
        self.tree.heading("Date", text="Date")
        self.tree.heading("Category", text="Category")
        self.tree.heading("Description", text="Description")
        self.tree.heading("Amount", text="Amount")

        self.tree.column("ID", width=40, anchor="center")
        self.tree.column("Date", width=100, anchor="center")
        self.tree.column("Category", width=110, anchor="center")
        self.tree.column("Description", width=300, anchor="w")
        self.tree.column("Amount", width=90, anchor="e")

        scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
        self.tree.configure(yscrollcommand=scrollbar.set)

        self.tree.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")

What does this code do?

This method builds the main data display area of your app.

  • show="headings" hides the default tree column so your Treeview looks like a plain table.
  • selectmode="browse" limits selection to one row at a time.
  • Each .heading() call sets the visible header text, and each .column() call controls width and text alignment.
  • The scrollbar is linked bidirectionally to the Treeview via yscrollcommand and command so scrolling either one moves the other.
  • Save app.py.
  • You won't see a visual change yet. The next method will populate this table with expense data.

Seeing a red underline in VS Code?

Make sure the method is indented at the same level as _create_input_form (one level inside the class). Also confirm your parentheses and quotes are balanced on every line.

Help me fix a syntax error in my _create_treeview method.

Write the refresh method

The Treeview starts empty. You need a method that reads all expenses from storage and inserts them as rows. This same method will be called every time the data changes.

  • Below _create_treeview, add the _refresh_treeview method:
    def _refresh_treeview(self):
        """Reload all expenses from storage into the Treeview."""
        for item in self.tree.get_children():
            self.tree.delete(item)

        expenses = self.storage.get_all_expenses()
        for exp in expenses:
            display_row = (exp["id"], exp["date"], exp["category"], exp["description"], f"${exp['amount']:.2f}")
            self.tree.insert("", "end", values=display_row)

        total = self.storage.get_total()
        self.total_label.config(text=f"Total: ${total:.2f}")

What does this code do?

  • get_children() returns all current row IDs in the Treeview. The loop deletes every one, giving you a clean slate.
  • get_all_expenses() fetches the full list from your StorageManager dictionary, already sorted by date descending.
  • Each expense dictionary is turned into a display tuple. The amount is formatted as a dollar string (e.g. $12.50).
  • tree.insert("", "end", values=display_row) appends each row at the bottom of the table.
  • Finally, the total label updates to reflect the current sum of all expenses.
  • Save app.py.
  • You won't see output yet. The next chunk adds the action buttons, and in a later step you will run the app to see everything together.

Red squiggles under get_children or get_all_expenses?

Check that _refresh_treeview is placed after _create_treeview and uses self.tree (not tree). Also confirm that self.storage is set in __init__ before the method calls.

Help me debug my _refresh_treeview method.

Add Edit and Delete buttons

Users need a way to modify or remove expenses after they are recorded. Two action buttons below the table will handle this.

  • Below _create_treeview and above _refresh_treeview, add the _create_action_buttons method:
    def _create_action_buttons(self):
        """Create Edit and Delete buttons."""
        button_frame = ttk.Frame(self.root)
        button_frame.pack(fill="x", padx=10, pady=(0, 10))

        self.edit_button = ttk.Button(button_frame, text="Edit Selected", command=self._edit_expense)
        self.edit_button.pack(side="left", padx=5)

        self.delete_button = ttk.Button(button_frame, text="Delete Selected", command=self._delete_expense)
        self.delete_button.pack(side="left", padx=5)

What does this code do?

  • A separate ttk.Frame holds the buttons, keeping them visually grouped below the table.
  • Each button's command points to a method you will implement in the next step: _edit_expense and _delete_expense.
  • pack(side="left") lines the buttons up horizontally from the left edge.
  • Save app.py.
  • VS Code may show warnings that _edit_expense and _delete_expense do not exist yet. That is expected. You will create those methods in the next step.

Buttons not showing up when you eventually run the app?

Confirm that _create_action_buttons is called in __init__ after _create_treeview. The order of pack() calls determines the vertical layout of your window.

Help me figure out why my Edit and Delete buttons are not visible.

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py with all three new methods in place.

ⓧ I'd like to double check the full code

Here is the full app.py as it should look at the end of this step:

import tkinter as tk
from tkinter import ttk, messagebox
from datetime import date

from expense import Expense
from storage import StorageManager


class ExpenseTrackerApp:
    """Main application class for the Daily Expense Tracker."""

    def __init__(self):
        self.root = tk.Tk()
        self.root.title("Daily Expense Tracker")
        self.root.geometry("800x600")
        self.root.minsize(700, 500)

        self.storage = StorageManager()
        self.editing_id = None

        self._create_input_form()
        self._create_treeview()
        self._create_action_buttons()
        self._refresh_treeview()

    def _create_input_form(self):
        """Create the input form for adding/editing expenses."""
        form_frame = ttk.LabelFrame(self.root, text="Add Expense", padding=10)
        form_frame.pack(fill="x", padx=10, pady=(10, 5))

        ttk.Label(form_frame, text="Date:").grid(row=0, column=0, sticky="w", padx=5, pady=5)
        self.date_entry = ttk.Entry(form_frame, width=15)
        self.date_entry.grid(row=0, column=1, padx=5, pady=5)
        self.date_entry.insert(0, date.today().isoformat())

        ttk.Label(form_frame, text="Category:").grid(row=0, column=2, sticky="w", padx=5, pady=5)
        self.category_combo = ttk.Combobox(form_frame, values=Expense.CATEGORIES, state="readonly", width=15)
        self.category_combo.grid(row=0, column=3, padx=5, pady=5)
        self.category_combo.set(Expense.CATEGORIES[0])

        ttk.Label(form_frame, text="Description:").grid(row=1, column=0, sticky="w", padx=5, pady=5)
        self.desc_entry = ttk.Entry(form_frame, width=30)
        self.desc_entry.grid(row=1, column=1, columnspan=3, sticky="ew", padx=5, pady=5)

        ttk.Label(form_frame, text="Amount:").grid(row=1, column=4, sticky="w", padx=5, pady=5)
        self.amount_entry = ttk.Entry(form_frame, width=12)
        self.amount_entry.grid(row=1, column=5, padx=5, pady=5)

        self.add_button = ttk.Button(form_frame, text="Add Expense", command=self._add_expense)
        self.add_button.grid(row=0, column=5, padx=5, pady=5)

        self.clear_button = ttk.Button(form_frame, text="Clear", command=self._clear_form)
        self.clear_button.grid(row=0, column=6, padx=5, pady=5)

        form_frame.columnconfigure(1, weight=1)

        self.total_label = ttk.Label(self.root, text="Total: $0.00", font=("Arial", 12, "bold"))
        self.total_label.pack(anchor="e", padx=15, pady=5)

    def _create_treeview(self):
        """Create the Treeview table for displaying expenses."""
        tree_frame = ttk.Frame(self.root)
        tree_frame.pack(fill="both", expand=True, padx=10, pady=5)

        columns = ("ID", "Date", "Category", "Description", "Amount")
        self.tree = ttk.Treeview(tree_frame, columns=columns, show="headings", selectmode="browse")

        self.tree.heading("ID", text="ID")
        self.tree.heading("Date", text="Date")
        self.tree.heading("Category", text="Category")
        self.tree.heading("Description", text="Description")
        self.tree.heading("Amount", text="Amount")

        self.tree.column("ID", width=40, anchor="center")
        self.tree.column("Date", width=100, anchor="center")
        self.tree.column("Category", width=110, anchor="center")
        self.tree.column("Description", width=300, anchor="w")
        self.tree.column("Amount", width=90, anchor="e")

        scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
        self.tree.configure(yscrollcommand=scrollbar.set)

        self.tree.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")

    def _create_action_buttons(self):
        """Create Edit and Delete buttons."""
        button_frame = ttk.Frame(self.root)
        button_frame.pack(fill="x", padx=10, pady=(0, 10))

        self.edit_button = ttk.Button(button_frame, text="Edit Selected", command=self._edit_expense)
        self.edit_button.pack(side="left", padx=5)

        self.delete_button = ttk.Button(button_frame, text="Delete Selected", command=self._delete_expense)
        self.delete_button.pack(side="left", padx=5)

    def _refresh_treeview(self):
        """Reload all expenses from storage into the Treeview."""
        for item in self.tree.get_children():
            self.tree.delete(item)

        expenses = self.storage.get_all_expenses()
        for exp in expenses:
            display_row = (exp["id"], exp["date"], exp["category"], exp["description"], f"${exp['amount']:.2f}")
            self.tree.insert("", "end", values=display_row)

        total = self.storage.get_total()
        self.total_label.config(text=f"Total: ${total:.2f}")

Your table structure and refresh logic are in place. Next up, you will implement the add, edit, and delete functionality so the buttons and form actually do something.

Implement Add, Edit, and Delete Functionality

Your GUI is built and the Treeview table is ready to display data. The buttons are wired to method names that do not exist yet.

In this step, you will write the event handler methods that make those buttons work. Each method connects user actions (clicking a button, selecting a row) to the StorageManager methods you already built.

In this step, get ready to:
  • Write the add expense handler with input validation.
  • Write the delete expense handler with confirmation dialog.
  • Write the edit expense handler that populates the form for updates.
Write the add expense and clear form methods

The _add_expense() method is the core handler for the Add Expense button. It reads the form fields, validates the data, and either adds a new expense or updates an existing one.

  • In app.py, find the _create_action_buttons() method.
  • Below the closing line of that method, add the _add_expense() method by pasting this code:
    def _add_expense(self):
        """Add a new expense or update an existing one."""
        # Read values from the input fields
        date_str = self.date_entry.get().strip()
        category = self.category_combo.get()
        description = self.desc_entry.get().strip()
        amount = self.amount_entry.get().strip()

        # Create an Expense object with the form values
        expense = Expense(
            id=self.editing_id,
            date_str=date_str,
            category=category,
            description=description,
            amount=amount,
        )

        # Validate the expense data before saving
        try:
            expense.validate()
        except ValueError as e:
            messagebox.showerror("Validation Error", str(e))
            return

        # Update if editing, otherwise add as new
        if self.editing_id is not None:
            self.storage.update_expense(
                self.editing_id, date_str, category, description, expense.amount
            )
            self.editing_id = None
            self.add_button.config(text="Add Expense")
        else:
            self.storage.add_expense(expense)

        self._clear_form()
        self._refresh_treeview()

What does this code do?

This method handles both adding and updating. It reads the four form fields, packages them into an Expense object, then calls validate() inside a try/except block.

  • If validation fails, messagebox.showerror() pops up a dialog with the error message and return stops the method early.
  • If self.editing_id is set, the method calls update_expense() instead of add_expense(). This lets the same button handle both operations.

The _add_expense() method calls _clear_form() at the end, but that method does not exist yet. You will write it next.

  • Directly below the _add_expense() method you just wrote, add the _clear_form() method:
    def _clear_form(self):
        """Clear all input fields and reset to add mode."""
        # Reset date to today
        self.date_entry.delete(0, tk.END)
        self.date_entry.insert(0, date.today().isoformat())
        # Reset category to first option
        self.category_combo.set(Expense.CATEGORIES[0])
        # Clear text fields
        self.desc_entry.delete(0, tk.END)
        self.amount_entry.delete(0, tk.END)
        # Exit editing mode
        self.editing_id = None
        self.add_button.config(text="Add Expense")

What does this code do?

This helper resets all form fields back to their defaults. It clears the text entries, resets the date to today, sets the category back to the first option, and resets the button text from "Update Expense" back to "Add Expense".

Setting self.editing_id = None tells _add_expense() that the next click should create a new expense rather than update an existing one.

  • Save app.py.

Seeing a red underline under _clear_form?

Make sure _clear_form() is indented at the same level as _add_expense(). Both methods must be inside the ExpenseTrackerApp class (one level of indentation from the class body).

help me fix indentation errors in my Python class methods

Write the delete expense method

Deleting needs two safety checks: is anything selected, and does the user really want to delete it? The messagebox module provides built-in confirmation dialogs for this.

  • In app.py, add the _delete_expense() method below _clear_form():
    def _delete_expense(self):
        """Delete the selected expense."""
        # Check if a row is selected in the Treeview
        selected = self.tree.selection()
        if not selected:
            messagebox.showwarning("No Selection", "Please select an expense to delete.")
            return

        # Ask for confirmation before deleting
        confirm = messagebox.askyesno("Confirm Delete", "Are you sure you want to delete this expense?")
        if confirm:
            item_values = self.tree.item(selected[0], "values")
            expense_id = int(item_values[0])
            self.storage.delete_expense(expense_id)
            self._refresh_treeview()

What does this code do?

  • self.tree.selection() returns a tuple of selected item IDs. If nothing is selected, it shows a warning and exits early.
  • messagebox.askyesno() shows a Yes/No dialog and returns True or False.
  • self.tree.item(selected[0], "values") retrieves the row data. The first value (index 0) is the expense ID, which gets passed to storage.delete_expense().
  • Save app.py.

Getting a NameError for messagebox?

Check the imports at the top of app.py. You should have from tkinter import ttk, messagebox on the second line. If you only imported ttk, add messagebox to that same import.

help me fix my messagebox import

Write the edit expense method

Editing reuses the same form as adding. When the user clicks Edit Selected, the method fills the form with the selected row's data and switches the button to update mode.

  • In app.py, add the _edit_expense() method below _delete_expense():
    def _edit_expense(self):
        """Load the selected expense into the form for editing."""
        # Check if a row is selected
        selected = self.tree.selection()
        if not selected:
            messagebox.showwarning("No Selection", "Please select an expense to edit.")
            return

        # Get the values from the selected row
        item_values = self.tree.item(selected[0], "values")
        self.editing_id = int(item_values[0])

        # Clear the form and populate with selected expense data
        self._clear_form()
        self.date_entry.delete(0, tk.END)
        self.date_entry.insert(0, item_values[1])
        self.category_combo.set(item_values[2])
        self.desc_entry.insert(0, item_values[3])
        self.amount_entry.insert(0, item_values[4].replace("$", ""))

        # Switch button to update mode
        self.add_button.config(text="Update Expense")

What does this code do?

  • It stores the selected expense's ID in self.editing_id. This tells _add_expense() to run an update instead of a create on the next click.
  • It calls _clear_form() first (which resets to today's date), then immediately overwrites the fields with the selected row's values.
  • The .replace("$", "") strips the dollar sign from the displayed amount so the user sees a clean number they can edit.
  • Save app.py.

Form not populating when you click Edit?

Make sure _clear_form() is called before inserting new values. Without it, the old field contents remain and the new values append to them instead of replacing them.

help me debug my edit expense method

Make sure the method order in your file is: _create_action_buttons(), then _add_expense(), then _clear_form(), then _delete_expense(), then _edit_expense(), then _refresh_treeview().

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py with all four new methods in place.

ⓧ I'd like to double check the full code

Here is the complete app.py as it should look at the end of this step:

import tkinter as tk
from tkinter import ttk, messagebox
from datetime import date

from expense import Expense
from storage import StorageManager


class ExpenseTrackerApp:
    """Main application class for the Daily Expense Tracker."""

    def __init__(self):
        self.root = tk.Tk()
        self.root.title("Daily Expense Tracker")
        self.root.geometry("800x600")
        self.root.minsize(700, 500)

        self.storage = StorageManager()
        self.editing_id = None

        self._create_input_form()
        self._create_treeview()
        self._create_action_buttons()
        self._refresh_treeview()

    def _create_input_form(self):
        """Create the input form for adding/editing expenses."""
        form_frame = ttk.LabelFrame(self.root, text="Add Expense", padding=10)
        form_frame.pack(fill="x", padx=10, pady=(10, 5))

        ttk.Label(form_frame, text="Date:").grid(row=0, column=0, sticky="w", padx=5, pady=5)
        self.date_entry = ttk.Entry(form_frame, width=15)
        self.date_entry.grid(row=0, column=1, padx=5, pady=5)
        self.date_entry.insert(0, date.today().isoformat())

        ttk.Label(form_frame, text="Category:").grid(row=0, column=2, sticky="w", padx=5, pady=5)
        self.category_combo = ttk.Combobox(form_frame, values=Expense.CATEGORIES, state="readonly", width=15)
        self.category_combo.grid(row=0, column=3, padx=5, pady=5)
        self.category_combo.set(Expense.CATEGORIES[0])

        ttk.Label(form_frame, text="Description:").grid(row=1, column=0, sticky="w", padx=5, pady=5)
        self.desc_entry = ttk.Entry(form_frame, width=30)
        self.desc_entry.grid(row=1, column=1, columnspan=3, sticky="ew", padx=5, pady=5)

        ttk.Label(form_frame, text="Amount:").grid(row=1, column=4, sticky="w", padx=5, pady=5)
        self.amount_entry = ttk.Entry(form_frame, width=12)
        self.amount_entry.grid(row=1, column=5, padx=5, pady=5)

        self.add_button = ttk.Button(form_frame, text="Add Expense", command=self._add_expense)
        self.add_button.grid(row=0, column=5, padx=5, pady=5)

        self.clear_button = ttk.Button(form_frame, text="Clear", command=self._clear_form)
        self.clear_button.grid(row=0, column=6, padx=5, pady=5)

        form_frame.columnconfigure(1, weight=1)

        self.total_label = ttk.Label(self.root, text="Total: $0.00", font=("Arial", 12, "bold"))
        self.total_label.pack(anchor="e", padx=15, pady=5)

    def _create_treeview(self):
        """Create the Treeview table for displaying expenses."""
        tree_frame = ttk.Frame(self.root)
        tree_frame.pack(fill="both", expand=True, padx=10, pady=5)

        columns = ("ID", "Date", "Category", "Description", "Amount")
        self.tree = ttk.Treeview(tree_frame, columns=columns, show="headings", selectmode="browse")

        self.tree.heading("ID", text="ID")
        self.tree.heading("Date", text="Date")
        self.tree.heading("Category", text="Category")
        self.tree.heading("Description", text="Description")
        self.tree.heading("Amount", text="Amount")

        self.tree.column("ID", width=40, anchor="center")
        self.tree.column("Date", width=100, anchor="center")
        self.tree.column("Category", width=110, anchor="center")
        self.tree.column("Description", width=300, anchor="w")
        self.tree.column("Amount", width=90, anchor="e")

        scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
        self.tree.configure(yscrollcommand=scrollbar.set)

        self.tree.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")

    def _create_action_buttons(self):
        """Create Edit and Delete buttons."""
        button_frame = ttk.Frame(self.root)
        button_frame.pack(fill="x", padx=10, pady=(0, 10))

        self.edit_button = ttk.Button(button_frame, text="Edit Selected", command=self._edit_expense)
        self.edit_button.pack(side="left", padx=5)

        self.delete_button = ttk.Button(button_frame, text="Delete Selected", command=self._delete_expense)
        self.delete_button.pack(side="left", padx=5)

    def _add_expense(self):
        """Add a new expense or update an existing one."""
        date_str = self.date_entry.get().strip()
        category = self.category_combo.get()
        description = self.desc_entry.get().strip()
        amount = self.amount_entry.get().strip()

        expense = Expense(
            id=self.editing_id,
            date_str=date_str,
            category=category,
            description=description,
            amount=amount,
        )

        try:
            expense.validate()
        except ValueError as e:
            messagebox.showerror("Validation Error", str(e))
            return

        if self.editing_id is not None:
            self.storage.update_expense(
                self.editing_id, date_str, category, description, expense.amount
            )
            self.editing_id = None
            self.add_button.config(text="Add Expense")
        else:
            self.storage.add_expense(expense)

        self._clear_form()
        self._refresh_treeview()

    def _delete_expense(self):
        """Delete the selected expense."""
        selected = self.tree.selection()
        if not selected:
            messagebox.showwarning("No Selection", "Please select an expense to delete.")
            return

        confirm = messagebox.askyesno("Confirm Delete", "Are you sure you want to delete this expense?")
        if confirm:
            item_values = self.tree.item(selected[0], "values")
            expense_id = int(item_values[0])
            self.storage.delete_expense(expense_id)
            self._refresh_treeview()

    def _edit_expense(self):
        """Load the selected expense into the form for editing."""
        selected = self.tree.selection()
        if not selected:
            messagebox.showwarning("No Selection", "Please select an expense to edit.")
            return

        item_values = self.tree.item(selected[0], "values")
        self.editing_id = int(item_values[0])

        self._clear_form()
        self.date_entry.delete(0, tk.END)
        self.date_entry.insert(0, item_values[1])
        self.category_combo.set(item_values[2])
        self.desc_entry.insert(0, item_values[3])
        self.amount_entry.insert(0, item_values[4].replace("$", ""))

        self.add_button.config(text="Update Expense")

    def _clear_form(self):
        """Clear all input fields and reset to add mode."""
        self.date_entry.delete(0, tk.END)
        self.date_entry.insert(0, date.today().isoformat())
        self.category_combo.set(Expense.CATEGORIES[0])
        self.desc_entry.delete(0, tk.END)
        self.amount_entry.delete(0, tk.END)
        self.editing_id = None
        self.add_button.config(text="Add Expense")

    def _refresh_treeview(self):
        """Reload all expenses from storage into the Treeview."""
        for item in self.tree.get_children():
            self.tree.delete(item)

        expenses = self.storage.get_all_expenses()
        for exp in expenses:
            display_row = (exp["id"], exp["date"], exp["category"], exp["description"], f"${exp['amount']:.2f}")
            self.tree.insert("", "end", values=display_row)

        total = self.storage.get_total()
        self.total_label.config(text=f"Total: ${total:.2f}")

Your CRUD methods are all in place. Next up, you will add the entry point that actually launches the application window so you can test everything live.

Run and Test Your Application

You have built three complete classes across three files. The Expense model validates data, the StorageManager handles persistence, and ExpenseTrackerApp owns the entire GUI.

Now you need to wire up the application's entry point so Python knows what to run when you execute the file. After that, you will put the full CRUD workflow through its paces.

In this step, get ready to:
  • Add the application entry point to app.py.
  • Run the Expense Tracker and test adding, editing, and deleting expenses.
  • Verify that data persists in expenses.json between sessions.
Add the entry point and launch the app

Python scripts need an entry point that tells the interpreter what to execute when you run the file directly. The if __name__ == "__main__": pattern ensures the app only launches when you run app.py directly, not when another file imports it.

  • In VS Code, open app.py and scroll to the very bottom of the file, after the last method of the ExpenseTrackerApp class.
  • Add the following code below the _refresh_treeview method, at the same indentation level as the class methods for the run() method, and at the module level (no indentation) for the if __name__ block:
    def run(self):
        """Start the application main loop."""
        self.root.mainloop()


if __name__ == "__main__":
    app = ExpenseTrackerApp()
    app.run()

What does this code do?

The run() method wraps self.root.mainloop(), which starts Tkinter's event loop. This loop keeps the window open and responsive to clicks, typing, and button presses.

The if __name__ == "__main__": guard creates an ExpenseTrackerApp instance and calls app.run() only when you execute the file directly.

  • Save app.py.
  • Open your terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Run the application by entering this command:
python app.py

A window titled "Daily Expense Tracker" should appear with the input form at the top, an empty table in the middle, and "Total: $0.00" on the right.

Window not appearing?

  • Make sure your terminal is inside the expense-tracker folder. Run ls and confirm you see app.py, expense.py, and storage.py listed.
  • If you see ModuleNotFoundError, check that all three files are saved and in the same folder.
  • If you see a SyntaxError, check that the if __name__ block has no indentation (it sits outside the class).

Why isn't my Expense Tracker window appearing when I run python app.py?

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete app.py file. Compare yours against it:

import tkinter as tk
from tkinter import ttk, messagebox
from datetime import date

from expense import Expense
from storage import StorageManager


class ExpenseTrackerApp:
    """Main application class for the Daily Expense Tracker."""

    def __init__(self):
        self.root = tk.Tk()
        self.root.title("Daily Expense Tracker")
        self.root.geometry("800x600")
        self.root.minsize(700, 500)

        self.storage = StorageManager()
        self.editing_id = None

        self._create_input_form()
        self._create_treeview()
        self._create_action_buttons()
        self._refresh_treeview()

    def _create_input_form(self):
        """Create the input form for adding/editing expenses."""
        form_frame = ttk.LabelFrame(self.root, text="Add Expense", padding=10)
        form_frame.pack(fill="x", padx=10, pady=(10, 5))

        ttk.Label(form_frame, text="Date:").grid(row=0, column=0, sticky="w", padx=5, pady=5)
        self.date_entry = ttk.Entry(form_frame, width=15)
        self.date_entry.grid(row=0, column=1, padx=5, pady=5)
        self.date_entry.insert(0, date.today().isoformat())

        ttk.Label(form_frame, text="Category:").grid(row=0, column=2, sticky="w", padx=5, pady=5)
        self.category_combo = ttk.Combobox(form_frame, values=Expense.CATEGORIES, state="readonly", width=15)
        self.category_combo.grid(row=0, column=3, padx=5, pady=5)
        self.category_combo.set(Expense.CATEGORIES[0])

        ttk.Label(form_frame, text="Description:").grid(row=1, column=0, sticky="w", padx=5, pady=5)
        self.desc_entry = ttk.Entry(form_frame, width=30)
        self.desc_entry.grid(row=1, column=1, columnspan=3, sticky="ew", padx=5, pady=5)

        ttk.Label(form_frame, text="Amount:").grid(row=1, column=4, sticky="w", padx=5, pady=5)
        self.amount_entry = ttk.Entry(form_frame, width=12)
        self.amount_entry.grid(row=1, column=5, padx=5, pady=5)

        self.add_button = ttk.Button(form_frame, text="Add Expense", command=self._add_expense)
        self.add_button.grid(row=0, column=5, padx=5, pady=5)

        self.clear_button = ttk.Button(form_frame, text="Clear", command=self._clear_form)
        self.clear_button.grid(row=0, column=6, padx=5, pady=5)

        form_frame.columnconfigure(1, weight=1)

        self.total_label = ttk.Label(self.root, text="Total: $0.00", font=("Arial", 12, "bold"))
        self.total_label.pack(anchor="e", padx=15, pady=5)

    def _create_treeview(self):
        """Create the Treeview table for displaying expenses."""
        tree_frame = ttk.Frame(self.root)
        tree_frame.pack(fill="both", expand=True, padx=10, pady=5)

        columns = ("ID", "Date", "Category", "Description", "Amount")
        self.tree = ttk.Treeview(tree_frame, columns=columns, show="headings", selectmode="browse")

        self.tree.heading("ID", text="ID")
        self.tree.heading("Date", text="Date")
        self.tree.heading("Category", text="Category")
        self.tree.heading("Description", text="Description")
        self.tree.heading("Amount", text="Amount")

        self.tree.column("ID", width=40, anchor="center")
        self.tree.column("Date", width=100, anchor="center")
        self.tree.column("Category", width=110, anchor="center")
        self.tree.column("Description", width=300, anchor="w")
        self.tree.column("Amount", width=90, anchor="e")

        scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
        self.tree.configure(yscrollcommand=scrollbar.set)

        self.tree.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")

    def _create_action_buttons(self):
        """Create Edit and Delete buttons."""
        button_frame = ttk.Frame(self.root)
        button_frame.pack(fill="x", padx=10, pady=(0, 10))

        self.edit_button = ttk.Button(button_frame, text="Edit Selected", command=self._edit_expense)
        self.edit_button.pack(side="left", padx=5)

        self.delete_button = ttk.Button(button_frame, text="Delete Selected", command=self._delete_expense)
        self.delete_button.pack(side="left", padx=5)

    def _add_expense(self):
        """Add a new expense or update an existing one."""
        date_str = self.date_entry.get().strip()
        category = self.category_combo.get()
        description = self.desc_entry.get().strip()
        amount = self.amount_entry.get().strip()

        expense = Expense(
            id=self.editing_id,
            date_str=date_str,
            category=category,
            description=description,
            amount=amount,
        )

        try:
            expense.validate()
        except ValueError as e:
            messagebox.showerror("Validation Error", str(e))
            return

        if self.editing_id is not None:
            self.storage.update_expense(
                self.editing_id, date_str, category, description, expense.amount
            )
            self.editing_id = None
            self.add_button.config(text="Add Expense")
        else:
            self.storage.add_expense(expense)

        self._clear_form()
        self._refresh_treeview()

    def _delete_expense(self):
        """Delete the selected expense."""
        selected = self.tree.selection()
        if not selected:
            messagebox.showwarning("No Selection", "Please select an expense to delete.")
            return

        confirm = messagebox.askyesno("Confirm Delete", "Are you sure you want to delete this expense?")
        if confirm:
            item_values = self.tree.item(selected[0], "values")
            expense_id = int(item_values[0])
            self.storage.delete_expense(expense_id)
            self._refresh_treeview()

    def _edit_expense(self):
        """Load the selected expense into the form for editing."""
        selected = self.tree.selection()
        if not selected:
            messagebox.showwarning("No Selection", "Please select an expense to edit.")
            return

        item_values = self.tree.item(selected[0], "values")
        self.editing_id = int(item_values[0])

        self._clear_form()
        self.date_entry.delete(0, tk.END)
        self.date_entry.insert(0, item_values[1])
        self.category_combo.set(item_values[2])
        self.desc_entry.insert(0, item_values[3])
        self.amount_entry.insert(0, item_values[4].replace("$", ""))

        self.add_button.config(text="Update Expense")

    def _clear_form(self):
        """Clear all input fields and reset to add mode."""
        self.date_entry.delete(0, tk.END)
        self.date_entry.insert(0, date.today().isoformat())
        self.category_combo.set(Expense.CATEGORIES[0])
        self.desc_entry.delete(0, tk.END)
        self.amount_entry.delete(0, tk.END)
        self.editing_id = None
        self.add_button.config(text="Add Expense")

    def _refresh_treeview(self):
        """Reload all expenses from storage into the Treeview."""
        for item in self.tree.get_children():
            self.tree.delete(item)

        expenses = self.storage.get_all_expenses()
        for exp in expenses:
            display_row = (exp["id"], exp["date"], exp["category"], exp["description"], f"${exp['amount']:.2f}")
            self.tree.insert("", "end", values=display_row)

        total = self.storage.get_total()
        self.total_label.config(text=f"Total: ${total:.2f}")

    def run(self):
        """Start the application main loop."""
        self.root.mainloop()


if __name__ == "__main__":
    app = ExpenseTrackerApp()
    app.run()
Test the full CRUD workflow

Your app is running, but you need to confirm that every feature works end-to-end. You will test adding, editing, deleting, and persistence.

  • In the running app, leave the Date field as today's date.
  • Select Food from the Category dropdown.
  • Type Lunch at cafe in the Description field.
  • Type 12.50 in the Amount field.
  • Click Add Expense.

The expense appears in the table and the total updates to "Total: $12.50".

  • Add a second expense: select Transport as the category, type Bus pass for the description, and 45.00 for the amount.
  • Click Add Expense.

The total should now read "Total: $57.50" with both expenses visible in the table.

  • Click on the "Lunch at cafe" row to select it.
  • Click Edit Selected.
  • The form fields populate with that expense's values, and the button text changes to Update Expense.
  • Change the amount to 15.00 and click Update Expense.

The table row updates to show $15.00, and the total changes to "Total: $60.00".

  • Click on the "Bus pass" row to select it.
  • Click Delete Selected.
  • A confirmation dialog appears. Click Yes.

The "Bus pass" row disappears and the total drops to "Total: $15.00".

What is CRUD?

CRUD stands for Create, Read, Update, Delete. These are the four basic operations for managing stored data. You just tested all four: adding (Create), viewing in the table (Read), editing (Update), and removing (Delete).

  • Close the application window.
  • Run the app again by entering this command:
python app.py

Your "Lunch at cafe" expense should still appear in the table with the updated $15.00 amount. This confirms that data persists to expenses.json between sessions.

Where does the data live?

Look in your expense-tracker folder. You will see a new file called expenses.json that was auto-created the first time you added an expense. Open it in VS Code to see the raw JSON data your StorageManager wrote.

Data not persisting after restart?

  • Check that you are running python app.py from inside the expense-tracker folder. The expenses.json file is created relative to where you run the command.
  • Open expenses.json and verify it contains a "expenses" key with your data inside it.

Why is my expense data not showing up when I reopen the app?

Secret mission

Add Category and Date Range Filters

Your expense tracker shows every expense at once. What if you only want to see this week's food spending? Build a filter panel that lets you narrow expenses by category and date range, with a live filtered total.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on your machine, so there are no ongoing costs or cloud resources to worry about.

Decide whether to keep your project files to continue experimenting, or delete them to free up disk space.

Resources you used:

  • The expense-tracker project folder on your Desktop (contains expense.py, storage.py, app.py, and expenses.json).

Keep everything running

No action needed. Choose this if you want to keep using the Expense Tracker or continue building on it.

  • Your project files stay in the expense-tracker folder on your Desktop.
  • Your expense data persists in expenses.json and loads automatically when you reopen the app.
  • Python and VS Code remain installed on your machine for future projects.

Pause - I'll come back to this later

Since this is a local project with no running processes, pausing and keeping are the same thing. Your files sit on disk until you return.

  • Close the Expense Tracker window if it is still open.
  • Close VS Code.
  • When you are ready to resume, reopen the expense-tracker folder in VS Code and run python app.py again.

Delete - I don't want to use this again

Remove all project files from your machine. This deletes your expense data permanently.

  • Close the Expense Tracker window and VS Code.
  • Delete the project folder by running the command for your operating system below.

macOS

rm -rf ~/Desktop/expense-tracker

Windows

rmdir /s /q %USERPROFILE%\Desktop\expense-tracker
  • Confirm the folder is gone by checking your Desktop.

Nice Work!

Nice Work!

Congratulations! You built a complete desktop Expense Tracker application from scratch using Python Object-Oriented Programming and Tkinter.

You've learned how to:

  • Designed a multi-class Python application using OOP principles with distinct classes for data models (Expense), storage operations (StorageManager), and UI components (ExpenseTrackerApp).
  • Built a graphical interface with ttk themed widgets including Treeview, Combobox, and messagebox dialogs, and implemented full CRUD operations (Create, Read, Update, Delete) backed by a dictionary data store.
  • Used JSON serialization to persist your expense dictionary to a file, with input validation ensuring data integrity across sessions.
  • Added category and date range filters for targeted expense viewing.

Ready to quiz yourself?