Expense Tracker with Flet and SQLite
Build a touch-friendly desktop expense tracker with charts in pure Python.
Introduction
30 Second Summary
Everyone has that moment at the end of the month where they wonder where all their money went. Tracking expenses sounds simple, but most apps are either too complex to set up or too clunky to use quickly on the go.
In this project, you will build a touch-friendly expense tracker that runs as a native desktop window. You'll use Python with Flet for the GUI, SQLite for local data storage, and an interactive bar chart to visualize your spending by category.
What You'll Build
A native desktop expense tracker application with a Material Design interface, built entirely in Python using Flet and backed by a local SQLite database.
By the end of this project, you'll have:
- A working desktop app with large tap-friendly buttons, text fields, and a modern Material Design GUI.
- A local SQLite database that persists expense data between app sessions.
- An interactive bar chart showing spending per category, built with flet-charts.
- Secret Mission: Add date-range filtering to show expenses only from a selected month.
Are there any prerequisites?
You should have basic Python knowledge including variables, functions, loops, and lists. No prior experience with GUI frameworks or databases is needed.
Set Up Your Project Environment
Every app needs a clean workspace before you start writing features. Without one, packages conflict with each other and files end up scattered across your system.
In this step, you will create a dedicated project folder, set up an isolated Python environment, and install Flet. Flet is the framework that gives your app a modern Material Design interface with zero frontend code.
In this step, get ready to:
- Create a project directory and Python virtual environment.
- Install Flet and the flet-charts package.
- Create a minimal app file and confirm Flet opens a native window.
Create your project directory and virtual environment
A virtual environment keeps your project's packages separate from the rest of your system. This prevents version conflicts between projects.
- Press Ctrl+Alt+T to open a terminal window.
- 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
- Confirm the folder exists by running:
ls ~/Desktop
You should see expense-tracker in the output.
- Create a Python virtual environment by running:
python -m venv .venv
- Activate the virtual environment by running:
source .venv/bin/activate
Your terminal prompt now shows (.venv) at the beginning. This confirms the virtual environment is active.
- Verify your Python version by running:
python --version
You should see Python 3.14.6 (or a similar 3.14.x version) in the output.
Not seeing (.venv) in your prompt?
Make sure you ran the source command from inside the expense-tracker folder. The .venv folder must exist in your current directory.
If you see a "No such file or directory" error, run ls to check that .venv appears in the listing. If it does not, re-run python -m venv .venv first.
Help me fix my virtual environment activation.
Install Flet and flet-charts
Flet provides the GUI framework. The flet-charts extension adds chart controls for data visualization.
- Install Flet with all desktop extras by running:
pip install 'flet[all]'
What does flet[all] mean?
The [all] part tells pip to install Flet along with its optional extras for desktop, web, and packaging support. This gives you the full set of features for building native desktop apps.
Seeing a network error or timeout?
Large packages like Flet can take a minute to download. If the install times out, check your internet connection and try the command again.
If you see a permissions error, confirm your terminal prompt starts with (.venv). Installing without the virtual environment active causes permission failures.
Help me fix my Flet installation error.
- Install the charts extension by running:
pip install flet-charts
- Verify Flet installed correctly by running:
flet --version
You should see a version number like 0.85.3 in the output.
Command not found after installing?
If flet --version returns "command not found", your virtual environment may not be active. Run source .venv/bin/activate again, then retry the command.
Help me get the flet command working.
Create the project file and verify Flet launches
Now that Flet is installed, you will create a minimal app file to confirm everything works. This file will grow into your full expense tracker over the next steps.
- Open VS Code in your project directory by running:
code .
What does code . do?
The code command launches VS Code. The . tells it to open the current folder as a workspace. This way VS Code shows your expense-tracker directory in the sidebar.
- In VS Code, click File in the top menu bar.
- Click New File.
- Type main.py as the file name and press Enter to create it.
- Paste the following code into main.py:
import flet as ft
def main(page: ft.Page):
# Set the window title
page.title = "Expense Tracker"
# Display a simple text label to confirm the app runs
page.add(ft.Text("Expense Tracker"))
if __name__ == "__main__":
ft.run(main)
What does this code do?
- import flet as ft brings in the Flet framework so you can use its controls.
- def main(page: ft.Page) defines the function Flet calls when your app starts. The page object represents your application window.
- page.add(ft.Text(...)) places a text label in the window.
- ft.run(main) launches the app and opens the native desktop window.
- Save main.py by pressing Ctrl+S.
- Switch back to your terminal and run the app:
flet run main.py
A native desktop window opens with the title "Expense Tracker" and displays the text "Expense Tracker" in the body.
- Close the app window when you are done verifying. You can also press Ctrl+C in the terminal to stop the process.
Window not appearing?
Make sure your virtual environment is still active (you should see (.venv) in your terminal prompt). If not, run source .venv/bin/activate before trying again.
If you see an import error mentioning flet, confirm the package installed by running pip list | grep flet. You should see both flet and flet-charts in the output.
Help me get my Flet app window to open.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and close the Flet window before continuing.
ⓧ I'd like to double check the full code
Here is the complete main.py at this point:
import flet as ft
def main(page: ft.Page):
page.title = "Expense Tracker"
page.add(ft.Text("Expense Tracker"))
if __name__ == "__main__":
ft.run(main)
Your environment is ready. You have a virtual environment, Flet installed, and a working app window. Next up, you will build the database layer that stores expenses between sessions.
Build the Database Layer
Right now your app opens a window and displays text. That is great for proving Flet works, but an expense tracker needs to remember data between sessions.
In this step, you will build a SQLite database layer that stores expenses in a local file. Every function you write here follows the same pattern: open a connection, run a query, close the connection. Once this layer is in place, the rest of the app can save and retrieve data without worrying about how storage works.
In this step, get ready to:
- Define the database schema and initialization function.
- Write CRUD helper functions for adding, reading, and deleting expenses.
- Wire the database into your app so it creates the database file on startup.
Create the SQLite schema and constants
Before writing any database logic, you need to set up the imports, configure a database file path, and define the categories your app will support.
- In VS Code, open main.py and replace the entire file contents with the following code:
import flet as ft
import sqlite3
from datetime import date
# Path to the SQLite database file
DB_PATH = "expenses.db"
# Valid spending categories for the dropdown menu
CATEGORIES = ["Food", "Transport", "Shopping", "Bills", "Entertainment", "Health", "Other"]
# Map each category to a distinct color for visual identification
CATEGORY_COLORS = {
"Food": ft.Colors.GREEN,
"Transport": ft.Colors.BLUE,
"Shopping": ft.Colors.PURPLE,
"Bills": ft.Colors.ORANGE,
"Entertainment": ft.Colors.PINK,
"Health": ft.Colors.RED,
"Other": ft.Colors.GREY,
}
def init_db():
"""Create the expenses table if it does not already exist."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
amount REAL NOT NULL CHECK (amount > 0),
category TEXT NOT NULL,
description TEXT,
date TEXT NOT NULL
)
""")
conn.commit()
conn.close()
def main(page: ft.Page):
page.title = "Expense Tracker"
page.padding = 20
page.theme_mode = ft.ThemeMode.LIGHT
init_db()
if __name__ == "__main__":
ft.run(main)
What does this code do?
This sets up the foundation for your database layer:
- sqlite3 is Python's built-in database module. No extra install needed.
- DB_PATH tells every function where to find the database file. SQLite creates this file automatically if it does not exist.
- CATEGORIES and CATEGORY_COLORS define the spending categories and their colors for the UI later.
- init_db() creates the expenses table with five columns. CREATE TABLE IF NOT EXISTS means the app can restart without errors. CHECK (amount > 0) rejects invalid negative amounts at the database level.
- Save main.py.
- You won't see a visible change yet. The next chunk adds the helper functions that read and write expense data.
Implement the CRUD functions
CRUD stands for Create, Read, Update, and Delete. Your expense tracker needs functions to insert new expenses, fetch all of them, delete one, and calculate spending totals per category.
- In main.py, find the init_db() function. Add the following four functions directly below it (between init_db() and the def main line):
def add_expense(amount, category, description, expense_date):
"""Insert a new expense into the database."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"INSERT INTO expenses (amount, category, description, date) VALUES (?, ?, ?, ?)",
(amount, category, description, expense_date),
)
conn.commit()
conn.close()
def get_all_expenses():
"""Retrieve all expenses, newest first."""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT id, amount, category, description, date FROM expenses ORDER BY date DESC")
rows = cursor.fetchall()
conn.close()
return rows
def delete_expense(expense_id):
"""Remove a single expense by its ID."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("DELETE FROM expenses WHERE id = ?", (expense_id,))
conn.commit()
conn.close()
def get_category_totals():
"""Sum spending per category for the bar chart."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT category, SUM(amount) as total FROM expenses GROUP BY category ORDER BY total DESC")
rows = cursor.fetchall()
conn.close()
return rows
What do these functions do?
Each function opens its own connection, runs one query, then closes the connection. This keeps things simple and avoids locked-database issues.
- add_expense() inserts a row using ? placeholders. These parameterized queries prevent SQL injection by keeping user input separate from the SQL command.
- get_all_expenses() sets conn.row_factory = sqlite3.Row so each result behaves like a dictionary. You can access columns by name (exp["amount"]) instead of by index number.
- delete_expense() removes one row by its unique id column.
- get_category_totals() uses GROUP BY and SUM(amount) to calculate total spending per category. This powers the bar chart later.
Why parameterized queries?
Never insert user input directly into a SQL string. Parameterized queries (the ? placeholders) let the database engine handle escaping. This protects against SQL injection attacks where malicious input could delete or expose your data.
- Save main.py.
- You won't see a change yet. The next substep runs the app so the database file gets created.
Wire the database into app startup and verify
The init_db() call is already inside main() from the first chunk. Now you just need to run the app and confirm the database file appears.
- Run your app by entering this command in the terminal:
flet run main.py
- A native window opens with the title "Expense Tracker". The window will be empty since we removed the placeholder text. Close the window.
- Confirm the database file was created by running:
ls
- You should see expenses.db listed alongside main.py and the .venv folder.
Don't see expenses.db?
- Make sure you saved main.py before running it. Unsaved changes mean the old code (without init_db()) runs instead.
- Check that init_db() is called inside the main() function body, not outside it.
- If you see a syntax error in the terminal, check for missing commas or mismatched parentheses in the CREATE TABLE statement.
Help me troubleshoot why expenses.db is not appearing after I run flet run main.py
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and that expenses.db exists in your project folder.
ⓧ I'd like to double check the full code
import flet as ft
import sqlite3
from datetime import date
DB_PATH = "expenses.db"
CATEGORIES = ["Food", "Transport", "Shopping", "Bills", "Entertainment", "Health", "Other"]
CATEGORY_COLORS = {
"Food": ft.Colors.GREEN,
"Transport": ft.Colors.BLUE,
"Shopping": ft.Colors.PURPLE,
"Bills": ft.Colors.ORANGE,
"Entertainment": ft.Colors.PINK,
"Health": ft.Colors.RED,
"Other": ft.Colors.GREY,
}
def init_db():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
amount REAL NOT NULL CHECK (amount > 0),
category TEXT NOT NULL,
description TEXT,
date TEXT NOT NULL
)
""")
conn.commit()
conn.close()
def add_expense(amount, category, description, expense_date):
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"INSERT INTO expenses (amount, category, description, date) VALUES (?, ?, ?, ?)",
(amount, category, description, expense_date),
)
conn.commit()
conn.close()
def get_all_expenses():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT id, amount, category, description, date FROM expenses ORDER BY date DESC")
rows = cursor.fetchall()
conn.close()
return rows
def delete_expense(expense_id):
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("DELETE FROM expenses WHERE id = ?", (expense_id,))
conn.commit()
conn.close()
def get_category_totals():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT category, SUM(amount) as total FROM expenses GROUP BY category ORDER BY total DESC")
rows = cursor.fetchall()
conn.close()
return rows
def main(page: ft.Page):
page.title = "Expense Tracker"
page.padding = 20
page.theme_mode = ft.ThemeMode.LIGHT
init_db()
if __name__ == "__main__":
ft.run(main)
Your database layer is complete. You have functions to create, read, and delete expenses, plus an aggregation function for chart data. Next up, you will build the input form so users can actually add expenses through the GUI.
Build the Add Expense Form
Your database layer is ready to store expenses, but there is no way for a user to actually enter one.
In this step, you will build a touch-friendly input form with large text fields, a category dropdown, and a floating action button that validates input and saves expenses to the database.
In this step, get ready to:
- Create input controls for amount, category, and description.
- Add a floating action button with form validation and submission logic.
- Wire the form into the page layout and verify it saves expenses.
Create the input form with touch-friendly controls
Every expense needs three pieces of information: how much was spent, what category it falls under, and an optional description.
- In main.py, find the init_db() call inside the main() function, then paste the following code below it:
# Amount input with number keyboard for easy entry
amount_field = ft.TextField(
label="Amount",
prefix_text="$",
keyboard_type=ft.KeyboardType.NUMBER,
width=200,
text_size=18,
)
# Dropdown populated with predefined spending categories
category_dropdown = ft.Dropdown(
label="Category",
width=200,
options=[ft.dropdown.Option(c) for c in CATEGORIES],
text_size=16,
)
# Optional text field for extra context about the expense
description_field = ft.TextField(
label="Description",
hint_text="What did you spend on?",
expand=True,
text_size=16,
)
What does this code do?
Each control is configured for comfortable finger tapping on a touchscreen:
- amount_field uses keyboard_type=ft.KeyboardType.NUMBER so mobile devices show a numeric keypad. The prefix_text="$" adds a dollar sign before the input.
- category_dropdown builds its options list dynamically from the CATEGORIES list you defined earlier.
- description_field uses expand=True to fill any remaining horizontal space in the row.
- Save main.py.
You will not see a visible change yet. The next chunk wires these controls into the page.
Seeing a syntax error?
- Make sure the new code is indented with 4 spaces (one level inside def main).
- Check that you pasted below init_db(), not outside the main() function.
Still stuck? Why am I getting an indentation error in my Flet main function?
Implement the form submission logic
The form needs a handler that validates what the user typed, saves it to the database, and resets the fields for the next entry.
- Below the description_field you just added, paste this code:
# Placeholder functions for list and chart refresh (built in later steps)
def refresh_expenses():
pass
def refresh_chart():
pass
# Handle the add button click: validate, save, and reset the form
def handle_add(e):
if not amount_field.value:
page.open(ft.SnackBar(ft.Text("Please enter an amount.")))
return
try:
amount = float(amount_field.value)
except ValueError:
page.open(ft.SnackBar(ft.Text("Amount must be a number.")))
return
if not category_dropdown.value:
page.open(ft.SnackBar(ft.Text("Please select a category.")))
return
# Save the expense with today's date auto-filled
add_expense(
amount=amount,
category=category_dropdown.value,
description=description_field.value or "",
expense_date=date.today().isoformat(),
)
# Clear form fields after a successful save
amount_field.value = ""
description_field.value = ""
category_dropdown.value = None
# Refresh the UI and confirm to the user
refresh_expenses()
refresh_chart()
page.open(ft.SnackBar(ft.Text("Expense added!")))
# Large floating button for an easy tap target
add_button = ft.FloatingActionButton(
icon=ft.Icons.ADD,
on_click=handle_add,
)
What does this code do?
- refresh_expenses() and refresh_chart() are placeholder functions. You will replace them with real logic in the next two steps.
- handle_add(e) runs three validation checks (amount present, amount is numeric, category selected) before saving. Each failure shows a SnackBar message and exits early with return.
- date.today().isoformat() auto-fills today's date in ISO format (YYYY-MM-DD) so the user never has to type it.
- ft.FloatingActionButton creates a large circular button that floats above the content. It is easy to tap on a touchscreen.
- Save main.py.
You will not see a change yet. The next chunk adds these controls to the visible page.
Seeing a NameError for date?
- Confirm that from datetime import date is at the top of your file. This import was added in the previous step.
- Check that handle_add is indented at the same level as amount_field (inside main()).
Need help? Why is my handle_add function throwing a NameError?
Wire the form into the page layout
The controls exist but are not visible yet. You need to arrange them in a row and add that row to the page.
- Below the add_button you just added, paste this code:
# Arrange the input controls in a horizontal row that wraps on small screens
form_row = ft.Row(
controls=[amount_field, category_dropdown, description_field],
alignment=ft.MainAxisAlignment.START,
spacing=10,
wrap=True,
)
# Add the form row to the page and attach the floating action button
page.add(form_row)
page.floating_action_button = add_button
What does this code do?
- ft.Row arranges the three input controls side by side. wrap=True means controls will drop to a new line if the window is too narrow.
- spacing=10 adds 10 pixels between each control for comfortable spacing.
- page.floating_action_button pins the add button to the bottom-right corner of the window, following Material Design conventions.
- Save main.py.
- Run your app by executing this command in your terminal:
flet run main.py
- In the app window, type 12.50 in the Amount field.
- Select Food from the Category dropdown.
- Type Lunch in the Description field.
- Click the round + button in the bottom-right corner.
You should see a green SnackBar appear at the bottom of the window saying "Expense added!" and all three form fields should reset to empty.
Don't see the SnackBar?
- Make sure on_click=handle_add is set on the add_button. Without it, clicking does nothing.
- Check that page.floating_action_button = add_button appears after page.add(form_row). If the button is not attached to the page, it will not render.
- If you see the form but the button is missing, confirm that you typed ft.FloatingActionButton (not ft.Button).
Still stuck? My Flet FloatingActionButton is not showing or clicking does nothing.
✔️ Awesome, I've got everything!
Great. Double check that you have saved main.py and the SnackBar appeared after adding a test expense.
ⓧ I'd like to double check the full code
Here is the complete main.py at this point:
import flet as ft
import sqlite3
from datetime import date
DB_PATH = "expenses.db"
CATEGORIES = ["Food", "Transport", "Shopping", "Bills", "Entertainment", "Health", "Other"]
CATEGORY_COLORS = {
"Food": ft.Colors.GREEN,
"Transport": ft.Colors.BLUE,
"Shopping": ft.Colors.PURPLE,
"Bills": ft.Colors.ORANGE,
"Entertainment": ft.Colors.PINK,
"Health": ft.Colors.RED,
"Other": ft.Colors.GREY,
}
def init_db():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
amount REAL NOT NULL CHECK (amount > 0),
category TEXT NOT NULL,
description TEXT,
date TEXT NOT NULL
)
""")
conn.commit()
conn.close()
def add_expense(amount, category, description, expense_date):
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"INSERT INTO expenses (amount, category, description, date) VALUES (?, ?, ?, ?)",
(amount, category, description, expense_date),
)
conn.commit()
conn.close()
def get_all_expenses():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT id, amount, category, description, date FROM expenses ORDER BY date DESC")
rows = cursor.fetchall()
conn.close()
return rows
def delete_expense(expense_id):
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("DELETE FROM expenses WHERE id = ?", (expense_id,))
conn.commit()
conn.close()
def get_category_totals():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT category, SUM(amount) as total FROM expenses GROUP BY category ORDER BY total DESC")
rows = cursor.fetchall()
conn.close()
return rows
def main(page: ft.Page):
page.title = "Expense Tracker"
page.padding = 20
page.theme_mode = ft.ThemeMode.LIGHT
init_db()
amount_field = ft.TextField(
label="Amount",
prefix_text="$",
keyboard_type=ft.KeyboardType.NUMBER,
width=200,
text_size=18,
)
category_dropdown = ft.Dropdown(
label="Category",
width=200,
options=[ft.dropdown.Option(c) for c in CATEGORIES],
text_size=16,
)
description_field = ft.TextField(
label="Description",
hint_text="What did you spend on?",
expand=True,
text_size=16,
)
def refresh_expenses():
pass
def refresh_chart():
pass
def handle_add(e):
if not amount_field.value:
page.open(ft.SnackBar(ft.Text("Please enter an amount.")))
return
try:
amount = float(amount_field.value)
except ValueError:
page.open(ft.SnackBar(ft.Text("Amount must be a number.")))
return
if not category_dropdown.value:
page.open(ft.SnackBar(ft.Text("Please select a category.")))
return
add_expense(
amount=amount,
category=category_dropdown.value,
description=description_field.value or "",
expense_date=date.today().isoformat(),
)
amount_field.value = ""
description_field.value = ""
category_dropdown.value = None
refresh_expenses()
refresh_chart()
page.open(ft.SnackBar(ft.Text("Expense added!")))
add_button = ft.FloatingActionButton(
icon=ft.Icons.ADD,
on_click=handle_add,
)
form_row = ft.Row(
controls=[amount_field, category_dropdown, description_field],
alignment=ft.MainAxisAlignment.START,
spacing=10,
wrap=True,
)
page.add(form_row)
page.floating_action_button = add_button
if __name__ == "__main__":
ft.run(main)
Your form is working and saving expenses to the database. Next up, you will display those saved expenses as a scrollable list of cards so users can see and manage their spending history.
Display Expenses as a Scrollable List
Your form successfully adds expenses to the database. The problem is you have no way to see them inside the app.
In this step, you will build a scrollable list that renders each expense as a Material Design card. Each card shows the amount, category, description, and date. You will also add delete functionality so you can remove expenses with a single tap.
In this step, get ready to:
- Build an expense list view using cards and a scrollable container.
- Add delete functionality with a trash icon on each card.
- Connect the list to the database with tab-based navigation.
Build the expense list view with cards
Right now, the app just shows a form. You need a scrollable container that displays each expense record as a styled card.
- In main.py, find the line that says description_field = ft.TextField( and scroll to just after its closing parenthesis.
- Add the following code below description_field to create the scrollable list and a placeholder container for the chart (which you will build in the next step):
expense_list = ft.ListView(expand=True, spacing=10, padding=10)
chart_container = ft.Container(expand=True, padding=10)
What do these controls do?
ft.ListView is a scrollable container that displays child controls in a vertical list. Setting expand=True makes it fill all available vertical space.
ft.Container is a generic wrapper that will hold the bar chart in the next step. For now it is an empty placeholder.
- Now find the placeholder refresh_expenses() function that currently says pass. Replace the entire function with this implementation:
def refresh_expenses():
# Query all expenses from the database
expenses = get_all_expenses()
# Clear existing cards before rebuilding the list
expense_list.controls.clear()
for exp in expenses:
# Build a Card for each expense record
expense_list.controls.append(
ft.Card(
content=ft.ListTile(
leading=ft.Icon(
ft.Icons.CIRCLE,
color=CATEGORY_COLORS.get(exp["category"], ft.Colors.GREY),
),
title=ft.Text(f"${exp['amount']:.2f}", size=18, weight=ft.FontWeight.BOLD),
subtitle=ft.Text(f"{exp['category']} - {exp['description'] or 'No description'}\n{exp['date']}"),
trailing=ft.IconButton(
ft.Icons.DELETE_OUTLINE,
icon_color=ft.Colors.RED_400,
on_click=lambda e, eid=exp["id"]: handle_delete(eid),
),
),
)
)
What does this code do?
refresh_expenses() queries the database, clears the current list, then rebuilds it by appending one ft.Card per expense.
- ft.ListTile provides a consistent Material Design row layout with leading icon, title, subtitle, and trailing action.
- The leading icon uses the CATEGORY_COLORS dictionary to display a colored circle matching the expense category.
- The trailing ft.IconButton shows a red trash icon. The lambda captures each expense's id so the correct record is deleted on tap.
- Save main.py.
You will not see any visible change yet because the list is not wired into the page layout. The next substep adds the delete handler, then you will connect everything.
Add delete functionality
Each card already has a trash icon button wired to call handle_delete(). You need to define that function so tapping the icon removes the expense from the database and refreshes the list.
- In main.py, find the handle_add function. Add the following new function directly below it (after the last line of handle_add):
def handle_delete(expense_id):
# Remove the expense from the database
delete_expense(expense_id)
# Rebuild the expense list and chart
refresh_expenses()
refresh_chart()
# Confirm deletion to the user
page.open(ft.SnackBar(ft.Text("Expense deleted.")))
What does this code do?
handle_delete() takes an expense ID, calls the delete_expense() database function you wrote in Step 2, then calls both refresh_expenses() and refresh_chart() so the UI stays in sync.
The ft.SnackBar at the bottom of the screen confirms the action, just like the one that appears when you add an expense.
- Save main.py.
You will not see a change yet. The next chunk wires both the list and delete handler into the page layout.
Connect the list with tab-based navigation
To give the app structure, you will replace the bare form layout with a tabbed interface. The first tab shows the expense form and list. The second tab is a placeholder for the chart summary you will build next.
- In main.py, find the line page.add(form_row). Delete that line along with the page.floating_action_button = add_button line below it.
- In their place, add the following tabs structure and startup call:
tabs = ft.Tabs(
selected_index=0,
expand=True,
tabs=[
ft.Tab(
text="Expenses",
icon=ft.Icons.LIST_ALT,
content=ft.Column(
controls=[form_row, expense_list],
expand=True,
spacing=15,
),
),
ft.Tab(
text="Summary",
icon=ft.Icons.BAR_CHART,
content=chart_container,
),
],
)
page.add(tabs)
page.floating_action_button = add_button
# Load existing expenses on app start
refresh_expenses()
What does this code do?
- ft.Tabs creates a tabbed navigation bar at the top of the app window.
- The Expenses tab wraps the form and the scrollable expense list inside an ft.Column.
- The Summary tab holds chart_container, which is empty for now.
- refresh_expenses() is called at the end of main() so any previously saved expenses load immediately when the app starts.
- Save main.py and run the app by executing:
flet run main.py
- Add 2-3 expenses using the form (try different categories like Food, Transport, and Shopping).
- Confirm each expense appears as a card in the list below the form, showing the amount in bold, the category with a colored circle, the description, and today's date.
- Tap the red trash icon on one card and confirm it disappears from the list.
Cards not showing up?
- Make sure you deleted the old page.add(form_row) line. If both page.add(form_row) and page.add(tabs) exist, the layout will be broken.
- Check that expense_list is defined before refresh_expenses() in the file. Python reads top-to-bottom, so the variable must exist before it is used.
- Verify that refresh_expenses() is called at the very end of main(), after page.add(tabs).
Still stuck? Why aren't my expense cards appearing in the Flet ListView?
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py before moving on.
ⓧ I'd like to double check the full code
Here is the full main.py at this point:
import flet as ft
import sqlite3
from datetime import date
DB_PATH = "expenses.db"
CATEGORIES = ["Food", "Transport", "Shopping", "Bills", "Entertainment", "Health", "Other"]
CATEGORY_COLORS = {
"Food": ft.Colors.GREEN,
"Transport": ft.Colors.BLUE,
"Shopping": ft.Colors.PURPLE,
"Bills": ft.Colors.ORANGE,
"Entertainment": ft.Colors.PINK,
"Health": ft.Colors.RED,
"Other": ft.Colors.GREY,
}
def init_db():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
amount REAL NOT NULL CHECK (amount > 0),
category TEXT NOT NULL,
description TEXT,
date TEXT NOT NULL
)
""")
conn.commit()
conn.close()
def add_expense(amount, category, description, expense_date):
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"INSERT INTO expenses (amount, category, description, date) VALUES (?, ?, ?, ?)",
(amount, category, description, expense_date),
)
conn.commit()
conn.close()
def get_all_expenses():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT id, amount, category, description, date FROM expenses ORDER BY date DESC")
rows = cursor.fetchall()
conn.close()
return rows
def delete_expense(expense_id):
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("DELETE FROM expenses WHERE id = ?", (expense_id,))
conn.commit()
conn.close()
def get_category_totals():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT category, SUM(amount) as total FROM expenses GROUP BY category ORDER BY total DESC")
rows = cursor.fetchall()
conn.close()
return rows
def main(page: ft.Page):
page.title = "Expense Tracker"
page.padding = 20
page.theme_mode = ft.ThemeMode.LIGHT
init_db()
amount_field = ft.TextField(
label="Amount",
prefix_text="$",
keyboard_type=ft.KeyboardType.NUMBER,
width=200,
text_size=18,
)
category_dropdown = ft.Dropdown(
label="Category",
width=200,
options=[ft.dropdown.Option(c) for c in CATEGORIES],
text_size=16,
)
description_field = ft.TextField(
label="Description",
hint_text="What did you spend on?",
expand=True,
text_size=16,
)
expense_list = ft.ListView(expand=True, spacing=10, padding=10)
chart_container = ft.Container(expand=True, padding=10)
def refresh_expenses():
expenses = get_all_expenses()
expense_list.controls.clear()
for exp in expenses:
expense_list.controls.append(
ft.Card(
content=ft.ListTile(
leading=ft.Icon(
ft.Icons.CIRCLE,
color=CATEGORY_COLORS.get(exp["category"], ft.Colors.GREY),
),
title=ft.Text(f"${exp['amount']:.2f}", size=18, weight=ft.FontWeight.BOLD),
subtitle=ft.Text(f"{exp['category']} - {exp['description'] or 'No description'}\n{exp['date']}"),
trailing=ft.IconButton(
ft.Icons.DELETE_OUTLINE,
icon_color=ft.Colors.RED_400,
on_click=lambda e, eid=exp["id"]: handle_delete(eid),
),
),
)
)
def refresh_chart():
pass
def handle_add(e):
if not amount_field.value:
page.open(ft.SnackBar(ft.Text("Please enter an amount.")))
return
try:
amount = float(amount_field.value)
except ValueError:
page.open(ft.SnackBar(ft.Text("Amount must be a number.")))
return
if not category_dropdown.value:
page.open(ft.SnackBar(ft.Text("Please select a category.")))
return
add_expense(
amount=amount,
category=category_dropdown.value,
description=description_field.value or "",
expense_date=date.today().isoformat(),
)
amount_field.value = ""
description_field.value = ""
category_dropdown.value = None
refresh_expenses()
refresh_chart()
page.open(ft.SnackBar(ft.Text("Expense added!")))
def handle_delete(expense_id):
delete_expense(expense_id)
refresh_expenses()
refresh_chart()
page.open(ft.SnackBar(ft.Text("Expense deleted.")))
add_button = ft.FloatingActionButton(
icon=ft.Icons.ADD,
on_click=handle_add,
)
form_row = ft.Row(
controls=[amount_field, category_dropdown, description_field],
alignment=ft.MainAxisAlignment.START,
spacing=10,
wrap=True,
)
tabs = ft.Tabs(
selected_index=0,
expand=True,
tabs=[
ft.Tab(
text="Expenses",
icon=ft.Icons.LIST_ALT,
content=ft.Column(
controls=[form_row, expense_list],
expand=True,
spacing=15,
),
),
ft.Tab(
text="Summary",
icon=ft.Icons.BAR_CHART,
content=chart_container,
),
],
)
page.add(tabs)
page.floating_action_button = add_button
refresh_expenses()
if __name__ == "__main__":
ft.run(main)
Your expense tracker now has a complete input-to-display loop. Next up, you will build a bar chart that visualizes your spending by category.
Add a Spending Summary Bar Chart
Your expense tracker can add and display expenses, but numbers alone make it hard to spot where your money goes.
In this step, you will add an interactive bar chart to the Summary tab using flet-charts. Each category gets its own color-coded bar, so spending patterns become obvious at a glance.
In this step, get ready to:
- Import flet-charts and build a chart builder function.
- Display an interactive bar chart in the Summary tab.
- Wire the chart to refresh automatically when expenses change.
Import flet-charts and create the chart builder function
The flet-charts package provides chart controls built on top of Flet. You installed it in Step 1, and now you will import it and use its BarChart control to visualize your spending.
- In main.py, find this line at the very top:
import flet as ft
- Add the flet-charts import directly below it, so the first two lines now look like this:
import flet as ft
import flet_charts as fch
Why import flet_charts separately?
Flet keeps its charting controls in a separate package called flet-charts. This keeps the base Flet install lightweight.
The convention is to import it as fch to keep chart references short throughout your code.
Now you will replace the placeholder refresh_chart() function with the real implementation that queries your database and builds a bar chart.
- In main.py, find the placeholder function inside main():
def refresh_chart():
pass
- Replace it entirely with the following function:
def refresh_chart():
totals = get_category_totals()
if not totals:
chart_container.content = ft.Text("No expenses yet. Add some to see your spending summary!", size=16)
return
max_total = max(row[1] for row in totals)
groups = []
labels = []
for i, (category, total) in enumerate(totals):
groups.append(
fch.BarChartGroup(
x=i,
rods=[
fch.BarChartRod(
from_y=0,
to_y=total,
width=40,
color=CATEGORY_COLORS.get(category, ft.Colors.GREY),
border_radius=4,
tooltip=fch.BarChartRodTooltip(f"${total:.2f}"),
),
],
)
)
labels.append(
fch.ChartAxisLabel(
value=i,
label=ft.Container(ft.Text(category, size=11), padding=5),
)
)
chart = fch.BarChart(
expand=True,
interactive=True,
max_y=max_total * 1.2,
border=ft.Border.all(1, ft.Colors.GREY_400),
horizontal_grid_lines=fch.ChartGridLines(
color=ft.Colors.GREY_300, width=1, dash_pattern=[3, 3]
),
left_axis=fch.ChartAxis(label_size=50),
bottom_axis=fch.ChartAxis(label_size=50, labels=labels),
groups=groups,
)
chart_container.content = chart
What does this code do?
This function queries your database for spending totals per category, then builds a visual bar chart from the results.
- get_category_totals() returns each category and its summed spending amount.
- If there are no expenses yet, a fallback text message appears instead of an empty chart.
- Each category becomes a BarChartGroup with a single BarChartRod whose height (to_y) is the total spending in that category.
- max_y is set to 1.2 times the highest total so bars never touch the top edge.
- interactive=True enables touch tooltips that show the dollar amount when you tap or hover a bar.
- The chart is assigned to chart_container.content, which is already inside the Summary tab from the previous step.
- Save main.py.
Seeing an import error?
Make sure your virtual environment is activated (you should see (.venv) in your terminal prompt). If flet-charts is not installed, run pip install flet-charts inside the activated environment.
Help me fix this import error
Wire chart refresh to update whenever expenses change
The handle_add() and handle_delete() functions already call refresh_chart() from the previous step. The missing piece is calling it once at app startup so the chart appears immediately when you open the Summary tab.
- In main.py, scroll to the bottom of the main() function and find this line:
refresh_expenses()
- Add refresh_chart() directly below it, so both functions run at startup:
refresh_expenses()
refresh_chart()
- Save main.py and run the app:
flet run main.py
- Add expenses in at least two different categories (for example, one in Food and one in Transport).
- Click the Summary tab at the top of the app.
You should see a bar chart with one colored bar per category. The bar heights reflect how much you spent in each category.
Tapping or hovering a bar shows the dollar amount as a tooltip.
Chart not showing any bars?
Make sure you have at least one expense saved. If the Summary tab shows the fallback text instead of bars, switch back to the Expenses tab and add an expense first.
If you see an error mentioning fch or BarChart, double-check that import flet_charts as fch is on line 2 of your file.
Help me debug my bar chart
✔️ Awesome, I've got everything!
Great. Double-check you have saved main.py and your chart is displaying correctly.
ⓧ I'd like to double check the full code
import flet as ft
import flet_charts as fch
import sqlite3
from datetime import date
DB_PATH = "expenses.db"
CATEGORIES = ["Food", "Transport", "Shopping", "Bills", "Entertainment", "Health", "Other"]
CATEGORY_COLORS = {
"Food": ft.Colors.GREEN,
"Transport": ft.Colors.BLUE,
"Shopping": ft.Colors.PURPLE,
"Bills": ft.Colors.ORANGE,
"Entertainment": ft.Colors.PINK,
"Health": ft.Colors.RED,
"Other": ft.Colors.GREY,
}
def init_db():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
amount REAL NOT NULL CHECK (amount > 0),
category TEXT NOT NULL,
description TEXT,
date TEXT NOT NULL
)
""")
conn.commit()
conn.close()
def add_expense(amount, category, description, expense_date):
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"INSERT INTO expenses (amount, category, description, date) VALUES (?, ?, ?, ?)",
(amount, category, description, expense_date),
)
conn.commit()
conn.close()
def get_all_expenses():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT id, amount, category, description, date FROM expenses ORDER BY date DESC")
rows = cursor.fetchall()
conn.close()
return rows
def delete_expense(expense_id):
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("DELETE FROM expenses WHERE id = ?", (expense_id,))
conn.commit()
conn.close()
def get_category_totals():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT category, SUM(amount) as total FROM expenses GROUP BY category ORDER BY total DESC")
rows = cursor.fetchall()
conn.close()
return rows
def main(page: ft.Page):
page.title = "Expense Tracker"
page.padding = 20
page.theme_mode = ft.ThemeMode.LIGHT
init_db()
amount_field = ft.TextField(
label="Amount",
prefix_text="$",
keyboard_type=ft.KeyboardType.NUMBER,
width=200,
text_size=18,
)
category_dropdown = ft.Dropdown(
label="Category",
width=200,
options=[ft.dropdown.Option(c) for c in CATEGORIES],
text_size=16,
)
description_field = ft.TextField(
label="Description",
hint_text="What did you spend on?",
expand=True,
text_size=16,
)
expense_list = ft.ListView(expand=True, spacing=10, padding=10)
chart_container = ft.Container(expand=True, padding=10)
def refresh_expenses():
expenses = get_all_expenses()
expense_list.controls.clear()
for exp in expenses:
expense_list.controls.append(
ft.Card(
content=ft.ListTile(
leading=ft.Icon(
ft.Icons.CIRCLE,
color=CATEGORY_COLORS.get(exp["category"], ft.Colors.GREY),
),
title=ft.Text(f"${exp['amount']:.2f}", size=18, weight=ft.FontWeight.BOLD),
subtitle=ft.Text(f"{exp['category']} - {exp['description'] or 'No description'}\n{exp['date']}"),
trailing=ft.IconButton(
ft.Icons.DELETE_OUTLINE,
icon_color=ft.Colors.RED_400,
on_click=lambda e, eid=exp["id"]: handle_delete(eid),
),
),
)
)
def refresh_chart():
totals = get_category_totals()
if not totals:
chart_container.content = ft.Text("No expenses yet. Add some to see your spending summary!", size=16)
return
max_total = max(row[1] for row in totals)
groups = []
labels = []
for i, (category, total) in enumerate(totals):
groups.append(
fch.BarChartGroup(
x=i,
rods=[
fch.BarChartRod(
from_y=0,
to_y=total,
width=40,
color=CATEGORY_COLORS.get(category, ft.Colors.GREY),
border_radius=4,
tooltip=fch.BarChartRodTooltip(f"${total:.2f}"),
),
],
)
)
labels.append(
fch.ChartAxisLabel(
value=i,
label=ft.Container(ft.Text(category, size=11), padding=5),
)
)
chart = fch.BarChart(
expand=True,
interactive=True,
max_y=max_total * 1.2,
border=ft.Border.all(1, ft.Colors.GREY_400),
horizontal_grid_lines=fch.ChartGridLines(
color=ft.Colors.GREY_300, width=1, dash_pattern=[3, 3]
),
left_axis=fch.ChartAxis(label_size=50),
bottom_axis=fch.ChartAxis(label_size=50, labels=labels),
groups=groups,
)
chart_container.content = chart
def handle_add(e):
if not amount_field.value:
page.open(ft.SnackBar(ft.Text("Please enter an amount.")))
return
try:
amount = float(amount_field.value)
except ValueError:
page.open(ft.SnackBar(ft.Text("Amount must be a number.")))
return
if not category_dropdown.value:
page.open(ft.SnackBar(ft.Text("Please select a category.")))
return
add_expense(
amount=amount,
category=category_dropdown.value,
description=description_field.value or "",
expense_date=date.today().isoformat(),
)
amount_field.value = ""
description_field.value = ""
category_dropdown.value = None
refresh_expenses()
refresh_chart()
page.open(ft.SnackBar(ft.Text("Expense added!")))
def handle_delete(expense_id):
delete_expense(expense_id)
refresh_expenses()
refresh_chart()
page.open(ft.SnackBar(ft.Text("Expense deleted.")))
add_button = ft.FloatingActionButton(
icon=ft.Icons.ADD,
on_click=handle_add,
)
form_row = ft.Row(
controls=[amount_field, category_dropdown, description_field],
alignment=ft.MainAxisAlignment.START,
spacing=10,
wrap=True,
)
tabs = ft.Tabs(
selected_index=0,
expand=True,
tabs=[
ft.Tab(
text="Expenses",
icon=ft.Icons.LIST_ALT,
content=ft.Column(
controls=[form_row, expense_list],
expand=True,
spacing=15,
),
),
ft.Tab(
text="Summary",
icon=ft.Icons.BAR_CHART,
content=chart_container,
),
],
)
page.add(tabs)
page.floating_action_button = add_button
refresh_expenses()
refresh_chart()
if __name__ == "__main__":
ft.run(main)
Your expense tracker now has a complete visual dashboard. Next up, you will tackle the Secret Mission and add date-range filtering to both the list and the chart.
Secret mission
Filter Expenses by Month
Your expense tracker shows all expenses at once, but what if you have months of data? In this bonus challenge, add a month/year filter that updates both the expense list and the summary chart in real time.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The expense-tracker project folder (contains main.py, .venv, and expenses.db).
- Python virtual environment .venv with Flet and flet-charts packages installed.
- SQLite database file expenses.db storing your expense data.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your expense tracker.
- Your project files stay in the expense-tracker folder on your Desktop.
- Your expenses.db database retains all saved expenses.
- Reactivate the virtual environment any time with source .venv/bin/activate from inside the project folder.
Pause - I'll come back to this later
Close the Flet app window and deactivate your virtual environment to free up terminal resources.
- Close the Expense Tracker desktop window if it is still open.
- Deactivate the virtual environment by running:
deactivate
- Your project files and database remain intact on disk. When you are ready to resume, navigate back to the expense-tracker folder, reactivate the virtual environment, and run flet run main.py.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your code, virtual environment, and all saved expense data permanently.
- Close the Expense Tracker desktop window if it is still open.
- Deactivate the virtual environment if it is still active:
deactivate
- Navigate to your Desktop and remove the entire project folder:
cd ~/Desktop
rm -rf expense-tracker
- This removes main.py, the .venv virtual environment (including all installed packages), and the expenses.db database in one step.
- Confirm the folder is gone by running:
ls ~/Desktop | grep expense-tracker
- If nothing is returned, the project has been fully removed.
Nice Work!
Nice Work!
You just built a fully functional expense tracker desktop app using pure Python. From database schema to bar charts, everything runs locally on your machine with no external services required.
You've learned how to:
- Build a native desktop app with a modern Material Design GUI using Flet and Python.
- Store data persistently in a local SQLite database with parameterized queries and schema constraints.
- Create an interactive bar chart visualization of spending by category using flet-charts.
- Secret Mission: Added month-based filtering across both the expense list and summary chart.
Ready to quiz yourself?