Daily Expense Tracker with Python

Build a desktop expense tracker GUI app using Python Tkinter and OOP.

Introduction

30 Second Summary

Keeping track of daily spending is one of those things everyone knows they should do. Yet most people lose track of where their money goes because jotting expenses in a notes app is messy and easy to forget.

In this project, you will build a Daily Expense Tracker desktop application using Python and Tkinter. You will structure the app with Object-Oriented Programming so that each piece of your code has a clear, single responsibility.

What You'll Build

A desktop expense tracker with a modern GUI that lets you add, view, edit, and delete categorized expenses, with all data persisted in a JSON file between sessions.

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

  • A well-structured OOP codebase with separate classes for data models, storage management, and UI components.
  • A clean GUI interface built with ttk themed widgets including a Treeview table, Combobox dropdowns, and labeled input frames.
  • Full CRUD operations (create, read, update, delete) with input validation and persistent JSON storage that survives application restarts.
  • Secret Mission: Add date filtering to view expenses for a specific date range and display category-wise spending summaries.

Are there any prerequisites?

You should be comfortable with basic Python concepts like variables, functions, loops, and conditionals. No prior experience with GUIs or Object-Oriented Programming is needed.

Build the Expense Data Model

Your development environment is ready. Now it is time to build the foundation of your expense tracker: the data model.

Every well-structured application separates its data from its interface. In this step, you will create Python classes that define what an expense looks like and how to manage a collection of expenses.

In this step, get ready to:
  • Create an Expense class that represents a single expense entry.
  • Create an ExpenseManager class that handles all business logic for expenses.
  • Test the data model by creating an expense and printing its dictionary form.
Add the project imports

Before writing any classes, you need to import the modules your project will use throughout development. These cover the GUI toolkit, file handling, and date utilities.

  • Switch back to expense_tracker.py in VS Code and add the following imports at the top of the file:
import tkinter as tk
from tkinter import ttk, messagebox
import json
import os
from datetime import date, datetime

What do these imports do?

  • tkinter and ttk provide the GUI toolkit and themed widgets for your app's interface.
  • json and os handle reading/writing expense data to a file and checking if the file exists.
  • date and datetime let you work with today's date and validate date strings.
  • Save expense_tracker.py.

You won't see a change yet. The next chunk builds the Expense class that uses these imports.

Create the Expense class

Each expense in your tracker needs a consistent structure. The Expense class defines exactly what data an expense holds and how to convert it to and from a dictionary for storage.

  • Below your imports, add the Expense class by pasting the following code:

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

    def __init__(self, expense_id, expense_date, category, description, amount):
        self.expense_id = expense_id
        self.date = expense_date
        self.category = category
        self.description = description
        self.amount = amount

    def to_dict(self):
        """Convert expense to dictionary for JSON storage."""
        return {
            "expense_id": self.expense_id,
            "date": self.date,
            "category": self.category,
            "description": self.description,
            "amount": self.amount
        }

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

What does this class do?

The Expense class is a blueprint for a single expense entry. It stores five pieces of data: an ID, date, category, description, and amount.

  • to_dict() converts an Expense object into a plain dictionary. This is what gets saved to your JSON file later.
  • from_dict() is a class method that does the reverse. It takes a dictionary (loaded from JSON) and creates an Expense object from it.
  • Save expense_tracker.py.
  • In VS Code's terminal, run the file to confirm there are no syntax errors:
python expense_tracker.py

You should see no output and no errors. The file defines the class and exits cleanly.

Seeing a SyntaxError?

  • Check that the class Expense: line is not indented. It should start at column 0.
  • Make sure all method definitions inside the class use consistent 4-space indentation.
  • Verify the @classmethod decorator sits directly above def from_dict with no blank line between them.

Why is my Expense class causing a SyntaxError?

Create the ExpenseManager class

A data model on its own cannot do much. The ExpenseManager class handles all the business logic: adding, updating, deleting, and totalling expenses.

  • Below the Expense class, add the ExpenseManager class:

class ExpenseManager:
    """Manages expense operations and business logic."""

    def __init__(self, data_store):
        self.data_store = data_store
        self.expenses = self.data_store.load_all()
        self._next_id = self._calculate_next_id()

    def _calculate_next_id(self):
        """Determine the next available expense ID."""
        if not self.expenses:
            return 1
        return max(exp.expense_id for exp in self.expenses) + 1

    def add_expense(self, expense_date, category, description, amount):
        """Add a new expense and save to file."""
        expense = Expense(self._next_id, expense_date, category, description, amount)
        self.expenses.append(expense)
        self._next_id += 1
        self.data_store.save_all(self.expenses)
        return expense

    def update_expense(self, expense_id, expense_date, category, description, amount):
        """Update an existing expense and save to file."""
        for expense in self.expenses:
            if expense.expense_id == expense_id:
                expense.date = expense_date
                expense.category = category
                expense.description = description
                expense.amount = amount
                self.data_store.save_all(self.expenses)
                return True
        return False

    def delete_expense(self, expense_id):
        """Delete an expense by ID and save to file."""
        self.expenses = [exp for exp in self.expenses if exp.expense_id != expense_id]
        self.data_store.save_all(self.expenses)

    def get_total(self):
        """Calculate total of all expenses."""
        return sum(exp.amount for exp in self.expenses)

    def get_all_expenses(self):
        """Return all expenses."""
        return self.expenses

What does ExpenseManager do?

This class keeps your business logic in one place, separate from data storage and the UI. It accepts a data_store object (which you will build in the next step) for loading and saving.

  • _calculate_next_id() finds the highest existing ID and adds 1, so each new expense gets a unique number.
  • add_expense() creates an Expense object, appends it to the list, and saves the file.
  • update_expense() loops through expenses to find a match by ID, then overwrites its fields.
  • delete_expense() rebuilds the list excluding the target ID.
  • get_total() sums all amounts for the running total display.

Why separate Expense from ExpenseManager?

This is the Object-Oriented Programming principle of separation of concerns. Expense only knows what an expense IS. ExpenseManager knows how to work WITH expenses.

If you later want to change how expenses are stored (from JSON to a database, for example), you only modify the storage layer. The Expense class stays untouched.

  • Save expense_tracker.py.
  • Run the file again in your terminal:
python expense_tracker.py

You should still see no output and no errors. Both classes are defined but not yet called.

Seeing a NameError or IndentationError?

  • Make sure the ExpenseManager class starts at column 0 (no indentation), at the same level as the Expense class.
  • Check that class ExpenseManager: appears AFTER the entire Expense class, not inside it.
  • Verify there is a blank line between the end of Expense and the start of ExpenseManager.

