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 tkinter just 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

import tkinter as tk
from tkinter import ttk

root = tk.Tk()
root.title("Nanthouse Surgery — Booking System")
root.geometry("900x600")   # width x height in pixels
root.minsize(700, 500)     # don't let the user shrink past this

ttk.Label(root, text="Welcome to the booking system",
          font=("Segoe UI", 14)).pack(pady=20)

root.mainloop()
Nanthouse Surgery — Booking System
Welcome to the booking system
  • 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; minsize prevents the user from shrinking it.
  • Use ttk.Label, not tk.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.

import tkinter as tk
from tkinter import ttk

root = tk.Tk()
root.title("Patient Registration")

ttk.Label(root, text="First name:").grid(row=0, column=0, padx=8, pady=4, sticky="e")
ttk.Entry(root, width=30).grid(row=0, column=1, padx=8, pady=4)

ttk.Label(root, text="Last name:").grid(row=1, column=0, padx=8, pady=4, sticky="e")
ttk.Entry(root, width=30).grid(row=1, column=1, padx=8, pady=4)

ttk.Label(root, text="Date of birth:").grid(row=2, column=0, padx=8, pady=4, sticky="e")
ttk.Entry(root, width=12).grid(row=2, column=1, padx=8, pady=4, sticky="w")

ttk.Button(root, text="Save").grid(row=3, column=1, pady=12, sticky="e")

root.mainloop()
Patient Registration
First name:
Jane
Last name:
Smith
Date of birth:
1985-03-12
Save

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)

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.

import tkinter as tk
from tkinter import ttk

root = tk.Tk()
root.title("Appointment booking")

# Header frame
header = ttk.Frame(root, padding=10)
header.grid(row=0, column=0, sticky="ew")
ttk.Label(header, text="New Appointment",
          font=("Segoe UI", 16, "bold")).pack(side="left")

# Form frame
form = ttk.LabelFrame(root, text="Patient & slot", padding=10)
form.grid(row=1, column=0, padx=10, pady=10, sticky="nsew")

ttk.Label(form, text="Patient:").grid(row=0, column=0, sticky="e")
ttk.Entry(form).grid(row=0, column=1, sticky="ew")
ttk.Label(form, text="Doctor:").grid(row=1, column=0, sticky="e")
ttk.Entry(form).grid(row=1, column=1, sticky="ew")
form.columnconfigure(1, weight=1)

# Button bar
buttons = ttk.Frame(root, padding=10)
buttons.grid(row=2, column=0, sticky="e")
ttk.Button(buttons, text="Cancel").pack(side="right", padx=4)
ttk.Button(buttons, text="Save").pack(side="right", padx=4)

root.columnconfigure(0, weight=1)
root.rowconfigure(1, weight=1)
root.mainloop()
Appointment booking
New Appointment
Patient & slot
Patient:
Jane Smith
Doctor:
Dr Lewis
Save
Cancel

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

ttk.Label(root, text="Patient ID:").grid(row=0, column=0)
Patient ID:

Entry — single-line text input

patient_name = tk.StringVar()                # holds the value
entry = ttk.Entry(root, textvariable=patient_name, width=30)
entry.grid(row=0, column=1)

# Read or set programmatically:
value = patient_name.get()
patient_name.set("Jane Smith")
Jane Smith

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

def save_clicked():
    print("Save was clicked, patient =", patient_name.get())

ttk.Button(root, text="Save", command=save_clicked).grid(row=1, column=1)
Save

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

appt_type = tk.StringVar(value="in_surgery")
ttk.Combobox(
    root,
    textvariable=appt_type,
    values=["in_surgery", "online", "therapy"],
    state="readonly",                  # user can only pick, not type
).grid(row=0, column=1)
in_surgery

Checkbutton — true/false

sms_consent = tk.BooleanVar(value=False)
ttk.Checkbutton(
    root,
    text="Patient consents to SMS reminders",
    variable=sms_consent,
).grid(row=0, column=0, columnspan=2, sticky="w")
Patient consents to SMS reminders
Send paper letters by post

