Daily Expense Tracker with Python

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

Introduction

30 Second Summary

Tracking daily spending is one of those habits everyone knows they should build. A custom desktop app designed exactly for your workflow makes it easy to stick with.

In this project, you will build a Daily Expense Tracker application using Python. You'll structure the codebase with separate classes for data modeling, database persistence, and a graphical user interface.

What You'll Build

A fully functional desktop application that lets you add, view, edit, and delete daily expenses, with all data saved to a local database between sessions.

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

  • A Python class hierarchy with an Expense data model, an ExpenseDatabase manager, and an ExpenseTrackerApp GUI class.
  • A clean Tkinter interface with an input form, a sortable expense table, and action buttons for full CRUD operations.
  • An SQLite-backed persistent storage layer with input validation and a real-time total expense display.
  • Secret Mission: Add category-based filtering and a spending summary breakdown showing totals per category.

Are there any prerequisites?

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

Before We Start

To build a desktop application, you need Python installed on your machine along with a code editor to write and run your code.

In this step, you will install Python 3.13, set up your project folder in Cursor, and verify that Tkinter (Python's built-in GUI library) is working.

In this step, get ready to:
  • Install and verify Python 3.13.
  • Create a project folder and open it in Cursor.
  • Create expense_tracker.py and verify Tkinter works.
Install Python 3.13

Python is the programming language you will use throughout this project. You need version 3.13 specifically because it includes all the standard library modules (tkinter, sqlite3, dataclasses) the project relies on and uses the familiar installer on all platforms.

  • Check whether Python 3.13 is already installed by running this command in your terminal:

macOS

python3 --version

On macOS, the command is python3 because macOS does not map python to Python 3 by default.

Windows

python --version

On Windows, the standard Python installer registers the python command automatically.

✔️ I see Python 3.13

You should see output like Python 3.13.13 (or any 3.13.x version). You are all set to move on.

ⓧ I see a different version

If you see a version lower than 3.13 (for example 3.9 or 3.12), you need to install Python 3.13 alongside it.

  • Go to the Python 3.13 download page.
  • Download and run the installer for your operating system (macOS 64-bit universal2 installer, or Windows installer 64-bit).
  • On Windows, check the box that says Add python.exe to PATH during installation.
  • On macOS, the installer creates a python3.13 command. Use python3.13 --version to confirm.
  • After installation, close and reopen your terminal, then re-run the version check.

macOS users with an older default

macOS often ships with an older system Python. After installing 3.13, use python3.13 as your command throughout this project if python3 --version still shows the old version.

ⓧ Command not found

Python is not installed on your machine yet. Follow these steps to install it.

  • Go to the Python 3.13 download page.
  • Download the installer for your operating system (macOS 64-bit universal2 installer, or Windows installer 64-bit).
  • Run the installer.
  • On Windows, check the box that says Add python.exe to PATH before clicking Install Now.
  • On macOS, open the downloaded .pkg file and follow the prompts.
  • After installation, close and reopen your terminal.
  • Run the version check again to confirm you see Python 3.13.x.

Why Python 3.13 instead of the latest 3.14?

Python 3.13 uses the traditional installer on all platforms, which is straightforward for beginners. Python 3.14 introduced a new Windows install manager that works differently.

Sticking with 3.13 keeps the setup simple and predictable.

Create your project folder and open it in Cursor

You need a dedicated folder for this project and a code editor to write your Python files.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.

What is Cursor?

Cursor is an AI-powered code editor built on top of VS Code. If you do not have Cursor installed, you can download it from cursor.com. You can also use VS Code for this project if you prefer.

  • Open the built-in terminal by pressing Ctrl+` (both macOS and Windows).
  • 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
  • Open this folder in Cursor by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the expense-tracker folder on your Desktop, then click Open.
  • Confirm the folder opened by checking the Explorer sidebar on the left. It should display EXPENSE-TRACKER at the top.

Don't see the Explorer sidebar?

Press Cmd+Shift+E (macOS) or Ctrl+Shift+E (Windows) to toggle the Explorer panel open.

help me find the Explorer sidebar in Cursor

Create expense_tracker.py and verify Tkinter

Now you will create the single Python file this project uses and confirm that Tkinter (the GUI library) is available.

  • Open the terminal in Cursor again with Ctrl+` if it is not already open.
  • Create your main file by running:

macOS

touch expense_tracker.py

Windows

New-Item expense_tracker.py
  • Confirm the file exists by running:
ls

You should see expense_tracker.py listed in the output.

  • Verify that Tkinter is available by running this test command:

macOS

python3 -c "import tkinter; root = tkinter.Tk(); root.title('Test'); root.mainloop()"

If you installed Python 3.13 fresh, use python3.13 instead of python3 if your default still points to an older version.

Windows

python -c "import tkinter; root = tkinter.Tk(); root.title('Test'); root.mainloop()"

A small blank window titled "Test" appears on your screen. This confirms Tkinter is working correctly.

  • Close the test window by clicking the X button in its title bar.

No window appeared or you see an import error?

On macOS, Tkinter requires the official Python installer from python.org. If you installed Python through Homebrew, Tkinter may be missing. Reinstall using the .pkg installer from python.org/downloads.

On Windows, Tkinter is included with the standard Python installer by default. If you see an error, reinstall Python and make sure the tcl/tk option is checked during installation.

help me fix Tkinter not working on my machine

✔️ Awesome, I've got everything!

Make sure you have closed the test window and that expense_tracker.py is visible in your Explorer sidebar.

ⓧ I'd like to double check the full code

Your expense_tracker.py file should be completely empty at this point. You have not added any code yet.

# expense_tracker.py is empty - you will add code in the next step

Your environment is ready. Next up, you will build the Expense model class and the database layer that stores your expenses.

Build the Expense Model and Database Classes

Your environment is ready. Now it's time to build the foundation of your Expense Tracker.

Every well-structured application separates its data from its logic. In this step, you'll create two Python classes that form the backbone of your app: an Expense model that defines what an expense looks like, and an ExpenseDatabase class that handles saving, reading, updating, and deleting expenses from a local SQLite database.

In this step, get ready to:
  • Create the Expense data model using a Python dataclass.
  • Build the ExpenseDatabase class with full CRUD operations.
  • Test the database by adding and retrieving a sample expense.
Create the Expense dataclass

Before you can store expenses, you need to define what an expense actually is. A dataclass is a special Python class that automatically generates common methods like __init__ for you. It's perfect for defining simple data structures.

  • In expense_tracker.py, add the following imports and the Expense dataclass:
import tkinter as tk
from tkinter import ttk, messagebox
from dataclasses import dataclass, field
from datetime import date
import sqlite3


@dataclass
class Expense:
    # Each expense has an amount, category, description, and date
    amount: float
    category: str
    description: str
    date: str
    # id defaults to 0 for new expenses (database assigns the real id)
    id: int = field(default=0)

What does this code do?

The imports bring in everything you'll need for this project: tkinter for the GUI, dataclasses for the model, datetime for today's date, and sqlite3 for the database.

The Expense class uses the @dataclass decorator to automatically create an __init__ method from the listed fields. The id field defaults to 0 because the database assigns the real ID when it saves a new record.

  • Save expense_tracker.py.

You won't see a visible change yet. The next chunk uses this dataclass to store real expenses.

Create the ExpenseDatabase class

The database class is your app's persistence layer. It handles all communication with the SQLite database file so the rest of your code never writes raw SQL.

  • Below the Expense class in expense_tracker.py, add the ExpenseDatabase class and the CATEGORIES list:
class ExpenseDatabase:
    def __init__(self, db_path="expenses.db"):
        # Connect to SQLite database file (creates it if it doesn't exist)
        self.conn = sqlite3.connect(db_path)
        self.cursor = self.conn.cursor()
        self._create_table()

    def _create_table(self):
        # Create the expenses table if it doesn't already exist
        self.cursor.execute("""
            CREATE TABLE IF NOT EXISTS expenses (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                amount REAL NOT NULL,
                category TEXT NOT NULL,
                description TEXT NOT NULL,
                date TEXT NOT NULL
            )
        """)
        self.conn.commit()

    def add_expense(self, expense: Expense) -> int:
        # Insert a new expense and return its database-assigned id
        self.cursor.execute(
            "INSERT INTO expenses (amount, category, description, date) VALUES (?, ?, ?, ?)",
            (expense.amount, expense.category, expense.description, expense.date)
        )
        self.conn.commit()
        return self.cursor.lastrowid

    def get_all_expenses(self) -> list[Expense]:
        # Retrieve all expenses, newest first
        self.cursor.execute("SELECT id, amount, category, description, date FROM expenses ORDER BY date DESC")
        rows = self.cursor.fetchall()
        return [Expense(id=row[0], amount=row[1], category=row[2], description=row[3], date=row[4]) for row in rows]

    def update_expense(self, expense: Expense):
        # Update an existing expense by its id
        self.cursor.execute(
            "UPDATE expenses SET amount=?, category=?, description=?, date=? WHERE id=?",
            (expense.amount, expense.category, expense.description, expense.date, expense.id)
        )
        self.conn.commit()

    def delete_expense(self, expense_id: int):
        # Remove an expense from the database
        self.cursor.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
        self.conn.commit()

    def close(self):
        # Close the database connection
        self.conn.close()


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

What does this code do?

The __init__ method connects to an SQLite database file called expenses.db. If the file doesn't exist, SQLite creates it automatically.

Each method maps to a CRUD operation: add_expense inserts a new row, get_all_expenses reads all rows, update_expense modifies an existing row, and delete_expense removes one. The ? placeholders prevent SQL injection by safely substituting values into queries.

The CATEGORIES list defines the dropdown options your form will offer later.

  • Save expense_tracker.py.

You won't see a visual change yet. The next substep verifies everything works by running a quick test.

Seeing a red underline on list[Expense]?

The list[Expense] type hint syntax requires Python 3.9 or higher. If your editor flags it, confirm you're running Python 3.13 by checking python --version in your terminal.

Help me fix a type hint error in my Python file

Test the database class

You've written the code, but you need to confirm it actually works. A quick test in the Python interactive console will prove your database can store and retrieve expenses.

  • Open a terminal in Cursor by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Launch the Python interactive console by running:
python -i expense_tracker.py

What does this command do?

The -i flag runs your file and then drops you into an interactive prompt. This means all your classes are loaded and ready to use.

  • In the interactive prompt (you'll see >>>), create a database instance and add a test expense by typing these lines one at a time:
db = ExpenseDatabase()
test_expense = Expense(amount=12.50, category="Food", description="Lunch", date="2025-01-15")
db.add_expense(test_expense)
print(db.get_all_expenses())
  • You should see output like:
[Expense(amount=12.5, category='Food', description='Lunch', date='2025-01-15', id=1)]

What should I see?

The printed list contains one Expense object with all the values you entered. The id is now 1 because SQLite assigned it automatically. This confirms your database is storing and retrieving data correctly.

  • Exit the interactive console by typing:
exit()
  • Delete the test database file so you start fresh when building the full app. Run this in your terminal:
rm expenses.db

Don't see the expected output?

If you see NameError: name 'ExpenseDatabase' is not defined, make sure you saved expense_tracker.py before running the command. The -i flag loads the saved version of the file.

If you see a SyntaxError, double check your indentation matches the code above. Python is strict about consistent spacing.

Help me debug my ExpenseDatabase test

✔️ Awesome, I've got everything!

Great. Double check you've saved expense_tracker.py and deleted the test expenses.db file.

ⓧ I'd like to double check the full code

Here's what your expense_tracker.py should look like at this point:

import tkinter as tk
from tkinter import ttk, messagebox
from dataclasses import dataclass, field
from datetime import date
import sqlite3


@dataclass
class Expense:
    amount: float
    category: str
    description: str
    date: str
    id: int = field(default=0)


class ExpenseDatabase:
    def __init__(self, db_path="expenses.db"):
        self.conn = sqlite3.connect(db_path)
        self.cursor = self.conn.cursor()
        self._create_table()

    def _create_table(self):
        self.cursor.execute("""
            CREATE TABLE IF NOT EXISTS expenses (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                amount REAL NOT NULL,
                category TEXT NOT NULL,
                description TEXT NOT NULL,
                date TEXT NOT NULL
            )
        """)
        self.conn.commit()

    def add_expense(self, expense: Expense) -> int:
        self.cursor.execute(
            "INSERT INTO expenses (amount, category, description, date) VALUES (?, ?, ?, ?)",
            (expense.amount, expense.category, expense.description, expense.date)
        )
        self.conn.commit()
        return self.cursor.lastrowid

    def get_all_expenses(self) -> list[Expense]:
        self.cursor.execute("SELECT id, amount, category, description, date FROM expenses ORDER BY date DESC")
        rows = self.cursor.fetchall()
        return [Expense(id=row[0], amount=row[1], category=row[2], description=row[3], date=row[4]) for row in rows]

    def update_expense(self, expense: Expense):
        self.cursor.execute(
            "UPDATE expenses SET amount=?, category=?, description=?, date=? WHERE id=?",
            (expense.amount, expense.category, expense.description, expense.date, expense.id)
        )
        self.conn.commit()

    def delete_expense(self, expense_id: int):
        self.cursor.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
        self.conn.commit()

    def close(self):
        self.conn.close()


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

Your data model and database layer are complete. Next up, you'll build the application window and input form so you can add expenses through a real GUI instead of the console.

Build the Application Window and Input Form

Your data layer is ready. You have an Expense model and an ExpenseDatabase class that can store, retrieve, update, and delete expenses.

Now it is time to give your application a face. In this step, you will create the main application window and build an input form where users can enter expense details. The form uses Tkinter's ttk themed widgets arranged with the grid layout manager.

In this step, get ready to:
  • Create the ExpenseTrackerApp class with window configuration.
  • Build the input form with date, category, description, and amount fields.
  • Run the application and verify the form displays correctly.
Create the ExpenseTrackerApp class

Every Tkinter application needs a main window. You will create a class that inherits from tk.Tk, which gives your app a top-level window with a title bar, close button, and resizable frame.

  • In expense_tracker.py, find the CATEGORIES list at the bottom of the file.
  • Add the following class below the CATEGORIES list:
class ExpenseTrackerApp(tk.Tk):
    def __init__(self):
        super().__init__()
        # Set the window title and size
        self.title("Daily Expense Tracker")
        self.geometry("800x600")
        self.resizable(True, True)

        # Connect to the database and track the currently selected expense
        self.db = ExpenseDatabase()
        self.selected_id = None

        # Build the form UI
        self._build_form_frame()

What does this code do?

  • super().__init__() initializes the Tkinter root window from the parent class.
  • self.title() and self.geometry() set the window's title bar text and its starting size to 800 pixels wide by 600 tall.
  • self.db creates an instance of your ExpenseDatabase class so the app can read and write expenses.
  • self.selected_id will track which expense row the user has clicked on for editing or deleting.

You will not see a change yet. The _build_form_frame() method does not exist yet, so running this code would produce an error. You will add it next.

Build the input form frame

The form is where users enter expense details. You will use the grid layout manager to arrange labels and input fields in rows and columns, creating a clean two-row form.

  • Add the _build_form_frame method inside the ExpenseTrackerApp class, directly below the __init__ method:
    def _build_form_frame(self):
        # Create a labeled frame to group the form inputs
        form_frame = ttk.LabelFrame(self, text="Expense Details", padding=10)
        form_frame.grid(row=0, column=0, padx=10, pady=5, sticky="ew")
        self.columnconfigure(0, weight=1)

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

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

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

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

        # Action buttons row
        btn_frame = ttk.Frame(form_frame)
        btn_frame.grid(row=2, column=0, columnspan=6, pady=5)

        self.add_btn = ttk.Button(btn_frame, text="Add Expense", command=self.add_expense)
        self.add_btn.pack(side="left", padx=5)

        self.update_btn = ttk.Button(btn_frame, text="Update", command=self.update_expense)
        self.update_btn.pack(side="left", padx=5)

        self.delete_btn = ttk.Button(btn_frame, text="Delete", command=self.delete_expense)
        self.delete_btn.pack(side="left", padx=5)

        self.clear_btn = ttk.Button(btn_frame, text="Clear", command=self.clear_form)
        self.clear_btn.pack(side="left", padx=5)

        # Allow the description column to stretch with the window
        form_frame.columnconfigure(1, weight=1)

What does this code do?

  • ttk.LabelFrame creates a visible border with the title "Expense Details" around all form inputs.
  • The date field uses date.today().isoformat() to pre-fill with today's date in YYYY-MM-DD format.
  • The category dropdown is a ttk.Combobox with state="readonly" so users can only pick from the predefined CATEGORIES list (Food, Transport, Entertainment, Shopping, Bills, Health, Other).
  • The four buttons (Add Expense, Update, Delete, Clear) reference methods like self.add_expense that do not exist yet. You will implement them in a later step.
  • self.columnconfigure(0, weight=1) makes the form stretch horizontally when you resize the window.

Why use grid instead of pack?

Tkinter offers two main layout managers: pack (stacks widgets one after another) and grid (places widgets in rows and columns). Forms with labels next to inputs are a natural fit for grid because you can align everything neatly without nested frames.

You still cannot run the app because the button commands reference methods that do not exist. The next substep adds the entry point so you can launch and see the form.

Run the application and test the form

The CATEGORIES list you defined in the previous step provides seven common expense types: Food, Transport, Entertainment, Shopping, Bills, Health, and Other. These appear as dropdown options in the category Combobox you just built.

  • Add the main entry point at the very bottom of expense_tracker.py, outside the class (no indentation):
if __name__ == "__main__":
    app = ExpenseTrackerApp()
    # Ensure the database connection closes when the window is closed
    app.protocol("WM_DELETE_WINDOW", app.on_closing)
    app.mainloop()

What does this code do?

  • if __name__ == "__main__" ensures this code only runs when you execute the file directly (not when importing it).
  • app.protocol("WM_DELETE_WINDOW", app.on_closing) hooks the window's close button to a cleanup method that will close the database connection.
  • app.mainloop() starts the Tkinter event loop, keeping the window open until the user closes it.
  • Save expense_tracker.py.
  • Run the application by running this command in your terminal:
python expense_tracker.py

A window titled "Daily Expense Tracker" should appear with an "Expense Details" form containing date, category, description, and amount fields plus four buttons.

What about the button errors?

The buttons reference methods like add_expense and on_closing that do not exist yet. The window still opens because Tkinter only calls those methods when you click the buttons or close the window. You will implement them in later steps.

  • Click the category dropdown. You should see all seven categories: Food, Transport, Entertainment, Shopping, Bills, Health, Other.
  • Check that the date field shows today's date in YYYY-MM-DD format.
  • Close the window by clicking the X button. If you see an error about on_closing in your terminal, that is expected for now.

Window not appearing?

  • Make sure the if __name__ == "__main__" block is at the bottom of the file with no indentation. If it is indented inside the class, Python will not run it.
  • Check that _build_form_frame is indented inside the ExpenseTrackerApp class (one level of indentation).
  • Verify you are running the script from the correct folder. Use ls to confirm expense_tracker.py is in your current directory.

Why isn't my Tkinter window appearing when I run expense_tracker.py?

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before continuing.

ⓧ 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
from dataclasses import dataclass, field
from datetime import date
import sqlite3


@dataclass
class Expense:
    amount: float
    category: str
    description: str
    date: str
    id: int = field(default=0)


class ExpenseDatabase:
    def __init__(self, db_path="expenses.db"):
        self.conn = sqlite3.connect(db_path)
        self.cursor = self.conn.cursor()
        self._create_table()

    def _create_table(self):
        self.cursor.execute("""
            CREATE TABLE IF NOT EXISTS expenses (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                amount REAL NOT NULL,
                category TEXT NOT NULL,
                description TEXT NOT NULL,
                date TEXT NOT NULL
            )
        """)
        self.conn.commit()

    def add_expense(self, expense: Expense) -> int:
        self.cursor.execute(
            "INSERT INTO expenses (amount, category, description, date) VALUES (?, ?, ?, ?)",
            (expense.amount, expense.category, expense.description, expense.date)
        )
        self.conn.commit()
        return self.cursor.lastrowid

    def get_all_expenses(self) -> list[Expense]:
        self.cursor.execute("SELECT id, amount, category, description, date FROM expenses ORDER BY date DESC")
        rows = self.cursor.fetchall()
        return [Expense(id=row[0], amount=row[1], category=row[2], description=row[3], date=row[4]) for row in rows]

    def update_expense(self, expense: Expense):
        self.cursor.execute(
            "UPDATE expenses SET amount=?, category=?, description=?, date=? WHERE id=?",
            (expense.amount, expense.category, expense.description, expense.date, expense.id)
        )
        self.conn.commit()

    def delete_expense(self, expense_id: int):
        self.cursor.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
        self.conn.commit()

    def close(self):
        self.conn.close()


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


class ExpenseTrackerApp(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Daily Expense Tracker")
        self.geometry("800x600")
        self.resizable(True, True)

        self.db = ExpenseDatabase()
        self.selected_id = None

        self._build_form_frame()

    def _build_form_frame(self):
        form_frame = ttk.LabelFrame(self, text="Expense Details", padding=10)
        form_frame.grid(row=0, column=0, padx=10, pady=5, sticky="ew")
        self.columnconfigure(0, weight=1)

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

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

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

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

        btn_frame = ttk.Frame(form_frame)
        btn_frame.grid(row=2, column=0, columnspan=6, pady=5)

        self.add_btn = ttk.Button(btn_frame, text="Add Expense", command=self.add_expense)
        self.add_btn.pack(side="left", padx=5)

        self.update_btn = ttk.Button(btn_frame, text="Update", command=self.update_expense)
        self.update_btn.pack(side="left", padx=5)

        self.delete_btn = ttk.Button(btn_frame, text="Delete", command=self.delete_expense)
        self.delete_btn.pack(side="left", padx=5)

        self.clear_btn = ttk.Button(btn_frame, text="Clear", command=self.clear_form)
        self.clear_btn.pack(side="left", padx=5)

        form_frame.columnconfigure(1, weight=1)


if __name__ == "__main__":
    app = ExpenseTrackerApp()
    app.protocol("WM_DELETE_WINDOW", app.on_closing)
    app.mainloop()

Your application window is alive. Next up, you will add the expense table and total display so users can see their recorded spending at a glance.

Add the Expense Table and Total Display

Your form is ready to accept expense data, but there is nowhere to display it yet. An expense tracker needs a table showing all entries at a glance, plus a running total so you know exactly how much you have spent.

In this step, you will build a Treeview table widget to display expenses in columns, add a total label that sums your spending, and create a refresh method that keeps everything in sync with the database.

In this step, get ready to:
  • Create a Treeview table with columns, headings, and a scrollbar.
  • Add a total label that displays the sum of all expenses.
  • Implement a refresh method that loads data from the database into the table.
Create the Treeview table

The ttk.Treeview widget displays tabular data in rows and columns with clickable headings. You will use it to show each expense's date, category, description, and amount.

  • In expense_tracker.py, find the end of the _build_form_frame method (after form_frame.columnconfigure(1, weight=1)).
  • Add the following _build_table_frame method below it:
    def _build_table_frame(self):
        table_frame = ttk.Frame(self)
        table_frame.grid(row=1, column=0, padx=10, pady=5, sticky="nsew")
        self.rowconfigure(1, weight=1)

        # Define columns for the expense table
        columns = ("id", "date", "category", "description", "amount")
        self.tree = ttk.Treeview(table_frame, columns=columns, show="headings", selectmode="browse")

        # Set column heading labels
        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=120, anchor="center")
        self.tree.column("description", width=300, anchor="w")
        self.tree.column("amount", width=100, anchor="e")

        # Add vertical scrollbar for long lists
        scrollbar = ttk.Scrollbar(table_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 form for editing
        self.tree.bind("<<TreeviewSelect>>", self.on_row_select)

What does this code do?

This method creates the main data display for your application.

  • show="headings" hides the default tree column and only shows your data columns with their headings.
  • selectmode="browse" restricts selection to one row at a time, which keeps editing simple.
  • The <<TreeviewSelect>> event fires whenever the user clicks a row, which you will use later to populate the form for editing.
  • Save expense_tracker.py.

You will not see the table yet because __init__ does not call this method. You will wire it up after building the total label.

Add the total display

A running total gives you instant feedback on your spending. This small frame sits below the table and shows the sum of all visible expenses.

  • Below the _build_table_frame method you just added, add the _build_total_frame method:
    def _build_total_frame(self):
        total_frame = ttk.Frame(self)
        total_frame.grid(row=2, column=0, padx=10, pady=5, sticky="ew")

        # Bold label showing the running expense total
        self.total_label = ttk.Label(total_frame, text="Total: $0.00", font=("Segoe UI", 12, "bold"))
        self.total_label.pack(side="right", padx=10)

What does this code do?

This creates a label that starts at Total: $0.00 and is positioned on the right side of the window. The refresh_table method you build next will update this label dynamically as expenses are added or removed.

  • Save expense_tracker.py.
Implement the refresh method and wire everything together

The table and total label exist, but they need a method that reads from the database and populates them. The refresh_table method clears old rows, loads every expense from SQLite, inserts each one into the Treeview, and recalculates the total.

  • Below the _build_total_frame method, add the refresh_table method:
    def refresh_table(self):
        # Clear all existing rows from the table
        for item in self.tree.get_children():
            self.tree.delete(item)

        # Load all expenses from the database
        expenses = self.db.get_all_expenses()
        total = 0.0

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

        # Update the total label with the new sum
        self.total_label.config(text=f"Total: ${total:.2f}")

What does this code do?

  • self.tree.get_children() returns all current row IDs so they can be deleted before reloading fresh data.
  • self.tree.insert("", "end", values=(...)) adds a new row at the bottom of the table with the expense's values.
  • The f"{expense.amount:.2f}" format ensures amounts always show two decimal places (e.g. 12.50 instead of 12.5).
  • Save expense_tracker.py.

Now you need to call these three new methods from __init__ so the table and total actually appear when the app launches.

  • Scroll up to the __init__ method inside ExpenseTrackerApp.
  • Find this line:
        self._build_form_frame()
  • Add the following three lines directly below it:
        self._build_table_frame()
        self._build_total_frame()
        self.refresh_table()

Why call refresh_table in __init__?

Calling refresh_table() at startup loads any previously saved expenses from the database file. If you close the app and reopen it later, your data reappears automatically.

  • Save expense_tracker.py.
  • Run the app by entering the following command in your terminal:
python expense_tracker.py
  • You should see the input form at the top, an empty table with column headings (ID, Date, Category, Description, Amount) in the middle, and Total: $0.00 at the bottom right.

Seeing an error when you run the app?

  • If you see AttributeError: 'ExpenseTrackerApp' object has no attribute '_build_table_frame', make sure the new methods are indented inside the ExpenseTrackerApp class (one level of indentation from the class definition).
  • If the table does not appear, confirm that your __init__ method calls self._build_table_frame() after self._build_form_frame().

Still stuck? Ask for help.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete expense_tracker.py as it should look at this point:

import tkinter as tk
from tkinter import ttk, messagebox
from dataclasses import dataclass, field
from datetime import date
import sqlite3


@dataclass
class Expense:
    amount: float
    category: str
    description: str
    date: str
    id: int = field(default=0)


class ExpenseDatabase:
    def __init__(self, db_path="expenses.db"):
        self.conn = sqlite3.connect(db_path)
        self.cursor = self.conn.cursor()
        self._create_table()

    def _create_table(self):
        self.cursor.execute("""
            CREATE TABLE IF NOT EXISTS expenses (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                amount REAL NOT NULL,
                category TEXT NOT NULL,
                description TEXT NOT NULL,
                date TEXT NOT NULL
            )
        """)
        self.conn.commit()

    def add_expense(self, expense: Expense) -> int:
        self.cursor.execute(
            "INSERT INTO expenses (amount, category, description, date) VALUES (?, ?, ?, ?)",
            (expense.amount, expense.category, expense.description, expense.date)
        )
        self.conn.commit()
        return self.cursor.lastrowid

    def get_all_expenses(self) -> list[Expense]:
        self.cursor.execute("SELECT id, amount, category, description, date FROM expenses ORDER BY date DESC")
        rows = self.cursor.fetchall()
        return [Expense(id=row[0], amount=row[1], category=row[2], description=row[3], date=row[4]) for row in rows]

    def update_expense(self, expense: Expense):
        self.cursor.execute(
            "UPDATE expenses SET amount=?, category=?, description=?, date=? WHERE id=?",
            (expense.amount, expense.category, expense.description, expense.date, expense.id)
        )
        self.conn.commit()

    def delete_expense(self, expense_id: int):
        self.cursor.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
        self.conn.commit()

    def close(self):
        self.conn.close()


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


class ExpenseTrackerApp(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Daily Expense Tracker")
        self.geometry("800x600")
        self.resizable(True, True)

        self.db = ExpenseDatabase()
        self.selected_id = None

        self._build_form_frame()
        self._build_table_frame()
        self._build_total_frame()
        self.refresh_table()

    def _build_form_frame(self):
        form_frame = ttk.LabelFrame(self, text="Expense Details", padding=10)
        form_frame.grid(row=0, column=0, padx=10, pady=5, sticky="ew")
        self.columnconfigure(0, weight=1)

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

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

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

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

        btn_frame = ttk.Frame(form_frame)
        btn_frame.grid(row=2, column=0, columnspan=6, pady=5)

        self.add_btn = ttk.Button(btn_frame, text="Add Expense", command=self.add_expense)
        self.add_btn.pack(side="left", padx=5)

        self.update_btn = ttk.Button(btn_frame, text="Update", command=self.update_expense)
        self.update_btn.pack(side="left", padx=5)

        self.delete_btn = ttk.Button(btn_frame, text="Delete", command=self.delete_expense)
        self.delete_btn.pack(side="left", padx=5)

        self.clear_btn = ttk.Button(btn_frame, text="Clear", command=self.clear_form)
        self.clear_btn.pack(side="left", padx=5)

        form_frame.columnconfigure(1, weight=1)

    def _build_table_frame(self):
        table_frame = ttk.Frame(self)
        table_frame.grid(row=1, column=0, padx=10, pady=5, sticky="nsew")
        self.rowconfigure(1, weight=1)

        columns = ("id", "date", "category", "description", "amount")
        self.tree = ttk.Treeview(table_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=120, anchor="center")
        self.tree.column("description", width=300, anchor="w")
        self.tree.column("amount", width=100, anchor="e")

        scrollbar = ttk.Scrollbar(table_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_row_select)

    def _build_total_frame(self):
        total_frame = ttk.Frame(self)
        total_frame.grid(row=2, column=0, padx=10, pady=5, sticky="ew")

        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 refresh_table(self):
        for item in self.tree.get_children():
            self.tree.delete(item)

        expenses = self.db.get_all_expenses()
        total = 0.0

        for expense in expenses:
            self.tree.insert("", "end", values=(
                expense.id, expense.date, expense.category,
                expense.description, f"{expense.amount:.2f}"
            ))
            total += expense.amount

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


if __name__ == "__main__":
    app = ExpenseTrackerApp()
    app.protocol("WM_DELETE_WINDOW", app.on_closing)
    app.mainloop()

Your app now displays a professional-looking table and live total. Next up, you will wire the form buttons to actually add, edit, and delete expenses so the table fills with real data.

Implement Add, Edit, and Delete Functionality

Your app has a form panel, a table display, and a total label. The interface looks ready, but clicking the buttons does nothing yet.

In this step, you will wire up the actual CRUD logic. Each button will call a method that interacts with the database, updates the UI, and keeps the form in sync with the selected row.

In this step, get ready to:
  • Wire the Add Expense button to create and save expenses.
  • Implement row selection so clicking an expense populates the form for editing.
  • Add Update, Delete, and Clear button handlers.
Wire the add expense handler

The Add Expense button in your form already references self.add_expense as its command. Now you need to define the method it calls.

  • In expense_tracker.py, find the refresh_table method. Add the following add_expense method directly above it:
    def add_expense(self):
        # Validate before saving (method built in next step)
        if not self.validate_inputs():
            return

        # Create an Expense from the form fields
        expense = Expense(
            amount=float(self.amount_entry.get().strip()),
            category=self.category_combo.get(),
            description=self.desc_entry.get().strip(),
            date=self.date_entry.get().strip()
        )
        self.db.add_expense(expense)
        self.clear_form()
        self.refresh_table()

What does this code do?

This method reads each form widget's current value, packages them into an Expense object, and hands it to the database layer.

  • self.validate_inputs() calls a validation method you will build in the next step. It ensures the data is valid before saving.
  • After saving, clear_form() resets the form and refresh_table() reloads the table to show the new entry.

You won't be able to test this button yet. The validate_inputs method does not exist yet, so clicking Add Expense will show an error. You will build that method in the next step.

Handle row selection

For editing to work, clicking a row in the table needs to populate the form fields with that expense's data. The Treeview already fires a <<TreeviewSelect>> event bound to self.on_row_select. Now you need to define that handler.

  • Add the on_row_select method directly below add_expense:
    def on_row_select(self, event):
        # Check if something is actually selected
        selected = self.tree.selection()
        if not selected:
            return
        item = self.tree.item(selected[0])
        values = item["values"]

        # Store the selected expense's ID for update/delete
        self.selected_id = values[0]

        # Populate the date field
        self.date_entry.delete(0, tk.END)
        self.date_entry.insert(0, values[1])

        # Set the category dropdown
        self.category_combo.set(values[2])

        # Populate the description field
        self.desc_entry.delete(0, tk.END)
        self.desc_entry.insert(0, values[3])

        # Populate the amount field
        self.amount_entry.delete(0, tk.END)
        self.amount_entry.insert(0, values[4])

What does this code do?

  • self.tree.selection() returns the currently highlighted row. If nothing is selected, the method exits early.
  • self.tree.item(selected[0]) retrieves the row's data as a dictionary. The "values" key holds the column values (ID, date, category, description, amount).
  • Each form widget is cleared with delete(0, tk.END) and then filled with the selected row's value using insert(0, ...).
  • self.selected_id stores which expense is selected so that Update and Delete know which database record to target.

You will verify this works after adding the remaining methods below.

Add update, delete, and clear methods

With selection handling in place, you need methods that act on the selected expense. You also need a way to reset the form back to a clean state.

  • Add the update_expense method directly below on_row_select:
    def update_expense(self):
        # Ensure a row is selected before updating
        if self.selected_id is None:
            messagebox.showwarning("No Selection", "Please select an expense to update.")
            return
        if not self.validate_inputs():
            return

        # Build an Expense with the selected ID and new form values
        expense = Expense(
            id=self.selected_id,
            amount=float(self.amount_entry.get().strip()),
            category=self.category_combo.get(),
            description=self.desc_entry.get().strip(),
            date=self.date_entry.get().strip()
        )
        self.db.update_expense(expense)
        self.clear_form()
        self.refresh_table()

What does this code do?

  • The guard clause checks if self.selected_id is None. If the user clicks Update without selecting a row, a warning dialog appears.
  • The Expense is constructed with the existing id so the database knows which row to overwrite with the new values.
  • Add the delete_expense method directly below update_expense:
    def delete_expense(self):
        # Ensure a row is selected before deleting
        if self.selected_id is None:
            messagebox.showwarning("No Selection", "Please select an expense to delete.")
            return

        # Ask for confirmation before removing
        confirm = messagebox.askyesno("Confirm Delete", "Are you sure you want to delete this expense?")
        if confirm:
            self.db.delete_expense(self.selected_id)
            self.clear_form()
            self.refresh_table()

What does this code do?

  • messagebox.askyesno() opens a Yes/No dialog. This prevents accidental deletions by requiring the user to confirm.
  • If the user clicks Yes, the expense is removed from the database and the table refreshes immediately.
  • Add the clear_form method directly below delete_expense:
    def clear_form(self):
        # Reset the selected expense tracking
        self.selected_id = None
        # Reset date to today
        self.date_entry.delete(0, tk.END)
        self.date_entry.insert(0, date.today().isoformat())
        # Reset category to the first option
        self.category_combo.set(CATEGORIES[0])
        # Clear description and amount
        self.desc_entry.delete(0, tk.END)
        self.amount_entry.delete(0, tk.END)
        # Deselect any highlighted row in the table
        self.tree.selection_remove(*self.tree.selection())

What does this code do?

  • self.selected_id = None clears the internal tracking so Update and Delete know no row is active.
  • The date field resets to today's date using date.today().isoformat(), and the category dropdown resets to the first option (Food).
  • self.tree.selection_remove() deselects the highlighted row in the Treeview so the UI stays consistent.
  • Save expense_tracker.py.
  • Run the app by running this command in your terminal:
python expense_tracker.py
  • In the form, change the category dropdown to something other than Food, then click Clear. The date should reset to today, category should return to Food, and description and amount should be empty.
  • Click Update without selecting a row. You should see a warning dialog that says "Please select an expense to update."

App crashes when you click a button?

  • If clicking Add Expense shows an AttributeError mentioning validate_inputs, that is expected. You will build that method in the next step.
  • If the app will not open at all, check the terminal for a syntax error. Make sure all five new methods (add_expense, on_row_select, update_expense, delete_expense, clear_form) are indented to the same level as refresh_table.
  • Make sure the new methods appear above refresh_table and below _build_total_frame inside the ExpenseTrackerApp class.

Why is my Expense Tracker crashing when I click a button?

✔️ Awesome, I've got everything!

Great. Make sure 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
from dataclasses import dataclass, field
from datetime import date
import sqlite3


@dataclass
class Expense:
    amount: float
    category: str
    description: str
    date: str
    id: int = field(default=0)


class ExpenseDatabase:
    def __init__(self, db_path="expenses.db"):
        self.conn = sqlite3.connect(db_path)
        self.cursor = self.conn.cursor()
        self._create_table()

    def _create_table(self):
        self.cursor.execute("""
            CREATE TABLE IF NOT EXISTS expenses (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                amount REAL NOT NULL,
                category TEXT NOT NULL,
                description TEXT NOT NULL,
                date TEXT NOT NULL
            )
        """)
        self.conn.commit()

    def add_expense(self, expense: Expense) -> int:
        self.cursor.execute(
            "INSERT INTO expenses (amount, category, description, date) VALUES (?, ?, ?, ?)",
            (expense.amount, expense.category, expense.description, expense.date)
        )
        self.conn.commit()
        return self.cursor.lastrowid

    def get_all_expenses(self) -> list[Expense]:
        self.cursor.execute("SELECT id, amount, category, description, date FROM expenses ORDER BY date DESC")
        rows = self.cursor.fetchall()
        return [Expense(id=row[0], amount=row[1], category=row[2], description=row[3], date=row[4]) for row in rows]

    def update_expense(self, expense: Expense):
        self.cursor.execute(
            "UPDATE expenses SET amount=?, category=?, description=?, date=? WHERE id=?",
            (expense.amount, expense.category, expense.description, expense.date, expense.id)
        )
        self.conn.commit()

    def delete_expense(self, expense_id: int):
        self.cursor.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
        self.conn.commit()

    def close(self):
        self.conn.close()


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


class ExpenseTrackerApp(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Daily Expense Tracker")
        self.geometry("800x600")
        self.resizable(True, True)

        self.db = ExpenseDatabase()
        self.selected_id = None

        self._build_form_frame()
        self._build_table_frame()
        self._build_total_frame()
        self.refresh_table()

    def _build_form_frame(self):
        form_frame = ttk.LabelFrame(self, text="Expense Details", padding=10)
        form_frame.grid(row=0, column=0, padx=10, pady=5, sticky="ew")
        self.columnconfigure(0, weight=1)

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

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

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

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

        btn_frame = ttk.Frame(form_frame)
        btn_frame.grid(row=2, column=0, columnspan=6, pady=5)

        self.add_btn = ttk.Button(btn_frame, text="Add Expense", command=self.add_expense)
        self.add_btn.pack(side="left", padx=5)

        self.update_btn = ttk.Button(btn_frame, text="Update", command=self.update_expense)
        self.update_btn.pack(side="left", padx=5)

        self.delete_btn = ttk.Button(btn_frame, text="Delete", command=self.delete_expense)
        self.delete_btn.pack(side="left", padx=5)

        self.clear_btn = ttk.Button(btn_frame, text="Clear", command=self.clear_form)
        self.clear_btn.pack(side="left", padx=5)

        form_frame.columnconfigure(1, weight=1)

    def _build_table_frame(self):
        table_frame = ttk.Frame(self)
        table_frame.grid(row=1, column=0, padx=10, pady=5, sticky="nsew")
        self.rowconfigure(1, weight=1)

        columns = ("id", "date", "category", "description", "amount")
        self.tree = ttk.Treeview(table_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=120, anchor="center")
        self.tree.column("description", width=300, anchor="w")
        self.tree.column("amount", width=100, anchor="e")

        scrollbar = ttk.Scrollbar(table_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_row_select)

    def _build_total_frame(self):
        total_frame = ttk.Frame(self)
        total_frame.grid(row=2, column=0, padx=10, pady=5, sticky="ew")

        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 add_expense(self):
        if not self.validate_inputs():
            return

        expense = Expense(
            amount=float(self.amount_entry.get().strip()),
            category=self.category_combo.get(),
            description=self.desc_entry.get().strip(),
            date=self.date_entry.get().strip()
        )
        self.db.add_expense(expense)
        self.clear_form()
        self.refresh_table()

    def update_expense(self):
        if self.selected_id is None:
            messagebox.showwarning("No Selection", "Please select an expense to update.")
            return
        if not self.validate_inputs():
            return

        expense = Expense(
            id=self.selected_id,
            amount=float(self.amount_entry.get().strip()),
            category=self.category_combo.get(),
            description=self.desc_entry.get().strip(),
            date=self.date_entry.get().strip()
        )
        self.db.update_expense(expense)
        self.clear_form()
        self.refresh_table()

    def delete_expense(self):
        if self.selected_id is None:
            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:
            self.db.delete_expense(self.selected_id)
            self.clear_form()
            self.refresh_table()

    def on_row_select(self, event):
        selected = self.tree.selection()
        if not selected:
            return
        item = self.tree.item(selected[0])
        values = item["values"]

        self.selected_id = 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_form(self):
        self.selected_id = None
        self.date_entry.delete(0, tk.END)
        self.date_entry.insert(0, date.today().isoformat())
        self.category_combo.set(CATEGORIES[0])
        self.desc_entry.delete(0, tk.END)
        self.amount_entry.delete(0, tk.END)
        self.tree.selection_remove(*self.tree.selection())

    def refresh_table(self):
        for item in self.tree.get_children():
            self.tree.delete(item)

        expenses = self.db.get_all_expenses()
        total = 0.0

        for expense in expenses:
            self.tree.insert("", "end", values=(
                expense.id, expense.date, expense.category,
                expense.description, f"{expense.amount:.2f}"
            ))
            total += expense.amount

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


if __name__ == "__main__":
    app = ExpenseTrackerApp()
    app.protocol("WM_DELETE_WINDOW", app.on_closing)
    app.mainloop()

Why do add_expense and update_expense call validate_inputs?

The structure is in place now, but the validate_inputs method does not exist yet. You will implement it in the next step to check for valid amounts, non-empty descriptions, and correct date formats.

Until then, clicking Add Expense or Update will show an error. The Clear and Delete buttons work independently of validation.

Your buttons are wired and the selection logic is in place. Next up, you will add input validation so the app rejects bad data before it reaches the database.

Add Input Validation and Polish

Your Expense Tracker can add, edit, and delete expenses. But what happens if a user accidentally submits an empty form or types letters instead of a number for the amount?

In this step, you will add input validation to catch bad data before it reaches the database. You will also add a proper shutdown method and test the full application end-to-end.

In this step, get ready to:
  • Add a validation method that checks amount, description, and date fields.
  • Add a clean shutdown method to properly close the database connection.
  • Test the complete application with invalid inputs and verify data persists after restart.
Create the validate_inputs method

Your add_expense and update_expense methods already call self.validate_inputs(), but the method itself does not exist yet. Time to implement it.

  • In expense_tracker.py, find the _build_total_frame method. Add the following validate_inputs method directly below it (above add_expense):
    def validate_inputs(self) -> bool:
        amount_text = self.amount_entry.get().strip()
        description = self.desc_entry.get().strip()
        date_text = self.date_entry.get().strip()

        if not amount_text:
            messagebox.showerror("Validation Error", "Amount is required.")
            return False
        try:
            amount = float(amount_text)
            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

        if not description:
            messagebox.showerror("Validation Error", "Description is required.")
            return False

        if not date_text:
            messagebox.showerror("Validation Error", "Date is required.")
            return False
        try:
            date.fromisoformat(date_text)
        except ValueError:
            messagebox.showerror("Validation Error", "Date must be in YYYY-MM-DD format.")
            return False

        return True

What does this code do?

  • It reads the current values from each form field and strips whitespace.
  • The amount check ensures the field is not empty, converts it to a float, and confirms it is positive. If the user types letters, the except ValueError catches the conversion failure.
  • date.fromisoformat() validates that the date string is a real date in YYYY-MM-DD format. It raises a ValueError for invalid dates like 2024-13-45.
  • messagebox.showerror() pops up a native error dialog telling the user exactly what went wrong.
  • Save expense_tracker.py.
  • Run the app and try clicking Add Expense with the description and amount fields empty. You should see a popup error dialog saying "Amount is required."

Not seeing the error dialog?

  • Make sure validate_inputs is indented at the same level as the other methods inside ExpenseTrackerApp. One wrong indent makes it a standalone function instead of a class method.
  • Confirm your add_expense method starts with if not self.validate_inputs(): return. Without that call, validation never runs.
  • Why isn't my validation error dialog appearing when I click Add Expense?
Add the on_closing method

Your __main__ block already registers app.on_closing as the window-close handler. This method needs to close the database connection before destroying the window so no data is lost.

  • In expense_tracker.py, scroll to the end of the ExpenseTrackerApp class (below the refresh_table method). Add the following method:
    def on_closing(self):
        self.db.close()
        self.destroy()

Why close the database on exit?

SQLite buffers writes in memory before flushing them to disk. Calling self.db.close() ensures any pending writes are flushed and the database file is released cleanly.

self.destroy() shuts down the Tkinter event loop and closes the window.

  • Save expense_tracker.py.
Test the complete application

Your app is now feature-complete. Time to run it through a full test to confirm everything works together.

  • Run the application by executing this command in your terminal:
python expense_tracker.py
  • Test validation: leave the amount empty and click Add Expense. You should see the "Amount is required." error dialog.
  • Test validation: type abc in the amount field and click Add Expense. You should see "Amount must be a valid number."
  • Test validation: change the date field to not-a-date and click Add Expense (with valid amount and description). You should see "Date must be in YYYY-MM-DD format."
  • Test adding: enter a valid expense (amount: 12.50, category: Food, description: Lunch) and click Add Expense. The expense should appear in the table and the total should update.
  • Test editing: click the expense row in the table. The form fields should populate with its values. Change the amount to 15.00 and click Update. The table should reflect the new amount.
  • Test persistence: close the window by clicking the X button. Then run python expense_tracker.py again. Your expense should still be in the table.
  • Test deleting: select the expense row, click Delete, and confirm. The row should disappear and the total should drop to $0.00.

What confirms everything is working?

  • Invalid inputs trigger error dialogs and prevent bad data from reaching the database.
  • Valid expenses appear in the Treeview table with correct formatting.
  • The total label updates after every add, edit, or delete.
  • Closing and reopening the app shows the same expenses. The expenses.db file persists your data.

App crashes when you close the window?

  • Make sure the on_closing method is inside the ExpenseTrackerApp class. If it is outside the class, app.on_closing will throw an AttributeError.
  • If data is not persisting, check that your add_expense database method calls self.conn.commit() after the INSERT. Without commit, changes are lost on close.
  • My app crashes or data is not saving after I close the window. Help me debug.

✔️ Awesome, I've got everything!

Great. Double check you have saved expense_tracker.py.

ⓧ I'd like to double check the full code

Here is the complete expense_tracker.py file for comparison:

import tkinter as tk
from tkinter import ttk, messagebox
from dataclasses import dataclass, field
from datetime import date
import sqlite3


@dataclass
class Expense:
    amount: float
    category: str
    description: str
    date: str
    id: int = field(default=0)


class ExpenseDatabase:
    def __init__(self, db_path="expenses.db"):
        self.conn = sqlite3.connect(db_path)
        self.cursor = self.conn.cursor()
        self._create_table()

    def _create_table(self):
        self.cursor.execute("""
            CREATE TABLE IF NOT EXISTS expenses (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                amount REAL NOT NULL,
                category TEXT NOT NULL,
                description TEXT NOT NULL,
                date TEXT NOT NULL
            )
        """)
        self.conn.commit()

    def add_expense(self, expense: Expense) -> int:
        self.cursor.execute(
            "INSERT INTO expenses (amount, category, description, date) VALUES (?, ?, ?, ?)",
            (expense.amount, expense.category, expense.description, expense.date)
        )
        self.conn.commit()
        return self.cursor.lastrowid

    def get_all_expenses(self) -> list[Expense]:
        self.cursor.execute("SELECT id, amount, category, description, date FROM expenses ORDER BY date DESC")
        rows = self.cursor.fetchall()
        return [Expense(id=row[0], amount=row[1], category=row[2], description=row[3], date=row[4]) for row in rows]

    def update_expense(self, expense: Expense):
        self.cursor.execute(
            "UPDATE expenses SET amount=?, category=?, description=?, date=? WHERE id=?",
            (expense.amount, expense.category, expense.description, expense.date, expense.id)
        )
        self.conn.commit()

    def delete_expense(self, expense_id: int):
        self.cursor.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
        self.conn.commit()

    def close(self):
        self.conn.close()


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


class ExpenseTrackerApp(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Daily Expense Tracker")
        self.geometry("800x600")
        self.resizable(True, True)

        self.db = ExpenseDatabase()
        self.selected_id = None

        self._build_form_frame()
        self._build_table_frame()
        self._build_total_frame()
        self.refresh_table()

    def _build_form_frame(self):
        form_frame = ttk.LabelFrame(self, text="Expense Details", padding=10)
        form_frame.grid(row=0, column=0, padx=10, pady=5, sticky="ew")
        self.columnconfigure(0, weight=1)

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

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

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

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

        btn_frame = ttk.Frame(form_frame)
        btn_frame.grid(row=2, column=0, columnspan=6, pady=5)

        self.add_btn = ttk.Button(btn_frame, text="Add Expense", command=self.add_expense)
        self.add_btn.pack(side="left", padx=5)

        self.update_btn = ttk.Button(btn_frame, text="Update", command=self.update_expense)
        self.update_btn.pack(side="left", padx=5)

        self.delete_btn = ttk.Button(btn_frame, text="Delete", command=self.delete_expense)
        self.delete_btn.pack(side="left", padx=5)

        self.clear_btn = ttk.Button(btn_frame, text="Clear", command=self.clear_form)
        self.clear_btn.pack(side="left", padx=5)

        form_frame.columnconfigure(1, weight=1)

    def _build_table_frame(self):
        table_frame = ttk.Frame(self)
        table_frame.grid(row=1, column=0, padx=10, pady=5, sticky="nsew")
        self.rowconfigure(1, weight=1)

        columns = ("id", "date", "category", "description", "amount")
        self.tree = ttk.Treeview(table_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=120, anchor="center")
        self.tree.column("description", width=300, anchor="w")
        self.tree.column("amount", width=100, anchor="e")

        scrollbar = ttk.Scrollbar(table_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_row_select)

    def _build_total_frame(self):
        total_frame = ttk.Frame(self)
        total_frame.grid(row=2, column=0, padx=10, pady=5, sticky="ew")

        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) -> bool:
        amount_text = self.amount_entry.get().strip()
        description = self.desc_entry.get().strip()
        date_text = self.date_entry.get().strip()

        if not amount_text:
            messagebox.showerror("Validation Error", "Amount is required.")
            return False
        try:
            amount = float(amount_text)
            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

        if not description:
            messagebox.showerror("Validation Error", "Description is required.")
            return False

        if not date_text:
            messagebox.showerror("Validation Error", "Date is required.")
            return False
        try:
            date.fromisoformat(date_text)
        except ValueError:
            messagebox.showerror("Validation Error", "Date must be in YYYY-MM-DD format.")
            return False

        return True

    def add_expense(self):
        if not self.validate_inputs():
            return

        expense = Expense(
            amount=float(self.amount_entry.get().strip()),
            category=self.category_combo.get(),
            description=self.desc_entry.get().strip(),
            date=self.date_entry.get().strip()
        )
        self.db.add_expense(expense)
        self.clear_form()
        self.refresh_table()

    def update_expense(self):
        if self.selected_id is None:
            messagebox.showwarning("No Selection", "Please select an expense to update.")
            return
        if not self.validate_inputs():
            return

        expense = Expense(
            id=self.selected_id,
            amount=float(self.amount_entry.get().strip()),
            category=self.category_combo.get(),
            description=self.desc_entry.get().strip(),
            date=self.date_entry.get().strip()
        )
        self.db.update_expense(expense)
        self.clear_form()
        self.refresh_table()

    def delete_expense(self):
        if self.selected_id is None:
            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:
            self.db.delete_expense(self.selected_id)
            self.clear_form()
            self.refresh_table()

    def on_row_select(self, event):
        selected = self.tree.selection()
        if not selected:
            return
        item = self.tree.item(selected[0])
        values = item["values"]

        self.selected_id = 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_form(self):
        self.selected_id = None
        self.date_entry.delete(0, tk.END)
        self.date_entry.insert(0, date.today().isoformat())
        self.category_combo.set(CATEGORIES[0])
        self.desc_entry.delete(0, tk.END)
        self.amount_entry.delete(0, tk.END)
        self.tree.selection_remove(*self.tree.selection())

    def refresh_table(self):
        for item in self.tree.get_children():
            self.tree.delete(item)

        expenses = self.db.get_all_expenses()
        total = 0.0

        for expense in expenses:
            self.tree.insert("", "end", values=(
                expense.id, expense.date, expense.category,
                expense.description, f"{expense.amount:.2f}"
            ))
            total += expense.amount

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

    def on_closing(self):
        self.db.close()
        self.destroy()


if __name__ == "__main__":
    app = ExpenseTrackerApp()
    app.protocol("WM_DELETE_WINDOW", app.on_closing)
    app.mainloop()

Your Expense Tracker is fully functional with proper validation protecting the database from bad data. Next up, the Secret Mission challenges you to add category filtering and a spending summary.

Secret mission

Category Filtering and Spending Summary

Your expense tracker works great, but what if you could instantly see where your money is going? In this secret mission, you'll add category filtering to narrow down your expenses and a spending summary that breaks down totals by category with percentages.

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • Project folder on your Desktop containing expense_tracker.py.
  • SQLite database file expenses.db (inside your project folder).

Keep everything running

No action needed. Choose this if you want to keep tracking expenses or plan to extend your application further.

  • Your project folder stays on your Desktop.
  • Your expenses.db file retains all your expense data between sessions.
  • Python 3.13 and Cursor remain installed for future projects.

Pause - I'll come back to this later

Close the application to free up memory. Your files and data stay on disk for when you return.

  • Close the Expense Tracker window if it is still running.
  • Close Cursor (or VS Code) to free up memory.
  • Your project folder and expenses.db remain on your Desktop. All your expense data will still be there when you reopen the app.

Delete - I don't want to use this again

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

  • Close the Expense Tracker window and Cursor (or VS Code) if they are still open.
  • Delete the project folder from your Desktop by opening your terminal and running the following commands.

macOS

cd ~/Desktop
rm -rf expense-tracker

Windows

cd ~\Desktop
Remove-Item -Recurse -Force expense-tracker

If your project folder has a different name, replace expense-tracker with the actual folder name you used.

  • Confirm the folder is gone by running:
ls ~/Desktop

You should no longer see your project folder in the output.

Nice Work!

Nice Work!

You just built a fully functional desktop application from scratch using Python. Your Daily Expense Tracker can create, read, update, and delete expenses with persistent storage that survives between sessions.

You've learned how to:

  • Build a complete Python desktop application using Object-Oriented Programming with clear separation of concerns across an Expense model, ExpenseDatabase persistence layer, and ExpenseTrackerApp GUI class.
  • Design a graphical interface with Tkinter ttk themed widgets including Entry, Combobox, Treeview, and Button widgets arranged with the grid layout manager.
  • Implement persistent data storage with SQLite using parameterized queries for full CRUD operations and input validation with error handling.
  • Secret Mission: Added category filtering and a spending summary breakdown showing totals and percentages per category.

Ready to quiz yourself?