Why is my ExpenseManager class throwing an error?

Test the data model

Before building the full UI, you should confirm that the Expense class works correctly on its own. A quick test in a __main__ block lets you create an expense and see its dictionary output.

  • At the bottom of expense_tracker.py, below the ExpenseManager class, add this test block:

if __name__ == "__main__":
    # Temporary test
    from dataclasses import dataclass
    test_expense = Expense(1, "2024-01-15", "Food", "Lunch", 12.50)
    print(test_expense.to_dict())

What does this test do?

The if __name__ == "__main__": block only runs when you execute the file directly. It creates a sample Expense with an ID of 1, a date, category, description, and amount, then prints the dictionary representation.

This is a temporary test. You will remove it later once the full application is wired up.

  • Save expense_tracker.py.
  • Run the file to see your test output:
python expense_tracker.py

You should see a dictionary printed in your terminal:

{'expense_id': 1, 'date': '2024-01-15', 'category': 'Food', 'description': 'Lunch', 'amount': 12.5}

This confirms your Expense class can create objects and serialize them to dictionaries correctly.

Not seeing the dictionary output?

  • Make sure the if __name__ block is at column 0 (no indentation). It must be outside both classes.
  • Verify the print(test_expense.to_dict()) line is indented one level (4 spaces) inside the if block.
  • Check that you saved the file before running the command.

Why isn't my test block printing anything?

✔️ Awesome, I've got everything!

Great. Double check you have saved expense_tracker.py and confirmed the dictionary output in your terminal.

ⓧ I'd like to double check the full code

Here is the complete expense_tracker.py file at this point:

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


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

    def __init__(self, expense_id, expense_date, category, description, amount):
        self.expense_id = expense_id
        self.date = expense_date
        self.category = category
        self.description = description
        self.amount = amount

    def to_dict(self):
        """Convert expense to dictionary for JSON storage."""
        return {
            "expense_id": self.expense_id,
            "date": self.date,
            "category": self.category,
            "description": self.description,
            "amount": self.amount
        }

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


class ExpenseManager:
    """Manages expense operations and business logic."""

    def __init__(self, data_store):
        self.data_store = data_store
        self.expenses = self.data_store.load_all()
        self._next_id = self._calculate_next_id()

    def _calculate_next_id(self):
        """Determine the next available expense ID."""
        if not self.expenses:
            return 1
        return max(exp.expense_id for exp in self.expenses) + 1

    def add_expense(self, expense_date, category, description, amount):
        """Add a new expense and save to file."""
        expense = Expense(self._next_id, expense_date, category, description, amount)
        self.expenses.append(expense)
        self._next_id += 1
        self.data_store.save_all(self.expenses)
        return expense

    def update_expense(self, expense_id, expense_date, category, description, amount):
        """Update an existing expense and save to file."""
        for expense in self.expenses:
            if expense.expense_id == expense_id:
                expense.date = expense_date
                expense.category = category
                expense.description = description
                expense.amount = amount
                self.data_store.save_all(self.expenses)
                return True
        return False

    def delete_expense(self, expense_id):
        """Delete an expense by ID and save to file."""
        self.expenses = [exp for exp in self.expenses if exp.expense_id != expense_id]
        self.data_store.save_all(self.expenses)

    def get_total(self):
        """Calculate total of all expenses."""
        return sum(exp.amount for exp in self.expenses)

    def get_all_expenses(self):
        """Return all expenses."""
        return self.expenses


if __name__ == "__main__":
    # Temporary test
    from dataclasses import dataclass
    test_expense = Expense(1, "2024-01-15", "Food", "Lunch", 12.50)
    print(test_expense.to_dict())

Your data model is solid. Next up, you will build the DataStore class that reads and writes expenses to a JSON file, giving your app persistent memory between sessions.

Implement JSON File Storage

Your Expense and ExpenseManager classes can create and manipulate expenses in memory. The moment you close the program, everything disappears.

To make your expenses survive between sessions, you need a persistence layer. In this step, you will build a DataStore class that reads and writes expense data to a JSON file.

In this step, get ready to:
  • Create the DataStore class for JSON file persistence.
  • Implement methods to save and load expense data.
  • Verify the full data pipeline works end to end.
Create the DataStore class

The DataStore class is responsible for one thing: reading and writing the JSON file. It does not know about business logic or the UI.

  • In expense_tracker.py, find the line that says class ExpenseManager: and add the following code above it:
class DataStore:
    """Handles reading and writing expense data to a JSON file."""

    def __init__(self, filename="expenses.json"):
        self.filename = filename
        # Create the file with an empty list if it doesn't exist yet
        if not os.path.exists(self.filename):
            with open(self.filename, "w") as f:
                json.dump([], f)

What does this code do?

The DataStore constructor accepts a filename (defaulting to expenses.json). If the file does not already exist, it creates one containing an empty JSON array.

This means the very first time you run the app, it automatically sets up its own storage. No manual file creation needed.

  • Save expense_tracker.py.
  • You won't see a visible change yet. The next chunk adds the methods that make this class useful.
Implement save_all() and load_all() methods

Your DataStore needs two abilities: loading expenses from disk into Python objects, and saving Python objects back to disk as JSON.

  • Inside the DataStore class, add the following two methods below __init__:
    def load_all(self):
        """Load all expenses from the JSON file."""
        try:
            with open(self.filename, "r") as f:
                data = json.load(f)
            # Convert each dictionary back into an Expense object
            return [Expense.from_dict(item) for item in data]
        except (json.JSONDecodeError, KeyError):
            return []

    def save_all(self, expenses):
        """Save all expenses to the JSON file."""
        # Convert each Expense object into a dictionary
        data = [expense.to_dict() for expense in expenses]
        with open(self.filename, "w") as f:
            json.dump(data, f, indent=2)

What does this code do?

  • load_all() opens the JSON file, reads the list of dictionaries, and converts each one into an Expense object using the from_dict() class method you built earlier.
  • The try/except block handles corrupted or empty files gracefully by returning an empty list.
  • save_all() does the reverse. It converts each Expense into a dictionary using to_dict(), then writes the full list to disk with json.dump().
  • indent=2 makes the JSON file human-readable if you ever open it manually.
  • Save expense_tracker.py.
  • You won't see a change yet. The next substep verifies everything works together.

Seeing a red underline or IndentationError?

Make sure load_all and save_all are indented one level inside the DataStore class (4 spaces from the left edge). They should line up with __init__.

