Chapter 49: Build a Desktop App with Tkinter
Not every program needs a browser or a terminal. Sometimes you want a real window — with buttons, text boxes, menus, and a clean interface that anyone can use without knowing Python.
Tkinter is Python's built-in GUI library. It ships with Python. No installation needed. It's not the prettiest framework, but it runs everywhere, and it's more than capable of building useful desktop apps.
In this chapter you'll build a Personal Budget Tracker — a desktop app with a form to add transactions, a table to view them, charts to visualise spending, and a database to persist everything.
How Tkinter Works
Tkinter is event-driven. You:
- Create a window (the root)
- Add widgets (buttons, labels, entry boxes, etc.)
- Register event handlers (functions called when buttons are clicked, keys pressed, etc.)
- Start the event loop —
root.mainloop()— which listens for events forever
import tkinter as tk
root = tk.Tk()
root.title("My App")
root.geometry("400x300") # width x height in pixels
label = tk.Label(root, text="Hello, World!", font=("Arial", 16))
label.pack(pady=20)
button = tk.Button(root, text="Click me",
command=lambda: label.config(text="Clicked!"))
button.pack()
root.mainloop()
Run this and you get a window with a label and a button. Click the button — the label changes. That's Tkinter.
Core Widgets
import tkinter as tk
from tkinter import ttk # themed widgets — always prefer ttk over tk
root = tk.Tk()
root.title("Widget Gallery")
# Label — display text or images
tk.Label(root, text="Name:").grid(row=0, column=0, padx=5, pady=5, sticky="e")
# Entry — single-line text input
entry = ttk.Entry(root, width=20)
entry.grid(row=0, column=1, padx=5, pady=5)
# Button
btn = ttk.Button(root, text="Submit",
command=lambda: print(entry.get()))
btn.grid(row=1, column=1, pady=5)
# Text — multi-line text area
text = tk.Text(root, width=30, height=5)
text.grid(row=2, column=0, columnspan=2, padx=5, pady=5)
# Combobox (dropdown)
combo = ttk.Combobox(root, values=["Option 1", "Option 2", "Option 3"])
combo.set("Option 1")
combo.grid(row=3, column=0, columnspan=2, pady=5)
# Checkbutton
var = tk.BooleanVar()
check = ttk.Checkbutton(root, text="I agree", variable=var)
check.grid(row=4, column=0, columnspan=2)
# Radiobuttons
choice = tk.StringVar(value="A")
for i, option in enumerate(["A", "B", "C"]):
ttk.Radiobutton(root, text=option, variable=choice, value=option).grid(
row=5, column=i
)
# Listbox
listbox = tk.Listbox(root, height=4)
for item in ["Apple", "Banana", "Cherry", "Date"]:
listbox.insert(tk.END, item)
listbox.grid(row=6, column=0, columnspan=2, pady=5)
root.mainloop()
Layout Managers
Tkinter has three ways to position widgets:
pack — stack widgets in a direction
frame = tk.Frame(root)
frame.pack(fill="both", expand=True, padx=10, pady=10)
tk.Label(frame, text="Top").pack(side="top", fill="x")
tk.Label(frame, text="Left").pack(side="left", fill="y")
tk.Label(frame, text="Right").pack(side="right", fill="y")
tk.Label(frame, text="Bottom").pack(side="bottom", fill="x")
grid — position in a row/column table
# column=0 is left, row=0 is top
# sticky="e" aligns to the east (right) of the cell
tk.Label(root, text="Email:").grid(row=0, column=0, sticky="e", padx=5, pady=3)
ttk.Entry(root).grid(row=0, column=1, sticky="ew", padx=5, pady=3)
tk.Label(root, text="Password:").grid(row=1, column=0, sticky="e", padx=5, pady=3)
ttk.Entry(root, show="*").grid(row=1, column=1, sticky="ew", padx=5, pady=3)
ttk.Button(root, text="Login").grid(row=2, column=1, sticky="e", padx=5, pady=5)
root.columnconfigure(1, weight=1) # column 1 stretches when window resizes
place — exact pixel coordinates
# Use sparingly — fragile on different screen sizes
tk.Label(root, text="Absolute").place(x=50, y=100)
Use grid for forms and complex layouts. Use pack for simple vertical/horizontal stacks.
Variables — Linking Widgets to Data
Tkinter variables (StringVar, IntVar, BooleanVar, DoubleVar) connect widgets to Python values:
name_var = tk.StringVar()
age_var = tk.IntVar(value=25)
agree_var = tk.BooleanVar()
amount_var = tk.DoubleVar()
# Entry bound to a StringVar — changes automatically sync
entry = ttk.Entry(root, textvariable=name_var)
entry.pack()
# Read the value
print(name_var.get()) # whatever is in the entry box
# Set the value (updates the widget automatically)
name_var.set("Alice")
# React to changes
name_var.trace_add("write", lambda *_: print(f"Name changed to: {name_var.get()}"))
Dialogs and Messageboxes
from tkinter import messagebox, filedialog, simpledialog
# Information dialog
messagebox.showinfo("Success", "Record saved successfully!")
# Warning
messagebox.showwarning("Warning", "This cannot be undone.")
# Error
messagebox.showerror("Error", "Failed to connect to database.")
# Yes/No question
if messagebox.askyesno("Confirm", "Delete this record?"):
delete_record()
# Open file dialog
file_path = filedialog.askopenfilename(
title="Open File",
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")]
)
# Save file dialog
save_path = filedialog.asksaveasfilename(
defaultextension=".csv",
filetypes=[("CSV files", "*.csv")]
)
# Ask for text input
value = simpledialog.askstring("Input", "Enter your name:")
Menus
def create_menu(root: tk.Tk) -> None:
menubar = tk.Menu(root)
root.config(menu=menubar)
# File menu
file_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="File", menu=file_menu)
file_menu.add_command(label="New", command=lambda: print("New"), accelerator="Ctrl+N")
file_menu.add_command(label="Open...", command=lambda: print("Open"), accelerator="Ctrl+O")
file_menu.add_command(label="Save", command=lambda: print("Save"), accelerator="Ctrl+S")
file_menu.add_separator()
file_menu.add_command(label="Exit", command=root.quit)
# Edit menu
edit_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Edit", menu=edit_menu)
edit_menu.add_command(label="Undo", accelerator="Ctrl+Z")
edit_menu.add_command(label="Redo", accelerator="Ctrl+Y")
# Keyboard shortcuts
root.bind("<Control-n>", lambda e: print("New"))
root.bind("<Control-o>", lambda e: print("Open"))
root.bind("<Control-s>", lambda e: print("Save"))
Treeview — Displaying Tabular Data
ttk.Treeview is the right widget for displaying table data:
from tkinter import ttk
columns = ("id", "date", "description", "amount", "category")
tree = ttk.Treeview(root, columns=columns, show="headings", height=15)
# Configure column headers and widths
tree.heading("id", text="ID")
tree.heading("date", text="Date")
tree.heading("description", text="Description")
tree.heading("amount", text="Amount")
tree.heading("category", text="Category")
tree.column("id", width=40, anchor="center")
tree.column("date", width=100, anchor="center")
tree.column("description", width=200)
tree.column("amount", width=90, anchor="e")
tree.column("category", width=120)
# Scrollbar
scrollbar = ttk.Scrollbar(root, orient="vertical", command=tree.yview)
tree.configure(yscrollcommand=scrollbar.set)
tree.grid(row=0, column=0, sticky="nsew")
scrollbar.grid(row=0, column=1, sticky="ns")
# Insert a row
tree.insert("", tk.END, values=(1, "2026-03-01", "Coffee", "£3.50", "Food"))
# Delete all rows
for item in tree.get_children():
tree.delete(item)
# Get selected row
selected = tree.selection()
if selected:
values = tree.item(selected[0])["values"]
Embedding matplotlib Charts
import tkinter as tk
from tkinter import ttk
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
def create_chart_frame(parent) -> FigureCanvasTkAgg:
fig, ax = plt.subplots(figsize=(6, 3))
ax.bar(["Food", "Transport", "Housing", "Fun"], [400, 120, 900, 150],
color=["#4caf50", "#2196f3", "#ff9800", "#e91e63"])
ax.set_title("Spending by Category")
ax.set_ylabel("Amount (£)")
plt.tight_layout()
canvas = FigureCanvasTkAgg(fig, master=parent)
canvas.draw()
canvas.get_tk_widget().pack(fill="both", expand=True)
return canvas
Project — Personal Budget Tracker
"""
budget_tracker.py
A full desktop budget tracking app built with Tkinter.
Features:
- Add income and expense transactions
- View all transactions in a sortable table
- See spending by category in a pie chart
- Persistent storage in SQLite
- Export to CSV
"""
import csv
import sqlite3
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from datetime import date
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
# ── Database ──────────────────────────────────────────────────────────────────
DB_PATH = "budget.db"
def init_db() -> None:
with sqlite3.connect(DB_PATH) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
description TEXT NOT NULL,
amount REAL NOT NULL,
type TEXT NOT NULL CHECK(type IN ('Income','Expense')),
category TEXT NOT NULL
)
""")
def db_add(date_: str, description: str, amount: float,
type_: str, category: str) -> int:
with sqlite3.connect(DB_PATH) as conn:
cur = conn.execute(
"INSERT INTO transactions (date, description, amount, type, category)"
" VALUES (?, ?, ?, ?, ?)",
(date_, description, amount, type_, category)
)
return cur.lastrowid
def db_delete(transaction_id: int) -> None:
with sqlite3.connect(DB_PATH) as conn:
conn.execute("DELETE FROM transactions WHERE id = ?", (transaction_id,))
def db_fetch_all() -> list[tuple]:
with sqlite3.connect(DB_PATH) as conn:
return conn.execute(
"SELECT id, date, description, amount, type, category"
" FROM transactions ORDER BY date DESC, id DESC"
).fetchall()
def db_summary() -> dict:
with sqlite3.connect(DB_PATH) as conn:
rows = conn.execute(
"SELECT type, SUM(amount) FROM transactions GROUP BY type"
).fetchall()
totals = {"Income": 0.0, "Expense": 0.0}
for type_, total in rows:
totals[type_] = total or 0.0
return totals
def db_spending_by_category() -> list[tuple]:
with sqlite3.connect(DB_PATH) as conn:
return conn.execute(
"SELECT category, SUM(amount) FROM transactions"
" WHERE type='Expense' GROUP BY category ORDER BY SUM(amount) DESC"
).fetchall()
# ── Main Application ──────────────────────────────────────────────────────────
CATEGORIES = ["Food", "Transport", "Housing", "Utilities",
"Entertainment", "Health", "Shopping", "Salary", "Other"]
COLORS = ["#f44336", "#e91e63", "#9c27b0", "#673ab7",
"#3f51b5", "#2196f3", "#00bcd4", "#4caf50", "#ff9800"]
class BudgetApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("Personal Budget Tracker")
self.geometry("1000x700")
self.resizable(True, True)
self.configure(bg="#f5f5f5")
init_db()
self._build_ui()
self.refresh()
# ── UI Construction ───────────────────────────────────────────────────────
def _build_ui(self) -> None:
self._build_menu()
# Top: summary bar
self._build_summary_bar()
# Main pane: left = form + table, right = chart
paned = ttk.PanedWindow(self, orient="horizontal")
paned.pack(fill="both", expand=True, padx=10, pady=5)
left = ttk.Frame(paned)
right = ttk.Frame(paned)
paned.add(left, weight=3)
paned.add(right, weight=2)
self._build_form(left)
self._build_table(left)
self._build_chart(right)
def _build_menu(self) -> None:
menubar = tk.Menu(self)
self.config(menu=menubar)
file_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="File", menu=file_menu)
file_menu.add_command(label="Export CSV...", command=self.export_csv)
file_menu.add_separator()
file_menu.add_command(label="Exit", command=self.quit)
help_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Help", menu=help_menu)
help_menu.add_command(label="About",
command=lambda: messagebox.showinfo(
"About", "Budget Tracker v1.0\nBuilt with Python + Tkinter"
))
def _build_summary_bar(self) -> None:
frame = ttk.Frame(self, padding=8)
frame.pack(fill="x", padx=10, pady=(10, 0))
self.lbl_income = ttk.Label(frame, text="Income: £0.00",
font=("Helvetica", 12, "bold"),
foreground="#4caf50")
self.lbl_expenses = ttk.Label(frame, text="Expenses: £0.00",
font=("Helvetica", 12, "bold"),
foreground="#f44336")
self.lbl_balance = ttk.Label(frame, text="Balance: £0.00",
font=("Helvetica", 13, "bold"))
self.lbl_income.pack(side="left", padx=20)
self.lbl_expenses.pack(side="left", padx=20)
self.lbl_balance.pack(side="right", padx=20)
def _build_form(self, parent) -> None:
form = ttk.LabelFrame(parent, text="Add Transaction", padding=10)
form.pack(fill="x", padx=5, pady=5)
# Variables
self.v_date = tk.StringVar(value=date.today().isoformat())
self.v_description = tk.StringVar()
self.v_amount = tk.StringVar()
self.v_type = tk.StringVar(value="Expense")
self.v_category = tk.StringVar(value="Food")
# Form grid
fields = [
("Date:", ttk.Entry(form, textvariable=self.v_date, width=15)),
("Description:", ttk.Entry(form, textvariable=self.v_description, width=25)),
("Amount (£):", ttk.Entry(form, textvariable=self.v_amount, width=10)),
("Type:", ttk.Combobox(form, textvariable=self.v_type,
values=["Income", "Expense"], width=12)),
("Category:", ttk.Combobox(form, textvariable=self.v_category,
values=CATEGORIES, width=15)),
]
for row, (label_text, widget) in enumerate(fields):
ttk.Label(form, text=label_text).grid(row=row, column=0,
sticky="e", padx=5, pady=3)
widget.grid(row=row, column=1, sticky="ew", padx=5, pady=3)
form.columnconfigure(1, weight=1)
btn_frame = ttk.Frame(form)
btn_frame.grid(row=len(fields), column=0, columnspan=2, pady=8)
ttk.Button(btn_frame, text="Add Transaction",
command=self.add_transaction).pack(side="left", padx=5)
ttk.Button(btn_frame, text="Clear",
command=self.clear_form).pack(side="left", padx=5)
def _build_table(self, parent) -> None:
table_frame = ttk.LabelFrame(parent, text="Transactions", padding=5)
table_frame.pack(fill="both", expand=True, padx=5, pady=5)
cols = ("id", "date", "description", "amount", "type", "category")
self.tree = ttk.Treeview(table_frame, columns=cols, show="headings", height=15)
widths = {"id": 40, "date": 100, "description": 200,
"amount": 90, "type": 80, "category": 110}
for col in cols:
self.tree.heading(col, text=col.capitalize(),
command=lambda c=col: self._sort_by(c))
self.tree.column(col, width=widths[col],
anchor="e" if col == "amount" else "center" if col in ("id", "date", "type") else "w")
# Alternate row colours
self.tree.tag_configure("income", background="#e8f5e9")
self.tree.tag_configure("expense", background="#ffebee")
vsb = ttk.Scrollbar(table_frame, orient="vertical", command=self.tree.yview)
hsb = ttk.Scrollbar(table_frame, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self.tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
table_frame.rowconfigure(0, weight=1)
table_frame.columnconfigure(0, weight=1)
# Right-click menu to delete
self.context_menu = tk.Menu(self, tearoff=0)
self.context_menu.add_command(label="Delete Transaction",
command=self.delete_selected)
self.tree.bind("<Button-3>", self._show_context_menu)
def _build_chart(self, parent) -> None:
chart_frame = ttk.LabelFrame(parent, text="Spending by Category", padding=5)
chart_frame.pack(fill="both", expand=True, padx=5, pady=5)
self.fig, self.ax = plt.subplots(figsize=(4, 4))
self.fig.patch.set_facecolor("#f5f5f5")
self.canvas = FigureCanvasTkAgg(self.fig, master=chart_frame)
self.canvas.get_tk_widget().pack(fill="both", expand=True)
# ── Actions ───────────────────────────────────────────────────────────────
def add_transaction(self) -> None:
date_ = self.v_date.get().strip()
desc = self.v_description.get().strip()
amount_s = self.v_amount.get().strip()
type_ = self.v_type.get()
cat = self.v_category.get()
# Validation
errors = []
if not date_:
errors.append("Date is required.")
if not desc:
errors.append("Description is required.")
try:
amount = float(amount_s)
if amount <= 0:
errors.append("Amount must be positive.")
except ValueError:
errors.append("Amount must be a number.")
amount = 0.0
if errors:
messagebox.showerror("Validation Error", "\n".join(errors))
return
db_add(date_, desc, amount, type_, cat)
self.clear_form()
self.refresh()
def delete_selected(self) -> None:
selected = self.tree.selection()
if not selected:
return
if not messagebox.askyesno("Confirm", "Delete this transaction?"):
return
for item in selected:
transaction_id = self.tree.item(item)["values"][0]
db_delete(transaction_id)
self.refresh()
def clear_form(self) -> None:
self.v_date.set(date.today().isoformat())
self.v_description.set("")
self.v_amount.set("")
self.v_type.set("Expense")
self.v_category.set("Food")
def export_csv(self) -> None:
path = filedialog.asksaveasfilename(
defaultextension=".csv",
filetypes=[("CSV", "*.csv")],
initialfile=f"budget_{date.today()}.csv",
)
if not path:
return
rows = db_fetch_all()
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["ID", "Date", "Description", "Amount", "Type", "Category"])
writer.writerows(rows)
messagebox.showinfo("Export", f"Exported {len(rows)} rows to:\n{path}")
# ── Refresh ───────────────────────────────────────────────────────────────
def refresh(self) -> None:
self._refresh_summary()
self._refresh_table()
self._refresh_chart()
def _refresh_summary(self) -> None:
totals = db_summary()
income = totals["Income"]
expense = totals["Expense"]
balance = income - expense
self.lbl_income.config( text=f"Income: £{income:,.2f}")
self.lbl_expenses.config(text=f"Expenses: £{expense:,.2f}")
self.lbl_balance.config( text=f"Balance: £{balance:,.2f}",
foreground="#4caf50" if balance >= 0 else "#f44336")
def _refresh_table(self) -> None:
for item in self.tree.get_children():
self.tree.delete(item)
for row in db_fetch_all():
id_, date_, desc, amount, type_, cat = row
tag = "income" if type_ == "Income" else "expense"
sign = "+" if type_ == "Income" else "-"
self.tree.insert("", tk.END,
values=(id_, date_, desc, f"{sign}£{amount:.2f}", type_, cat),
tags=(tag,))
def _refresh_chart(self) -> None:
self.ax.clear()
data = db_spending_by_category()
if data:
labels = [row[0] for row in data]
amounts = [row[1] for row in data]
colors = COLORS[:len(labels)]
self.ax.pie(amounts, labels=labels, colors=colors,
autopct="%1.0f%%", startangle=90,
textprops={"fontsize": 8})
self.ax.set_title("Spending by Category", fontsize=10)
else:
self.ax.text(0.5, 0.5, "No expenses yet",
ha="center", va="center", transform=self.ax.transAxes)
self.canvas.draw()
def _sort_by(self, column: str) -> None:
"""Sort the treeview by a column."""
rows = [(self.tree.set(item, column), item)
for item in self.tree.get_children("")]
rows.sort(reverse=getattr(self, f"_sort_{column}_desc", False))
for index, (_, item) in enumerate(rows):
self.tree.move(item, "", index)
setattr(self, f"_sort_{column}_desc",
not getattr(self, f"_sort_{column}_desc", False))
def _show_context_menu(self, event) -> None:
item = self.tree.identify_row(event.y)
if item:
self.tree.selection_set(item)
self.context_menu.tk_popup(event.x_root, event.y_root)
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
app = BudgetApp()
app.mainloop()
Run it:
python budget_tracker.py
No installation needed — tkinter and sqlite3 ship with Python. You only need matplotlib:
pip install matplotlib
What You Built
A fully functional desktop app with:
- Menu bar — File -> Export CSV, Exit; Help -> About
- Summary bar — live income, expense, and balance totals in colour
- Transaction form — date, description, amount, type (Income/Expense), category; validation; clear button
- Treeview table — colour-coded rows (green for income, red for expenses), sortable columns, scrollbars, right-click context menu to delete
- Live pie chart — spending by category, embedded matplotlib, updates on every change
- SQLite persistence — all data survives closing the app
- CSV export — save all transactions to a file
What's Next?
Chapter 50 covers Packaging and Publishing — turning your Python scripts and apps into installable packages you can share on PyPI so anyone in the world can pip install your work.