Radiobutton — pick one of several

gender = tk.StringVar(value="F")
ttk.Radiobutton(root, text="Female", variable=gender, value="F").grid(row=0, column=0)
ttk.Radiobutton(root, text="Male",   variable=gender, value="M").grid(row=0, column=1)
ttk.Radiobutton(root, text="Other",  variable=gender, value="X").grid(row=0, column=2)
Female
Male
Other

All three radio buttons share the same variable — that’s what makes them mutually exclusive.

Listbox — scrollable list of items

listbox = tk.Listbox(root, height=10, exportselection=False)
listbox.grid(row=0, column=0)

for name in ["Alice", "Bob", "Charlie", "Diane"]:
    listbox.insert(tk.END, name)

# Read the selected item:
selected = listbox.curselection()       # tuple of indices
if selected:
    print(listbox.get(selected[0]))
Alice
Bob
Charlie
Diane

For tabular data (multiple columns) use Treeview instead — see below.

Text — multi-line input

notes = tk.Text(root, width=40, height=5, wrap="word")
notes.grid(row=0, column=0)

# Read / write:
text_value = notes.get("1.0", tk.END).strip()   # line 1, char 0 → end
notes.delete("1.0", tk.END)
notes.insert("1.0", "Initial consultation notes…")
Initial consultation notes… Patient reports headaches lasting 3+ days. No prior history of migraines. Recommended: follow-up in 7 days, hydration log.

The strange "1.0" is Tkinter’s “line.column” index — line 1, character 0.

Spinbox — number with up/down arrows

quantity = tk.IntVar(value=1)
ttk.Spinbox(root, from_=1, to=20, textvariable=quantity, width=5).grid(row=0, column=0)
3

Scale — slider

duration = tk.IntVar(value=30)
ttk.Scale(root, from_=10, to=90, orient="horizontal", variable=duration).grid(row=0, column=0)

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:

import tkinter as tk
from tkinter import ttk

root = tk.Tk()
root.title("Patients")
root.geometry("700x400")

# Frame to hold treeview + scrollbar together
container = ttk.Frame(root, padding=10)
container.pack(fill="both", expand=True)

# Create the treeview
columns = ("patient_id", "name", "dob")
tree = ttk.Treeview(container, columns=columns,
                    show="headings", height=15)

# Column headings (what the user sees)
tree.heading("patient_id", text="ID")
tree.heading("name", text="Name")
tree.heading("dob", text="Date of birth")

# Column widths and alignment
tree.column("patient_id", width=60, anchor="center")
tree.column("name", width=300, anchor="w")
tree.column("dob", width=120, anchor="center")

# Vertical scrollbar
vsb = ttk.Scrollbar(container, orient="vertical", command=tree.yview)
tree.configure(yscrollcommand=vsb.set)

tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
container.columnconfigure(0, weight=1)
container.rowconfigure(0, weight=1)

# Insert some rows
sample = [
    (1, "Alice Adams",   "1985-03-12"),
    (2, "Bob Bryn",      "1972-11-04"),
    (3, "Charlie Cadog", "1990-07-22"),
]
for row in sample:
    tree.insert("", "end", values=row)

root.mainloop()
Patients
ID
Name
Date of birth
1
Alice Adams
1985-03-12
2
Bob Bryn
1972-11-04
3
Charlie Cadog
1990-07-22

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 via tree.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):

import tkinter as tk
from tkinter import ttk

root = tk.Tk()
root.title("Admin")
root.geometry("800x500")

notebook = ttk.Notebook(root)
notebook.pack(fill="both", expand=True, padx=10, pady=10)

# Each tab is a Frame
patients_tab     = ttk.Frame(notebook, padding=10)
appointments_tab = ttk.Frame(notebook, padding=10)
invoices_tab     = ttk.Frame(notebook, padding=10)

notebook.add(patients_tab,     text="Patients")
notebook.add(appointments_tab, text="Appointments")
notebook.add(invoices_tab,     text="Invoices")