Also confirm there is a blank line between the end of __init__ and def load_all(self):.

Still stuck? Help me fix my DataStore indentation in expense_tracker.py

Connect DataStore to ExpenseManager

Your ExpenseManager class already references data_store in its methods. Now that the DataStore class exists, the connection is complete.

How are these classes connected?

  • ExpenseManager.__init__ accepts a data_store parameter and immediately calls load_all() to populate its expense list from the file.
  • add_expense(), update_expense(), and delete_expense() each call data_store.save_all() after mutating the list, ensuring changes are written to disk immediately.
  • This pattern is called dependency injection. ExpenseManager does not create its own DataStore. Instead, one is passed in from the outside. This keeps each class focused on a single responsibility.

The last thing to do is remove the temporary test block that tested the Expense class in isolation.

  • In expense_tracker.py, scroll to the bottom of the file.
  • Find this block:
if __name__ == "__main__":
    # Temporary test
    from dataclasses import dataclass
    test_expense = Expense(1, "2024-01-15", "Food", "Lunch", 12.50)
    print(test_expense.to_dict())
  • Delete the entire if __name__ == "__main__": block (all 5 lines).
  • Save expense_tracker.py.
  • Verify the integration works by running this command in your terminal:
python -c "from expense_tracker import DataStore, ExpenseManager; ds = DataStore(); mgr = ExpenseManager(ds); print(mgr.get_total())"
  • You should see 0 printed in the terminal. This confirms DataStore created the file, ExpenseManager loaded from it, and the total is zero (no expenses yet).
  • Confirm expenses.json was created by running:
ls
  • You should see expenses.json and expense_tracker.py in the output.

Seeing an ImportError or ModuleNotFoundError?

  • Make sure your terminal is in the expense-tracker folder. Run cd ~/Desktop/expense-tracker if needed.
  • Check that the DataStore class is positioned between the Expense class and the ExpenseManager class. Order matters because DataStore.load_all() references Expense.from_dict().
  • Confirm you deleted the old if __name__ block completely. A leftover line can cause a syntax error.

Still stuck? Help me debug my expense_tracker.py import error

Why JSON instead of CSV?

JSON preserves data types. Numbers stay as numbers, so you do not need to manually convert strings back to floats every time you load the file.

JSON's key-value pairs also map naturally to expense attributes. Each expense becomes a self-describing dictionary rather than a position-dependent row.

✔️ Awesome, I've got everything!

Great. Double check you have saved expense_tracker.py and that expenses.json exists in your folder.

ⓧ I'd like to double check the full code

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

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


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

    def __init__(self, expense_id, expense_date, category, description, amount):
        self.expense_id = expense_id
        self.date = expense_date
        self.category = category
        self.description = description
        self.amount = amount

    def to_dict(self):
        """Convert expense to dictionary for JSON storage."""
        return {
            "expense_id": self.expense_id,
            "date": self.date,
            "category": self.category,
            "description": self.description,
            "amount": self.amount
        }

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


class DataStore:
    """Handles reading and writing expense data to a JSON file."""

    def __init__(self, filename="expenses.json"):
        self.filename = filename
        if not os.path.exists(self.filename):
            with open(self.filename, "w") as f:
                json.dump([], f)

    def load_all(self):
        """Load all expenses from the JSON file."""
        try:
            with open(self.filename, "r") as f:
                data = json.load(f)
            return [Expense.from_dict(item) for item in data]
        except (json.JSONDecodeError, KeyError):
            return []

    def save_all(self, expenses):
        """Save all expenses to the JSON file."""
        data = [expense.to_dict() for expense in expenses]
        with open(self.filename, "w") as f:
            json.dump(data, f, indent=2)


class ExpenseManager:
    """Manages expense operations and business logic."""

    def __init__(self, data_store):
        self.data_store = data_store
        self.expenses = self.data_store.load_all()
        self._next_id = self._calculate_next_id()

    def _calculate_next_id(self):
        """Determine the next available expense ID."""
        if not self.expenses:
            return 1
        return max(exp.expense_id for exp in self.expenses) + 1

    def add_expense(self, expense_date, category, description, amount):
        """Add a new expense and save to file."""
        expense = Expense(self._next_id, expense_date, category, description, amount)
        self.expenses.append(expense)
        self._next_id += 1
        self.data_store.save_all(self.expenses)
        return expense

    def update_expense(self, expense_id, expense_date, category, description, amount):
        """Update an existing expense and save to file."""
        for expense in self.expenses:
            if expense.expense_id == expense_id:
                expense.date = expense_date
                expense.category = category
                expense.description = description
                expense.amount = amount
                self.data_store.save_all(self.expenses)
                return True
        return False

    def delete_expense(self, expense_id):
        """Delete an expense by ID and save to file."""
        self.expenses = [exp for exp in self.expenses if exp.expense_id != expense_id]
        self.data_store.save_all(self.expenses)

    def get_total(self):
        """Calculate total of all expenses."""
        return sum(exp.amount for exp in self.expenses)

    def get_all_expenses(self):
        """Return all expenses."""
        return self.expenses

Your data layer is solid. Expenses now persist between sessions in a clean JSON file. Next up, you will build the graphical interface that lets users interact with this data visually.

Build the Main Application Window

Your data layer is fully wired. The Expense model knows how to serialize itself, DataStore handles file I/O, and ExpenseManager ties them together with business logic.

Now it is time to give your app a face. In this step, you will build the main Tkinter window with an input form and action buttons. This is where event-driven programming comes in. Instead of code running top to bottom, the app waits for the user to click buttons or type values.

In this step, get ready to:
  • Create the main application class that launches a window.
  • Build an input form with fields for date, category, description, and amount.
  • Add action buttons wired to placeholder methods.
Create the ExpenseTrackerApp class

Every Tkinter application needs a root window. Your ExpenseTrackerApp class will own that window and coordinate all the UI pieces.

  • In expense_tracker.py, scroll to the bottom of the file (below the ExpenseManager class) and add the following class:
class ExpenseTrackerApp:
    """Main application class managing the GUI."""

    # Predefined spending categories for the dropdown
    CATEGORIES = ["Food", "Transport", "Bills", "Entertainment", "Shopping", "Health", "Other"]

    def __init__(self):
        # Create the main application window
        self.root = tk.Tk()
        self.root.title("Daily Expense Tracker")
        self.root.geometry("800x500")
        self.root.resizable(True, True)

        # Initialize data layer
        self.data_store = DataStore()
        self.manager = ExpenseManager(self.data_store)

        # Track which expense is selected for editing
        self.selected_expense_id = None

        # Build UI sections
        self._create_input_frame()
        self._create_button_frame()
        self._create_tree_frame()
        self._create_total_frame()

        # Load existing data into the table
        self.refresh_treeview()

    def _create_input_frame(self):
        pass

    def _create_button_frame(self):
        pass

    def _create_tree_frame(self):
        pass  # Placeholder

    def _create_total_frame(self):
        pass  # Placeholder

    def add_expense(self):
        pass

    def update_expense(self):
        pass

    def delete_expense(self):
        pass

    def clear_fields(self):
        pass

    def refresh_treeview(self):
        pass

    def run(self):
        # Start the Tkinter event loop
        self.root.mainloop()


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

What does this code do?

This class skeleton sets up the entire application structure:

  • CATEGORIES is a class-level constant. Every expense the user creates will belong to one of these seven categories.
  • __init__ creates the root window, sets its title and size, then initializes the data layer using the classes you built earlier.
  • selected_expense_id tracks which row the user clicked so the app knows what to update or delete.
  • The pass placeholders let the app run without crashing while you fill in each method one at a time.
  • run() starts the Tkinter event loop. This hands control to Tkinter so it can listen for clicks, keypresses, and window events.
  • Save expense_tracker.py.
  • In your VS Code terminal, run the app to confirm the window launches:
python expense_tracker.py

You should see an empty window titled "Daily Expense Tracker" appear on screen. Close it to return to the terminal.

Window not appearing?

  • Make sure the if __name__ == "__main__": block is at the very bottom of your file, outside any class.
  • Check that your ExpenseTrackerApp class is defined after ExpenseManager. Python reads top to bottom, so the class must exist before it is instantiated.
  • If you see an import error mentioning tkinter, confirm your Python installation includes Tkinter by running python -c "import tkinter; print(tkinter.TkVersion)".

Still stuck? Why won't my Tkinter window open?

Build the input form frame

A ttk.LabelFrame groups related widgets inside a bordered box with a title. You will use one to hold all the input fields for adding an expense.

  • In expense_tracker.py, find the _create_input_frame method that currently says pass. Replace it with the following code:
    def _create_input_frame(self):
        # Create a bordered frame labeled "Add Expense"
        input_frame = ttk.LabelFrame(self.root, text="Add Expense", padding=10)
        input_frame.pack(fill="x", padx=10, pady=5)

        # Date field - pre-filled with today's date
        ttk.Label(input_frame, text="Date (YYYY-MM-DD):").grid(row=0, column=0, padx=5, pady=5, sticky="w")
        self.date_entry = ttk.Entry(input_frame, width=15)
        self.date_entry.grid(row=0, column=1, padx=5, pady=5)
        self.date_entry.insert(0, date.today().isoformat())

        # Category dropdown - locked to predefined choices
        ttk.Label(input_frame, text="Category:").grid(row=0, column=2, padx=5, pady=5, sticky="w")
        self.category_combo = ttk.Combobox(input_frame, values=self.CATEGORIES, state="readonly", width=15)
        self.category_combo.grid(row=0, column=3, padx=5, pady=5)
        self.category_combo.set("Food")

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

        # Amount field
        ttk.Label(input_frame, text="Amount ($):").grid(row=1, column=3, padx=5, pady=5, sticky="w")
        self.amount_entry = ttk.Entry(input_frame, width=15)
        self.amount_entry.grid(row=1, column=4, padx=5, pady=5)

What does this code do?

This method builds a two-row grid of input fields inside a labeled border:

  • ttk.LabelFrame creates a titled container. The pack(fill="x") call stretches it horizontally across the window.
  • date.today().isoformat() pre-fills the date field with today in YYYY-MM-DD format so the user does not have to type it manually.
  • ttk.Combobox with state="readonly" locks the dropdown to CATEGORIES only. Users pick from the list but cannot type a custom value.
  • The .grid() layout manager positions widgets in a row/column grid. Row 0 holds date and category. Row 1 holds description and amount.
  • Save expense_tracker.py and run it again:
python expense_tracker.py

You should now see an "Add Expense" box at the top of the window with four labeled input fields. The date field shows today's date and the category dropdown defaults to "Food".

Fields not showing up?

  • Check your indentation. The entire _create_input_frame method body must be indented one level inside the class (4 spaces from the def line).
  • Confirm you deleted the pass statement when you pasted the new code. A leftover pass will not crash, but check that your method has the actual widget code.
  • If the window is very narrow, resize it horizontally. The grid layout may clip fields on small windows.

Still stuck? Why aren't my input fields appearing in the Tkinter window?

Add action buttons

The buttons give users a way to trigger CRUD operations. For now, each button points to a placeholder method. You will wire them to real logic in the next step.

  • In expense_tracker.py, find the _create_button_frame method that currently says pass. Replace it with the following code:
    def _create_button_frame(self):
        # Frame to hold action buttons in a horizontal row
        button_frame = ttk.Frame(self.root, padding=5)
        button_frame.pack(fill="x", padx=10)

        ttk.Button(button_frame, text="Add Expense", command=self.add_expense).pack(side="left", padx=5)
        ttk.Button(button_frame, text="Update Expense", command=self.update_expense).pack(side="left", padx=5)
        ttk.Button(button_frame, text="Delete Expense", command=self.delete_expense).pack(side="left", padx=5)
        ttk.Button(button_frame, text="Clear Fields", command=self.clear_fields).pack(side="left", padx=5)

What does this code do?

Each ttk.Button takes a command parameter. This tells Tkinter which method to call when the button is clicked.

The pack(side="left") arranges buttons horizontally from left to right inside the frame. Because the handler methods (add_expense, update_expense, etc.) currently just say pass, clicking a button does nothing yet.

  • Save expense_tracker.py and run it one more time:
python expense_tracker.py

You should see four buttons below the input form: "Add Expense", "Update Expense", "Delete Expense", and "Clear Fields". Clicking them does nothing yet, which is expected.

Buttons not visible?

  • Make sure you replaced the pass in _create_button_frame, not in a different method.
  • Verify the method name is exactly _create_button_frame (with a leading underscore). A typo here means __init__ calls a non-existent method and the app crashes.

Still stuck? Why are my Tkinter buttons not showing up?

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

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

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


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

    def __init__(self, expense_id, expense_date, category, description, amount):
        self.expense_id = expense_id
        self.date = expense_date
        self.category = category
        self.description = description
        self.amount = amount

    def to_dict(self):
        return {
            "expense_id": self.expense_id,
            "date": self.date,
            "category": self.category,
            "description": self.description,
            "amount": self.amount
        }

    @classmethod
    def from_dict(cls, data):
        return cls(
            expense_id=data["expense_id"],
            expense_date=data["date"],
            category=data["category"],
            description=data["description"],
            amount=data["amount"]
        )


