Tkinter Guide
A2 Level — Unit 5: A Programmed Solution to a Problem (NEA)
A practical reference for building the user interface of your NEA in Python’s standard-library GUI toolkit, Tkinter. Every example is self-contained — paste it into a .py file and run it. The aim is to give you the building blocks you’ll need for the top-band Software Development criteria — a modular, validated, fit-for-purpose UI.
Use ttk (themed Tk) widgets wherever possible — they look modern, match the OS theme, and are what professional Python GUIs use. The plain tk.Button, tk.Label etc. still work, but ttk.Button, ttk.Label look much better and earn you the “fit for audience and purpose” marks.
Why Tkinter
- Ships with Python. Zero install required on the school computers —
import tkinterjust works. - Cross-platform. Same code runs on Windows, macOS and Linux.
- Well-documented. The mark scheme rewards “sound understanding of tools and techniques” — Tkinter has good official docs and ubiquitous tutorials.
- Enough for the NEA. It can do everything a typical NEA needs: forms, tables (Treeview), tabs, menus, dialogs.
For a project the scale of Nanthouse Surgery — multiple screens, forms, table-based listings — Tkinter is more than sufficient.
A minimal window
Tk()creates the main window (also called the root).mainloop()starts the event loop — the window stays open until the user closes it.geometry("WxH")sets the initial size;minsizeprevents the user from shrinking it.- Use
ttk.Label, nottk.Label, for the modern look.
Layout managers — use grid
Tkinter has three layout managers: pack, grid, place. Use grid for the NEA — it’s how spreadsheet- and form-style UIs are built and is the only one that scales to real layouts.
Key arguments:
| Argument | What it does |
|---|---|
row, column |
Which cell to place the widget in (0-indexed). |
padx, pady |
External padding (pixels) around the widget. |
ipadx, ipady |
Internal padding (makes the widget itself larger). |
sticky |
Compass directions (n, s, e, w, or combos like "nsew") — how the widget stretches inside its cell. "e" right-aligns; "nsew" fills the whole cell. |
columnspan, rowspan |
Span multiple columns/rows. |
Letting columns and rows resize:
root.columnconfigure(1, weight=1) # column 1 expands when the window is resized
root.rowconfigure(0, weight=1)
Frames — group related widgets
A Frame is a container that you place other widgets inside. Use frames to group related widgets and to create a screen-per-frame structure.
ttk.LabelFrame is the same as ttk.Frame but with a border and a title — useful for visually grouping a form section.
Common widgets
Each example below assumes import tkinter as tk and from tkinter import ttk at the top.
Label
Entry — single-line text input
Use StringVar (or IntVar, DoubleVar, BooleanVar) as the single source of truth for each field — much cleaner than calling entry.get() everywhere.
Button — runs a function on click
Pass a function, don’t call it. command=save_clicked (no parentheses) gives Tkinter the function to run later. command=save_clicked() would call it once immediately and pass the return value — a common bug.
To pass arguments, use lambda:
ttk.Button(root, text="Delete", command=lambda: delete_patient(123)).grid(...)
Combobox — drop-down list
Checkbutton — true/false
Radiobutton — pick one of several
All three radio buttons share the same variable — that’s what makes them mutually exclusive.
Listbox — scrollable list of items
For tabular data (multiple columns) use Treeview instead — see below.
Text — multi-line input
The strange "1.0" is Tkinter’s “line.column” index — line 1, character 0.
Spinbox — number with up/down arrows
Scale — slider
Treeview — the table widget
For displaying tabular data (lists of appointments, patients, invoices), ttk.Treeview is the right tool. It can show multiple columns, sort, support selection, and scroll.
A patients table with three columns and a scrollbar:
Key points:
show="headings"hides the default left-most “tree” column — you get a pure table.columns=(...)defines the internal names used in code. The displayed heading text is set viatree.heading(name, text=...).tree.insert(parent, position, values=...)adds a row.parent=""means “top level”; for nested/tree data you’d pass a parent item id.tree.column(name, width=..., anchor=...)sets the column’s pixel width and text alignment.
Reading the selected row
def on_select(event=None):
selected = tree.selection() # tuple of item ids
if not selected:
return
row = tree.item(selected[0], "values")
patient_id, name, dob = row
print(f"Selected: {patient_id} — {name}")
tree.bind("<<TreeviewSelect>>", on_select)
Adding, updating, deleting rows
# Insert
new_id = tree.insert("", "end", values=(4, "Diane Davies", "1968-01-30"))
# Update an existing row
tree.item(new_id, values=(4, "Diane D.", "1968-01-30"))
# Delete the selected row(s)
for item in tree.selection():
tree.delete(item)
# Clear everything (e.g. before reloading from the database)
for item in tree.get_children():
tree.delete(item)
Sorting columns by clicking the header
def sort_by(column, reverse):
rows = [(tree.set(item, column), item) for item in tree.get_children("")]
rows.sort(reverse=reverse)
for index, (_, item) in enumerate(rows):
tree.move(item, "", index)
# Re-bind so the next click sorts the other way
tree.heading(column, command=lambda: sort_by(column, not reverse))
for col in columns:
tree.heading(col, command=lambda c=col: sort_by(c, False))
Loading the Treeview from a database
import sqlite3
def reload_patients():
# Clear current rows
for item in tree.get_children():
tree.delete(item)
# Fetch and insert
with sqlite3.connect("surgery.db") as conn:
for row in conn.execute("SELECT patient_id, name, dob FROM patient ORDER BY name"):
tree.insert("", "end", values=row)
reload_patients()
Call reload_patients() again whenever you add/edit/delete to refresh the view.
Notebook — tabbed interfaces
Useful for an admin screen with several distinct sections (Patients · Appointments · Invoices · Reports):
Menus and menubar
tearoff=False removes the dashed line at the top of the menu (which would otherwise let users “tear off” the menu — looks dated).
Dialogs
The tkinter.messagebox, tkinter.filedialog and tkinter.simpledialog modules give you ready-made pop-ups:
Events and bindings
Most widgets fire events you can react to. Common ones:
| Event | When it fires |
|---|---|
<Button-1> |
Left mouse click |
<Double-Button-1> |
Double click |
<Return> |
Enter key pressed |
<KeyRelease> |
Any key released (useful for live search) |
<<TreeviewSelect>> |
A Treeview row was selected |
<<ComboboxSelected>> |
A Combobox value was chosen |
<FocusIn> / <FocusOut> |
Field gained / lost focus |
def on_enter(event):
print("Enter pressed; current value:", event.widget.get())
entry = ttk.Entry(root)
entry.bind("<Return>", on_enter)
The handler receives an event object with event.widget, event.keysym, event.x, event.y, etc.
Validation on Entry widgets
Validation is explicitly rewarded in every band of Software Development. Tkinter validates via validatecommand:
How it works:
root.register(fn)wraps your Python function so Tkinter can call it."%P"is a substitution code — Tkinter replaces it with the proposed new value before calling your function.validate="key"means “validate on every keystroke”.- Return
Trueto allow the change,Falseto reject it.
Other useful substitution codes: %S (the character being inserted), %V (the validation event), %W (the widget name).
Validation on submit (alternative pattern)
Live key-by-key validation can feel annoying for complex fields (dates, emails). Validate on submit instead:
def save():
errors = []
if not patient_name.get().strip():
errors.append("Patient name is required.")
try:
age = int(age_entry.get())
if not (0 <= age <= 120):
errors.append("Age must be between 0 and 120.")
except ValueError:
errors.append("Age must be a whole number.")
if errors:
messagebox.showerror("Please fix:", "\n".join(errors))
return
# …save to database…
The top-band approach uses both: cheap key-time checks for obvious issues (digits only, length limits) and submit-time checks for cross-field rules (“end date must be after start date”).
Scrollbars
Most “scrollable” widgets (Listbox, Text, Treeview, Canvas) need an explicit scrollbar:
The two command=… / yscrollcommand=… lines wire them together: when the user drags the scrollbar it calls text.yview; when the user scrolls with the wheel it calls vsb.set.
Connecting to SQLite
Tkinter pairs naturally with sqlite3 for NEA data persistence. Always use parameterised queries (? placeholders) — never string-format SQL — for both validation and security marks.
import sqlite3
from tkinter import messagebox
def add_patient(name, dob, postcode):
try:
with sqlite3.connect("surgery.db") as conn:
conn.execute(
"INSERT INTO patient (name, dob, postcode) VALUES (?, ?, ?)",
(name, dob, postcode),
)
return True
except sqlite3.IntegrityError as exc:
messagebox.showerror("Could not add patient", str(exc))
return False
After a successful insert/update/delete, reload the Treeview to show the change:
if add_patient(name_var.get(), dob_var.get(), postcode_var.get()):
reload_patients() # the function from the Treeview section
name_var.set("") # clear the form
dob_var.set("")
postcode_var.set("")
For top-band Software Development marks, the mark scheme rewards “custom exception classes” and “efficient exception handling”. Define your own exceptions for domain failures and catch them at the UI layer:
class ClashError(Exception):
"""Raised when an appointment slot is already booked."""
class PermissionDenied(Exception):
"""Raised when a user role isn't allowed to perform an action."""
Then in the UI:
try:
booking_service.book(patient_id, slot, doctor_id)
messagebox.showinfo("Booked", "Appointment booked.")
except ClashError:
messagebox.showwarning("Slot taken", "That slot is already booked.")
except PermissionDenied:
messagebox.showerror("Not allowed", "Receptionists cannot book online consultations.")
Structuring a real NEA app
For anything bigger than a single form, don’t put everything in one giant script. The top-band Software Development criteria specifically reward “modular structure” and “minimised use of global variables”. A class-per-screen layout is the cleanest approach:
import tkinter as tk
from tkinter import ttk
class App(tk.Tk):
"""The main application window — controls which screen is shown."""
def __init__(self):
super().__init__()
self.title("Nanthouse Surgery — Booking System")
self.geometry("1000x650")
self.minsize(800, 500)
# A single container frame; each screen is a child Frame we raise to the top.
container = ttk.Frame(self)
container.pack(fill="both", expand=True)
container.rowconfigure(0, weight=1)
container.columnconfigure(0, weight=1)
self.frames = {}
for ScreenClass in (LoginScreen, PatientsScreen, AppointmentsScreen):
frame = ScreenClass(container, controller=self)
self.frames[ScreenClass.__name__] = frame
frame.grid(row=0, column=0, sticky="nsew")
self.show("LoginScreen")
def show(self, name):
self.frames[name].tkraise()
class LoginScreen(ttk.Frame):
def __init__(self, parent, controller):
super().__init__(parent, padding=20)
self.controller = controller
ttk.Label(self, text="Log in", font=("Segoe UI", 18, "bold")).grid(row=0, column=0, columnspan=2, pady=12)
ttk.Label(self, text="Username:").grid(row=1, column=0, sticky="e")
ttk.Entry(self).grid(row=1, column=1)
ttk.Label(self, text="Password:").grid(row=2, column=0, sticky="e")
ttk.Entry(self, show="*").grid(row=2, column=1)
ttk.Button(self, text="Log in",
command=lambda: controller.show("PatientsScreen")
).grid(row=3, column=1, pady=10, sticky="e")
class PatientsScreen(ttk.Frame):
def __init__(self, parent, controller):
super().__init__(parent, padding=20)
ttk.Label(self, text="Patients", font=("Segoe UI", 16, "bold")).pack(anchor="w")
# …Treeview, search box, add/edit/delete buttons here…
class AppointmentsScreen(ttk.Frame):
def __init__(self, parent, controller):
super().__init__(parent, padding=20)
ttk.Label(self, text="Appointments", font=("Segoe UI", 16, "bold")).pack(anchor="w")
if __name__ == "__main__":
App().mainloop()
What this pattern earns you:
- Modular structure — each screen is its own class in its own (logical) file.
- No globals — every screen receives
controlleras a parameter and uses it to switch screens. - Self-documenting — class names match the screens the user sees.
- Easy to extend — adding a screen is one new class plus one line in
App.__init__.
For a real NEA, split the classes into separate modules:
booking_app/
├── main.py # creates App() and runs mainloop
├── app.py # App class
├── screens/
│ ├── login.py
│ ├── patients.py
│ ├── appointments.py
│ └── invoices.py
├── services/
│ ├── booking.py # business logic (clash detection, etc.)
│ └── auth.py # who can do what
├── db/
│ ├── schema.sql
│ └── repository.py # all SQL lives here
└── exceptions.py # ClashError, PermissionDenied, etc.
Tying it back to the mark scheme: this organisation directly demonstrates “well-structured and modular”, “sound understanding of tools and techniques”, and “fully exploited the programming facilities of the language” — the band 4 criteria on the Software Development page.
A worked mini-screen
Putting everything together — a patients screen with a Treeview, an entry form, validation, and SQLite. Save as patients.py and run.
Here’s what it looks like:
import sqlite3
import tkinter as tk
from tkinter import ttk, messagebox
DB_PATH = "surgery.db"
def init_db():
with sqlite3.connect(DB_PATH) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS patient (
patient_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
dob TEXT NOT NULL,
postcode TEXT NOT NULL
)
""")
class PatientsScreen(ttk.Frame):
def __init__(self, master):
super().__init__(master, padding=12)
self.pack(fill="both", expand=True)
# --- Form ---
form = ttk.LabelFrame(self, text="Patient details", padding=10)
form.grid(row=0, column=0, sticky="ew")
form.columnconfigure(1, weight=1)
self.name_var = tk.StringVar()
self.dob_var = tk.StringVar()
self.postcode_var = tk.StringVar()
ttk.Label(form, text="Name:").grid(row=0, column=0, sticky="e", padx=4, pady=2)
ttk.Entry(form, textvariable=self.name_var, width=30).grid(row=0, column=1, sticky="ew")
ttk.Label(form, text="DOB (yyyy-mm-dd):").grid(row=1, column=0, sticky="e", padx=4, pady=2)
ttk.Entry(form, textvariable=self.dob_var, width=12).grid(row=1, column=1, sticky="w")
ttk.Label(form, text="Postcode:").grid(row=2, column=0, sticky="e", padx=4, pady=2)
ttk.Entry(form, textvariable=self.postcode_var, width=10).grid(row=2, column=1, sticky="w")
ttk.Button(form, text="Add patient", command=self.add).grid(row=3, column=1, sticky="e", pady=6)
# --- Treeview ---
cols = ("patient_id", "name", "dob", "postcode")
self.tree = ttk.Treeview(self, columns=cols, show="headings", height=12)
for c, w in zip(cols, (60, 250, 110, 100)):
self.tree.heading(c, text=c.replace("_", " ").title())
self.tree.column(c, width=w, anchor="w" if c == "name" else "center")
vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview)
self.tree.configure(yscrollcommand=vsb.set)
self.tree.grid(row=1, column=0, sticky="nsew", pady=(12, 0))
vsb.grid (row=1, column=1, sticky="ns", pady=(12, 0))
self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1)
ttk.Button(self, text="Delete selected", command=self.delete_selected).grid(row=2, column=0, sticky="e", pady=6)
self.reload()
# --- Validation ---
def _validate(self):
errors = []
if not self.name_var.get().strip():
errors.append("Name is required.")
dob = self.dob_var.get().strip()
if len(dob) != 10 or dob[4] != "-" or dob[7] != "-":
errors.append("DOB must be in yyyy-mm-dd format.")
if not self.postcode_var.get().strip():
errors.append("Postcode is required.")
if errors:
messagebox.showerror("Please fix:", "\n".join(errors))
return False
return True
# --- Actions ---
def add(self):
if not self._validate():
return
try:
with sqlite3.connect(DB_PATH) as conn:
conn.execute(
"INSERT INTO patient (name, dob, postcode) VALUES (?, ?, ?)",
(self.name_var.get().strip(),
self.dob_var.get().strip(),
self.postcode_var.get().strip()),
)
except sqlite3.Error as exc:
messagebox.showerror("Database error", str(exc))
return
self.name_var.set("")
self.dob_var.set("")
self.postcode_var.set("")
self.reload()
def delete_selected(self):
selection = self.tree.selection()
if not selection:
return
if not messagebox.askyesno("Confirm", "Delete the selected patient?"):
return
patient_id = self.tree.item(selection[0], "values")[0]
with sqlite3.connect(DB_PATH) as conn:
conn.execute("DELETE FROM patient WHERE patient_id = ?", (patient_id,))
self.reload()
def reload(self):
for item in self.tree.get_children():
self.tree.delete(item)
with sqlite3.connect(DB_PATH) as conn:
for row in conn.execute(
"SELECT patient_id, name, dob, postcode FROM patient ORDER BY name"
):
self.tree.insert("", "end", values=row)
if __name__ == "__main__":
init_db()
root = tk.Tk()
root.title("Patients")
root.geometry("700x500")
PatientsScreen(root)
root.mainloop()
What this single file demonstrates:
- Class-based modular structure.
ttkwidgets throughout — modern themed look.- Grid layout with
columnconfigure/rowconfigureso things resize properly. - Treeview with columns, scrollbar, and selection.
- Form with
StringVardata binding. - Submit-time validation with a clear error message.
- Parameterised SQL (no SQL injection).
- Exception handling with a user-facing error dialog.
- Refresh-after-write pattern for the Treeview.
Use this as the skeleton for every screen in your NEA — duplicate the class, change the columns and the SQL, and you have your next screen.