ttk.Label(patients_tab,     text="Patient list goes here").pack()
ttk.Label(appointments_tab, text="Appointments calendar goes here").pack()
ttk.Label(invoices_tab,     text="Invoice list goes here").pack()

root.mainloop()
Admin
Patients
Appointments
Invoices
Patient list goes here
import tkinter as tk
from tkinter import ttk, messagebox

root = tk.Tk()

menubar = tk.Menu(root)

file_menu = tk.Menu(menubar, tearoff=False)
file_menu.add_command(label="New patient…", command=lambda: print("new"))
file_menu.add_command(label="Open backup…", command=lambda: print("open"))
file_menu.add_separator()
file_menu.add_command(label="Quit", command=root.quit)
menubar.add_cascade(label="File", menu=file_menu)

help_menu = tk.Menu(menubar, tearoff=False)
help_menu.add_command(label="About",
    command=lambda: messagebox.showinfo("About", "Booking System v1.0"))
menubar.add_cascade(label="Help", menu=help_menu)

root.config(menu=menubar)
root.mainloop()
Booking System
…rest of app…

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:

from tkinter import messagebox, filedialog, simpledialog

# Information / warning / error
messagebox.showinfo("Saved", "Patient record saved successfully.")
messagebox.showwarning("Slot taken", "That slot is already booked.")
messagebox.showerror("Database error", "Could not connect to the database.")

# Yes/No question — returns True/False
if messagebox.askyesno("Confirm", "Delete this patient permanently?"):
    delete_patient()

# OK/Cancel — returns True/False
if messagebox.askokcancel("Quit", "Are you sure you want to quit?"):
    root.destroy()

# Ask the user to pick a file
path = filedialog.askopenfilename(
    title="Choose a CSV",
    filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
)

# Ask for a string / number
name = simpledialog.askstring("New therapist", "Therapist name:")
rate = simpledialog.askfloat("Rate", "Hourly rate (£):", minvalue=0)
Saved
i
Patient record saved successfully.
OK
Confirm
?
Delete this patient permanently?
No
Yes

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:

import tkinter as tk
from tkinter import ttk, messagebox

root = tk.Tk()

def is_valid_age(proposed):
    """Allow empty string (so the user can clear it) or a number 0-120."""
    if proposed == "":
        return True
    if not proposed.isdigit():
        return False
    return 0 <= int(proposed) <= 120

vcmd = (root.register(is_valid_age), "%P")     # %P = the proposed new value

ttk.Label(root, text="Age:").grid(row=0, column=0)
age_entry = ttk.Entry(root, validate="key", validatecommand=vcmd, width=5)
age_entry.grid(row=0, column=1)

root.mainloop()
Age:
42
✓ accepted
Age:
abc
✗ key rejected

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 True to allow the change, False to 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:

text = tk.Text(root, width=40, height=10, wrap="word")
vsb  = ttk.Scrollbar(root, orient="vertical", command=text.yview)
text.configure(yscrollcommand=vsb.set)

text.grid(row=0, column=0, sticky="nsew")
vsb.grid (row=0, column=1, sticky="ns")
root.columnconfigure(0, weight=1)
root.rowconfigure(0, weight=1)
Patient reports symptoms for 3+ days. Recommended: follow-up consultation. Blood test ordered (CBC, vitamin D). Notes from previous visit attached. Allergy: penicillin (verified 2022). Next of kin notified per consent form.

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 controller as 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:

Patients
Patient details
Name:
Diane Davies
DOB (yyyy-mm-dd):
1968-01-30
Postcode:
SA12 7AB
Add patient
ID
Name
DOB
Postcode
1
Alice Adams
1985-03-12
SA1 4PQ
2
Bob Bryn
1972-11-04
SA2 9XR
3
Charlie Cadog
1990-07-22
SA1 3KL
Delete selected
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.
  • ttk widgets throughout — modern themed look.
  • Grid layout with columnconfigure / rowconfigure so things resize properly.
  • Treeview with columns, scrollbar, and selection.
  • Form with StringVar data 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.