class DataStore:
    def __init__(self, filename="expenses.json"):
        self.filename = filename
        if not os.path.exists(self.filename):
            with open(self.filename, "w") as f:
                json.dump([], f)

    def load_all(self):
        try:
            with open(self.filename, "r") as f:
                data = json.load(f)
            return [Expense.from_dict(item) for item in data]
        except (json.JSONDecodeError, KeyError):
            return []

    def save_all(self, expenses):
        data = [expense.to_dict() for expense in expenses]
        with open(self.filename, "w") as f:
            json.dump(data, f, indent=2)


class ExpenseManager:
    def __init__(self, data_store):
        self.data_store = data_store
        self.expenses = self.data_store.load_all()
        self._next_id = self._calculate_next_id()

    def _calculate_next_id(self):
        if not self.expenses:
            return 1
        return max(exp.expense_id for exp in self.expenses) + 1

    def add_expense(self, expense_date, category, description, amount):
        expense = Expense(self._next_id, expense_date, category, description, amount)
        self.expenses.append(expense)
        self._next_id += 1
        self.data_store.save_all(self.expenses)
        return expense

    def update_expense(self, expense_id, expense_date, category, description, amount):
        for expense in self.expenses:
            if expense.expense_id == expense_id:
                expense.date = expense_date
                expense.category = category
                expense.description = description
                expense.amount = amount
                self.data_store.save_all(self.expenses)
                return True
        return False

    def delete_expense(self, expense_id):
        self.expenses = [exp for exp in self.expenses if exp.expense_id != expense_id]
        self.data_store.save_all(self.expenses)

    def get_total(self):
        return sum(exp.amount for exp in self.expenses)

    def get_all_expenses(self):
        return self.expenses


class ExpenseTrackerApp:
    """Main application class managing the GUI."""

    CATEGORIES = ["Food", "Transport", "Bills", "Entertainment", "Shopping", "Health", "Other"]

    def __init__(self):
        self.root = tk.Tk()
        self.root.title("Daily Expense Tracker")
        self.root.geometry("800x500")
        self.root.resizable(True, True)

        self.data_store = DataStore()
        self.manager = ExpenseManager(self.data_store)
        self.selected_expense_id = None

        self._create_input_frame()
        self._create_button_frame()
        self._create_tree_frame()
        self._create_total_frame()

        self.refresh_treeview()

    def _create_input_frame(self):
        input_frame = ttk.LabelFrame(self.root, text="Add Expense", padding=10)
        input_frame.pack(fill="x", padx=10, pady=5)

        ttk.Label(input_frame, text="Date (YYYY-MM-DD):").grid(row=0, column=0, padx=5, pady=5, sticky="w")
        self.date_entry = ttk.Entry(input_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(input_frame, text="Category:").grid(row=0, column=2, padx=5, pady=5, sticky="w")
        self.category_combo = ttk.Combobox(input_frame, values=self.CATEGORIES, state="readonly", width=15)
        self.category_combo.grid(row=0, column=3, padx=5, pady=5)
        self.category_combo.set("Food")

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

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

    def _create_button_frame(self):
        button_frame = ttk.Frame(self.root, padding=5)
        button_frame.pack(fill="x", padx=10)

        ttk.Button(button_frame, text="Add Expense", command=self.add_expense).pack(side="left", padx=5)
        ttk.Button(button_frame, text="Update Expense", command=self.update_expense).pack(side="left", padx=5)
        ttk.Button(button_frame, text="Delete Expense", command=self.delete_expense).pack(side="left", padx=5)
        ttk.Button(button_frame, text="Clear Fields", command=self.clear_fields).pack(side="left", padx=5)

    def _create_tree_frame(self):
        pass  # Placeholder

    def _create_total_frame(self):
        pass  # Placeholder

    def add_expense(self):
        pass

    def update_expense(self):
        pass

    def delete_expense(self):
        pass

    def clear_fields(self):
        pass

    def refresh_treeview(self):
        pass

    def run(self):
        self.root.mainloop()


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

Your app now has a complete input form and action buttons. In the next step, you will build the expense table using a Treeview widget and wire up the CRUD methods so those buttons actually do something.

Display Expenses and Wire Up CRUD

Your data layer and input form are ready. Now you need a way to actually see your expenses and interact with them.

In this step, you will build the expense table using a Treeview widget, wire up full CRUD (Create, Read, Update, Delete) functionality with input validation, and add a running total that updates automatically.

In this step, get ready to:
  • Build a multi-column expense table with a scrollbar.
  • Add input validation and CRUD operations (add, update, delete).
  • Display a running total and refresh the table after every change.
Create the expense table

The Treeview widget displays tabular data with multiple columns. It is the standard way to show structured lists in Tkinter applications.

  • In expense_tracker.py, find the placeholder _create_tree_frame method:
    def _create_tree_frame(self):
        pass  # Placeholder
  • Replace the entire method with this code:
    def _create_tree_frame(self):
        """Create the Treeview table for displaying expenses."""
        tree_frame = ttk.Frame(self.root, padding=5)
        tree_frame.pack(fill="both", expand=True, padx=10, pady=5)

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

        # Configure column headings
        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 ($)")

        # Configure column widths and alignment
        self.tree.column("ID", width=40, anchor="center")
        self.tree.column("Date", width=100, anchor="center")
        self.tree.column("Category", width=100, anchor="center")
        self.tree.column("Description", width=300, anchor="w")
        self.tree.column("Amount", width=100, anchor="e")

        # Add vertical scrollbar
        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")

        # Bind row selection to populate the edit form
        self.tree.bind("<<TreeviewSelect>>", self.on_tree_select)

What does this code do?

The Treeview widget creates a table with five columns. Setting show="headings" hides the default tree icon column, giving you a clean spreadsheet-like layout.

Each column has a configured width and anchor (alignment). The Description column is wider and left-aligned, while Amount is right-aligned like a real spreadsheet.

The <<TreeviewSelect>> binding fires whenever the learner clicks a row. This powers the edit workflow later.

  • Save expense_tracker.py.
  • Run the app in your terminal with python expense_tracker.py. You should see a table area with column headings (ID, Date, Category, Description, Amount) below the buttons.

Don't see the table headings?

  • Make sure the method name is exactly _create_tree_frame with the leading underscore. The __init__ method calls it by that name.
  • Check that show="headings" is present. Without it, an extra empty column appears on the left.

Why aren't my Treeview column headings showing up?

Implement CRUD methods with validation

Before any expense can be saved, you need validation logic that prevents bad data from entering the system. Then the CRUD methods can safely rely on clean inputs.

  • In expense_tracker.py, find the placeholder add_expense method. Add this new validate_inputs method directly above it:
    def validate_inputs(self):
        """Validate all input fields. Returns True if valid, False otherwise."""
        date_str = self.date_entry.get().strip()
        description = self.desc_entry.get().strip()
        amount_str = self.amount_entry.get().strip()
        category = self.category_combo.get()

        # Check date is not empty
        if not date_str:
            messagebox.showerror("Validation Error", "Please enter a date.")
            return False

        # Check date format is valid YYYY-MM-DD
        try:
            datetime.strptime(date_str, "%Y-%m-%d")
        except ValueError:
            messagebox.showerror("Validation Error", "Invalid date format. Use YYYY-MM-DD.")
            return False

        # Check category is selected
        if not category:
            messagebox.showerror("Validation Error", "Please select a category.")
            return False

        # Check description is not empty
        if not description:
            messagebox.showerror("Validation Error", "Please enter a description.")
            return False

        # Check amount is not empty
        if not amount_str:
            messagebox.showerror("Validation Error", "Please enter an amount.")
            return False

        # Check amount is a valid positive number
        try:
            amount = float(amount_str)
            if amount <= 0:
                messagebox.showerror("Validation Error", "Amount must be a positive number.")
                return False
        except ValueError:
            messagebox.showerror("Validation Error", "Amount must be a valid number.")
            return False

        return True

What does this code do?

This method checks every input field before allowing an expense to be saved. It verifies the date matches the YYYY-MM-DD format using datetime.strptime(), confirms a category is selected, ensures the description is not blank, and validates that the amount is a positive number.

Each check shows a specific error dialog using messagebox.showerror() so the user knows exactly what to fix. If anything fails, it returns False to prevent the operation from proceeding.

Now replace the placeholder CRUD methods. These four methods handle adding, updating, deleting, and selecting expenses.

  • Replace the add_expense, update_expense, and delete_expense placeholder methods with the following code. Also add the new on_tree_select method below delete_expense:
    def add_expense(self):
        """Add a new expense from the form inputs."""
        if not self.validate_inputs():
            return

        expense_date = self.date_entry.get().strip()
        category = self.category_combo.get()
        description = self.desc_entry.get().strip()
        amount = float(self.amount_entry.get().strip())

        self.manager.add_expense(expense_date, category, description, amount)
        self.refresh_treeview()
        self.clear_fields()
        messagebox.showinfo("Success", "Expense added successfully!")

    def update_expense(self):
        """Update the currently selected expense."""
        if self.selected_expense_id is None:
            messagebox.showerror("Error", "Please select an expense to update.")
            return

        if not self.validate_inputs():
            return

        expense_date = self.date_entry.get().strip()
        category = self.category_combo.get()
        description = self.desc_entry.get().strip()
        amount = float(self.amount_entry.get().strip())

        self.manager.update_expense(self.selected_expense_id, expense_date, category, description, amount)
        self.refresh_treeview()
        self.clear_fields()
        messagebox.showinfo("Success", "Expense updated successfully!")

    def delete_expense(self):
        """Delete the currently selected expense."""
        if self.selected_expense_id is None:
            messagebox.showerror("Error", "Please select an expense to delete.")
            return

        confirm = messagebox.askyesno("Confirm Delete", "Are you sure you want to delete this expense?")
        if confirm:
            self.manager.delete_expense(self.selected_expense_id)
            self.refresh_treeview()
            self.clear_fields()
            messagebox.showinfo("Success", "Expense deleted successfully!")

    def on_tree_select(self, event):
        """Populate form fields when a Treeview row is selected."""
        selected = self.tree.selection()
        if not selected:
            return

        item = self.tree.item(selected[0])
        values = item["values"]

        # Track which expense is selected for editing
        self.selected_expense_id = int(values[0])

        # Clear and populate fields with selected row's data
        self.date_entry.delete(0, tk.END)
        self.date_entry.insert(0, values[1])

        self.category_combo.set(values[2])

        self.desc_entry.delete(0, tk.END)
        self.desc_entry.insert(0, values[3])

        self.amount_entry.delete(0, tk.END)
        self.amount_entry.insert(0, values[4])

What does this code do?

Each CRUD method follows the same pattern: validate inputs, perform the operation through ExpenseManager, refresh the display, and show a confirmation dialog.

  • add_expense() validates, then passes the field values to the manager to create a new expense.
  • update_expense() first checks that a row is selected (via selected_expense_id), then overwrites that expense's data.
  • delete_expense() asks for confirmation using messagebox.askyesno() before removing the expense.
  • on_tree_select() fires when the user clicks a table row. It populates the form fields with that row's data so the user can edit it.
  • Save expense_tracker.py.
  • You will not see a change yet. The next chunk implements refresh_treeview() and clear_fields() which these methods depend on.
Add total display and refresh logic

The final piece connects everything together. The total label shows cumulative spending, and the refresh method keeps the Treeview in sync with your data.

  • In expense_tracker.py, replace the placeholder _create_total_frame, clear_fields, and refresh_treeview methods with the following code:
    def _create_total_frame(self):
        """Create the total display at the bottom."""
        total_frame = ttk.Frame(self.root, padding=5)
        total_frame.pack(fill="x", padx=10, pady=5)

        self.total_label = ttk.Label(total_frame, text="Total: $0.00", font=("Segoe UI", 12, "bold"))
        self.total_label.pack(side="right", padx=10)

    def clear_fields(self):
        """Clear all input fields and reset selection."""
        self.date_entry.delete(0, tk.END)
        self.date_entry.insert(0, date.today().isoformat())
        self.category_combo.set("Food")
        self.desc_entry.delete(0, tk.END)
        self.amount_entry.delete(0, tk.END)
        self.selected_expense_id = None

    def refresh_treeview(self):
        """Clear and repopulate the Treeview with current expenses."""
        # Remove all existing rows
        for item in self.tree.get_children():
            self.tree.delete(item)

        # Insert each expense as a new row
        for expense in self.manager.get_all_expenses():
            self.tree.insert("", "end", values=(
                expense.expense_id,
                expense.date,
                expense.category,
                expense.description,
                f"{expense.amount:.2f}"
            ))

        # Update total label
        total = self.manager.get_total()
        self.total_label.config(text=f"Total: ${total:.2f}")

What does this code do?

  • _create_total_frame() places a bold label at the bottom-right of the window showing the running total.
  • clear_fields() resets all input fields back to defaults (today's date, "Food" category, empty description and amount) and clears the selection tracking variable.
  • refresh_treeview() wipes the table and repopulates it from ExpenseManager. It also recalculates the total. This is called after every add, update, or delete to keep the display in sync.
  • Save expense_tracker.py.
  • Run the app by entering this command in your terminal:
python expense_tracker.py
  • You should see the full app window with the input form, buttons, an empty table with headings, and "Total: $0.00" in the bottom-right.
  • Try adding an expense: enter a description like Coffee, set the amount to 4.50, and click Add Expense. The expense appears in the table and the total updates.
  • Click the row you just added. The form fields populate with that expense's data.
  • Change the amount to 5.00 and click Update Expense. The table refreshes with the new value.
  • Click the row again and click Delete Expense. Confirm the dialog. The row disappears and the total resets to $0.00.

App crashes on launch?

  • If you see AttributeError: 'ExpenseTrackerApp' object has no attribute 'tree', make sure _create_tree_frame assigns self.tree (not a local variable).
  • If you see AttributeError: 'ExpenseTrackerApp' object has no attribute 'total_label', check that _create_total_frame assigns self.total_label.
  • Make sure the method names match exactly what __init__ calls: _create_tree_frame, _create_total_frame, refresh_treeview.

My expense tracker crashes when I try to run it. Help me debug.

What is messagebox.askyesno()?

This function from tkinter.messagebox shows a dialog with Yes and No buttons. It returns True if the user clicks Yes, or False if they click No. It prevents accidental deletions by requiring explicit confirmation.

✔️ Awesome, I've got everything!

Great. Double check you have saved expense_tracker.py and that your app runs without errors.

ⓧ I'd like to double check the full code

Here is the complete expense_tracker.py at this point:

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


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

    def __init__(self, expense_id, expense_date, category, description, amount):
        self.expense_id = expense_id
        self.date = expense_date
        self.category = category
        self.description = description
        self.amount = amount

    def to_dict(self):
        """Convert expense to dictionary for JSON storage."""
        return {
            "expense_id": self.expense_id,
            "date": self.date,
            "category": self.category,
            "description": self.description,
            "amount": self.amount
        }

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


class DataStore:
    """Handles reading and writing expense data to a JSON file."""

    def __init__(self, filename="expenses.json"):
        self.filename = filename
        if not os.path.exists(self.filename):
            with open(self.filename, "w") as f:
                json.dump([], f)

    def load_all(self):
        """Load all expenses from the JSON file."""
        try:
            with open(self.filename, "r") as f:
                data = json.load(f)
            return [Expense.from_dict(item) for item in data]
        except (json.JSONDecodeError, KeyError):
            return []

    def save_all(self, expenses):
        """Save all expenses to the JSON file."""
        data = [expense.to_dict() for expense in expenses]
        with open(self.filename, "w") as f:
            json.dump(data, f, indent=2)


class ExpenseManager:
    """Manages expense operations and business logic."""

    def __init__(self, data_store):
        self.data_store = data_store
        self.expenses = self.data_store.load_all()
        self._next_id = self._calculate_next_id()

    def _calculate_next_id(self):
        """Determine the next available expense ID."""
        if not self.expenses:
            return 1
        return max(exp.expense_id for exp in self.expenses) + 1

    def add_expense(self, expense_date, category, description, amount):
        """Add a new expense and save to file."""
        expense = Expense(self._next_id, expense_date, category, description, amount)
        self.expenses.append(expense)
        self._next_id += 1
        self.data_store.save_all(self.expenses)
        return expense

    def update_expense(self, expense_id, expense_date, category, description, amount):
        """Update an existing expense and save to file."""
        for expense in self.expenses:
            if expense.expense_id == expense_id:
                expense.date = expense_date
                expense.category = category
                expense.description = description
                expense.amount = amount
                self.data_store.save_all(self.expenses)
                return True
        return False

    def delete_expense(self, expense_id):
        """Delete an expense by ID and save to file."""
        self.expenses = [exp for exp in self.expenses if exp.expense_id != expense_id]
        self.data_store.save_all(self.expenses)

    def get_total(self):
        """Calculate total of all expenses."""
        return sum(exp.amount for exp in self.expenses)

    def get_all_expenses(self):
        """Return all expenses."""
        return self.expenses


class ExpenseTrackerApp:
    """Main application class managing the GUI."""

    CATEGORIES = ["Food", "Transport", "Bills", "Entertainment", "Shopping", "Health", "Other"]

    def __init__(self):
        self.root = tk.Tk()
        self.root.title("Daily Expense Tracker")
        self.root.geometry("800x500")
        self.root.resizable(True, True)

        self.data_store = DataStore()
        self.manager = ExpenseManager(self.data_store)
        self.selected_expense_id = None

        self._create_input_frame()
        self._create_button_frame()
        self._create_tree_frame()
        self._create_total_frame()

        self.refresh_treeview()

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

        ttk.Label(input_frame, text="Date (YYYY-MM-DD):").grid(row=0, column=0, padx=5, pady=5, sticky="w")
        self.date_entry = ttk.Entry(input_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(input_frame, text="Category:").grid(row=0, column=2, padx=5, pady=5, sticky="w")
        self.category_combo = ttk.Combobox(input_frame, values=self.CATEGORIES, state="readonly", width=15)
        self.category_combo.grid(row=0, column=3, padx=5, pady=5)
        self.category_combo.set("Food")

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

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

    def _create_button_frame(self):
        """Create action buttons."""
        button_frame = ttk.Frame(self.root, padding=5)
        button_frame.pack(fill="x", padx=10)

        ttk.Button(button_frame, text="Add Expense", command=self.add_expense).pack(side="left", padx=5)
        ttk.Button(button_frame, text="Update Expense", command=self.update_expense).pack(side="left", padx=5)
        ttk.Button(button_frame, text="Delete Expense", command=self.delete_expense).pack(side="left", padx=5)
        ttk.Button(button_frame, text="Clear Fields", command=self.clear_fields).pack(side="left", padx=5)

    def _create_tree_frame(self):
        """Create the Treeview table for displaying expenses."""
        tree_frame = ttk.Frame(self.root, padding=5)
        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=100, anchor="center")
        self.tree.column("Description", width=300, anchor="w")
        self.tree.column("Amount", width=100, 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")

        self.tree.bind("<<TreeviewSelect>>", self.on_tree_select)

    def _create_total_frame(self):
        """Create the total display at the bottom."""
        total_frame = ttk.Frame(self.root, padding=5)
        total_frame.pack(fill="x", padx=10, pady=5)

        self.total_label = ttk.Label(total_frame, text="Total: $0.00", font=("Segoe UI", 12, "bold"))
        self.total_label.pack(side="right", padx=10)

    def validate_inputs(self):
        """Validate all input fields. Returns True if valid, False otherwise."""
        date_str = self.date_entry.get().strip()
        description = self.desc_entry.get().strip()
        amount_str = self.amount_entry.get().strip()
        category = self.category_combo.get()

        if not date_str:
            messagebox.showerror("Validation Error", "Please enter a date.")
            return False

        try:
            datetime.strptime(date_str, "%Y-%m-%d")
        except ValueError:
            messagebox.showerror("Validation Error", "Invalid date format. Use YYYY-MM-DD.")
            return False

        if not category:
            messagebox.showerror("Validation Error", "Please select a category.")
            return False

        if not description:
            messagebox.showerror("Validation Error", "Please enter a description.")
            return False

        if not amount_str:
            messagebox.showerror("Validation Error", "Please enter an amount.")
            return False

        try:
            amount = float(amount_str)
            if amount <= 0:
                messagebox.showerror("Validation Error", "Amount must be a positive number.")
                return False
        except ValueError:
            messagebox.showerror("Validation Error", "Amount must be a valid number.")
            return False

        return True

    def add_expense(self):
        """Add a new expense from the form inputs."""
        if not self.validate_inputs():
            return

        expense_date = self.date_entry.get().strip()
        category = self.category_combo.get()
        description = self.desc_entry.get().strip()
        amount = float(self.amount_entry.get().strip())

        self.manager.add_expense(expense_date, category, description, amount)
        self.refresh_treeview()
        self.clear_fields()
        messagebox.showinfo("Success", "Expense added successfully!")

    def update_expense(self):
        """Update the currently selected expense."""
        if self.selected_expense_id is None:
            messagebox.showerror("Error", "Please select an expense to update.")
            return

        if not self.validate_inputs():
            return

        expense_date = self.date_entry.get().strip()
        category = self.category_combo.get()
        description = self.desc_entry.get().strip()
        amount = float(self.amount_entry.get().strip())

        self.manager.update_expense(self.selected_expense_id, expense_date, category, description, amount)
        self.refresh_treeview()
        self.clear_fields()
        messagebox.showinfo("Success", "Expense updated successfully!")

    def delete_expense(self):
        """Delete the currently selected expense."""
        if self.selected_expense_id is None:
            messagebox.showerror("Error", "Please select an expense to delete.")
            return

        confirm = messagebox.askyesno("Confirm Delete", "Are you sure you want to delete this expense?")
        if confirm:
            self.manager.delete_expense(self.selected_expense_id)
            self.refresh_treeview()
            self.clear_fields()
            messagebox.showinfo("Success", "Expense deleted successfully!")

    def on_tree_select(self, event):
        """Populate form fields when a Treeview row is selected."""
        selected = self.tree.selection()
        if not selected:
            return

        item = self.tree.item(selected[0])
        values = item["values"]

        self.selected_expense_id = int(values[0])

        self.date_entry.delete(0, tk.END)
        self.date_entry.insert(0, values[1])

        self.category_combo.set(values[2])

        self.desc_entry.delete(0, tk.END)
        self.desc_entry.insert(0, values[3])

        self.amount_entry.delete(0, tk.END)
        self.amount_entry.insert(0, values[4])

    def clear_fields(self):
        """Clear all input fields and reset selection."""
        self.date_entry.delete(0, tk.END)
        self.date_entry.insert(0, date.today().isoformat())
        self.category_combo.set("Food")
        self.desc_entry.delete(0, tk.END)
        self.amount_entry.delete(0, tk.END)
        self.selected_expense_id = None

    def refresh_treeview(self):
        """Clear and repopulate the Treeview with current expenses."""
        for item in self.tree.get_children():
            self.tree.delete(item)

        for expense in self.manager.get_all_expenses():
            self.tree.insert("", "end", values=(
                expense.expense_id,
                expense.date,
                expense.category,
                expense.description,
                f"{expense.amount:.2f}"
            ))

        total = self.manager.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()

Your expense tracker is now fully functional. Close the app window, then check your project folder. You should see a new expenses.json file that was auto-created on first launch. Any expenses you added are persisted there and will reappear next time you run the app.

Secret mission

Add Date Filtering and Category Summary

Your expense tracker works great for adding and managing expenses. But what if you want to see only this week's spending? Or understand which categories eat most of your budget? In this secret mission, you'll add date-range filtering and a category-wise spending summary window.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files for future use or delete them entirely.

Resources you used:

  • The expense-tracker folder on your Desktop (contains expense_tracker.py and expenses.json).

Keep everything running

No action needed. Choose this if you plan to keep tracking expenses or want to revisit the code later.

  • Your expense-tracker folder stays on your Desktop.
  • Your expenses.json file keeps all your saved expense data.
  • You can relaunch the app anytime by running python expense_tracker.py from the project folder.

Pause - I'll come back to this later

Since this is a local project with no running processes, there is nothing to pause. Your files sit on disk until you return.

  • Close the VS Code window to free up memory.
  • Your project folder and data file remain intact for next time.

Delete - I don't want to use this again

Remove all project files and start fresh. This permanently deletes your expense data.

  • Close VS Code if it is still open.
  • Delete the expense-tracker folder by running one of these commands in your terminal:

macOS

cd ~/Desktop
rm -rf expense-tracker

Windows

cd ~/Desktop
rmdir /s /q expense-tracker
  • Confirm the folder is gone by running:
ls ~/Desktop

You should no longer see expense-tracker in the output.

Nice Work!

Nice Work!

Nice work! You just built a complete Daily Expense Tracker desktop application using Python and Tkinter.

You've learned how to:

  • Architect a Python application using Object-Oriented Programming with separate classes for data modeling (Expense), file storage (DataStore), business logic (ExpenseManager), and the user interface (ExpenseTrackerApp).
  • Design a modern desktop GUI using ttk themed widgets including Treeview for tabular data, Combobox for category selection, and LabelFrame for grouped inputs.
  • Implement full CRUD operations with input validation, confirmation dialogs, and persistent JSON file storage that survives application restarts.
  • Added date filtering and a category-wise spending summary window to analyze expenses across time ranges.

Ready to quiz yourself?