Why input validation matters in forms
Forms are where users most often make mistakes: leaving required fields blank, typing letters into numeric fields, or entering values outside an allowed range. In a desktop GUI, validation is not only about rejecting bad input; it is also about guiding the user toward a correct entry with clear, immediate feedback.
In Tkinter, you can validate in three common ways, each useful in different situations:
- Validate on submit: accept any typing, then validate when the user clicks “Submit”. Simple and robust.
- Entry validation (validate/validatecommand): prevent invalid characters from being entered in the first place.
- Trace variables (trace_add): react to changes in a StringVar/IntVar in real time to show feedback, enable/disable buttons, etc.
Approach 1: Validate on submit (simple and reliable)
This approach lets users type freely, then checks all fields at once when they submit. It’s ideal when rules depend on multiple fields (for example, “password” and “confirm password”), or when you want to show a list of errors together.
Step-by-step pattern
- Read current field values.
- Normalize values (strip whitespace).
- Validate each rule and collect errors per field.
- Show feedback (inline messages, field highlighting).
- If no errors, proceed with saving/sending.
A practical pattern is to keep a dictionary of errors keyed by field name, so you can update the UI consistently.
def validate_on_submit(values):
errors = {}
name = values["name"].strip()
email = values["email"].strip()
age_text = values["age"].strip()
if not name:
errors["name"] = "Name is required."
if not email:
errors["email"] = "Email is required."
elif "@" not in email or "." not in email.split("@")[-1]:
errors["email"] = "Enter an email like name@example.com."
if not age_text:
errors["age"] = "Age is required."
else:
try:
age = int(age_text)
if not (13 <= age <= 120):
errors["age"] = "Age must be between 13 and 120."
except ValueError:
errors["age"] = "Age must be a whole number."
return errorsApproach 2: Prevent invalid typing with Entry validate/validatecommand
Sometimes you want to stop invalid characters immediately (for example, age should be digits only). Tkinter’s Entry supports validation via two options:
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
validate: when validation runs (common values:key,focusout).validatecommand: a callback that returnsTrueto allow the edit orFalseto reject it.
For numeric fields, a common rule is “allow empty while typing, otherwise digits only”. Allowing empty is important because users need to be able to delete and retype.
Digits-only Entry (allow empty)
import tkinter as tk
root = tk.Tk()
age_var = tk.StringVar()
def allow_digits_or_empty(proposed):
return proposed == "" or proposed.isdigit()
vcmd = (root.register(allow_digits_or_empty), "%P")
age_entry = tk.Entry(root, textvariable=age_var, validate="key", validatecommand=vcmd)
age_entry.pack()
root.mainloop()Key substitution codes you’ll use often:
%P: the proposed value if the edit is allowed%s: the current value before the edit%S: the inserted/deleted text%V: validation reason (key, focusin, focusout, etc.)
Tip: Use validate="focusout" when you want to allow free typing but still run a check when the user leaves the field (for example, email format).
Approach 3: Real-time checks with trace_add on variables
trace_add lets you react whenever a Tkinter variable changes. This is excellent for:
- Enabling/disabling the Submit button based on overall validity
- Showing inline “required” warnings as soon as a field becomes empty
- Updating helper text (for example, “Age must be 13–120”)
Basic trace_add example
import tkinter as tk
root = tk.Tk()
name_var = tk.StringVar()
status_var = tk.StringVar(value="Type your name")
def on_name_change(*_):
if name_var.get().strip():
status_var.set("Looks good")
else:
status_var.set("Name is required")
name_var.trace_add("write", on_name_change)
tk.Entry(root, textvariable=name_var).pack()
tk.Label(root, textvariable=status_var).pack()
root.mainloop()Note: Traces are great for feedback, but you should still validate on submit to ensure correctness (for example, if a field is modified programmatically, or if a rule depends on multiple fields).
User feedback patterns that work well
1) Inline error labels near each field
Place a small label under or beside each input. Update its text when validation fails. This is clearer than a single generic message because users can fix issues quickly.
2) Color the field background (or highlight)
Change the Entry background when invalid. Keep the color subtle and consistent. Also provide text feedback (color alone is not enough).
def set_entry_valid(entry, is_valid):
entry.configure(bg="white" if is_valid else "misty rose")3) Disable the Submit button until the form is valid
This reduces frustration by preventing a “Submit → error → fix → Submit” loop. Combine with trace-based checks so the button updates immediately.
submit_btn.configure(state="normal" if form_is_valid else "disabled")4) Show a form-level summary message (optional)
A short message at the top can help when multiple fields are wrong. Keep it brief, and still keep per-field messages.
Edge cases to handle explicitly
- Empty strings and whitespace: use
.strip()before checking required fields. - Numeric conversion: wrap
int()intry/exceptand provide a friendly message. - Ranges: check boundaries (for example, age 13–120) and explain the allowed range.
- Partial input while typing: if validating on each keystroke, allow intermediate states (empty, partial) and only mark “invalid” when appropriate.
- Email-like checks: for beginner forms, a simple rule is enough (contains one
@, has a dot in the domain part, no spaces). Avoid overly strict rules that reject valid addresses.
Exercise: Build a registration form with validation and clear feedback
You will build a small registration form with these fields:
- Full name (required)
- Email (required, email-like format)
- Age (required, whole number, range 13–120)
Feedback requirements:
- Inline error messages per field
- Invalid fields tinted
- Submit button disabled until all fields are valid
Step 1: Create state variables and the UI skeleton
import tkinter as tk
root = tk.Tk()
root.title("Registration")
name_var = tk.StringVar()
email_var = tk.StringVar()
age_var = tk.StringVar()
name_err = tk.StringVar(value="")
email_err = tk.StringVar(value="")
age_err = tk.StringVar(value="")
form_msg = tk.StringVar(value="")
main = tk.Frame(root, padx=12, pady=12)
main.grid(row=0, column=0, sticky="nsew")
tk.Label(main, text="Full name").grid(row=0, column=0, sticky="w")
name_entry = tk.Entry(main, textvariable=name_var, width=30)
name_entry.grid(row=1, column=0, sticky="we")
tk.Label(main, textvariable=name_err, fg="firebrick").grid(row=2, column=0, sticky="w")
tk.Label(main, text="Email").grid(row=3, column=0, sticky="w", pady=(8, 0))
email_entry = tk.Entry(main, textvariable=email_var, width=30)
email_entry.grid(row=4, column=0, sticky="we")
tk.Label(main, textvariable=email_err, fg="firebrick").grid(row=5, column=0, sticky="w")
tk.Label(main, text="Age").grid(row=6, column=0, sticky="w", pady=(8, 0))
age_entry = tk.Entry(main, textvariable=age_var, width=10)
age_entry.grid(row=7, column=0, sticky="w")
tk.Label(main, textvariable=age_err, fg="firebrick").grid(row=8, column=0, sticky="w")
submit_btn = tk.Button(main, text="Submit", state="disabled")
submit_btn.grid(row=9, column=0, sticky="we", pady=(10, 0))
tk.Label(main, textvariable=form_msg, fg="dark green").grid(row=10, column=0, sticky="w", pady=(8, 0))
main.columnconfigure(0, weight=1)
root.mainloop()Step 2: Add an Entry-level guard for age (digits only)
This prevents letters from being typed into the age field, while still allowing the user to clear the field.
def allow_digits_or_empty(proposed):
return proposed == "" or proposed.isdigit()
vcmd = (root.register(allow_digits_or_empty), "%P")
age_entry.configure(validate="key", validatecommand=vcmd)Step 3: Write validation helpers (field-level)
Keep each field’s validation small and focused. Return an error message string (empty string means valid).
def validate_name(text):
if not text.strip():
return "Full name is required."
return ""
def validate_email(text):
s = text.strip()
if not s:
return "Email is required."
if " " in s:
return "Email cannot contain spaces."
if s.count("@") != 1:
return "Enter an email like name@example.com."
local, domain = s.split("@")
if not local or not domain or "." not in domain:
return "Enter an email like name@example.com."
return ""
def validate_age(text):
s = text.strip()
if not s:
return "Age is required."
try:
age = int(s)
except ValueError:
return "Age must be a whole number."
if age < 13 or age > 120:
return "Age must be between 13 and 120."
return ""Step 4: Connect real-time feedback with trace_add
As the user types, update error labels, color fields, and the Submit button state.
def set_entry_valid(entry, is_valid):
entry.configure(bg="white" if is_valid else "misty rose")
def refresh_feedback(*_):
n_err = validate_name(name_var.get())
e_err = validate_email(email_var.get())
a_err = validate_age(age_var.get())
name_err.set(n_err)
email_err.set(e_err)
age_err.set(a_err)
set_entry_valid(name_entry, n_err == "")
set_entry_valid(email_entry, e_err == "")
set_entry_valid(age_entry, a_err == "")
all_ok = (n_err == "" and e_err == "" and a_err == "")
submit_btn.configure(state="normal" if all_ok else "disabled")
form_msg.set("")
name_var.trace_add("write", refresh_feedback)
email_var.trace_add("write", refresh_feedback)
age_var.trace_add("write", refresh_feedback)
refresh_feedback()Step 5: Validate again on submit and show a clear result
Even with real-time checks, validate on submit to ensure the final state is correct. If valid, show a success message (and in a real app, you would save the data).
def on_submit():
refresh_feedback()
n_err = name_err.get()
e_err = email_err.get()
a_err = age_err.get()
if n_err or e_err or a_err:
form_msg.set("Please fix the highlighted fields.")
return
form_msg.set("Registration saved.")
submit_btn.configure(command=on_submit)Full exercise code (assembled)
import tkinter as tk
root = tk.Tk()
root.title("Registration")
name_var = tk.StringVar()
email_var = tk.StringVar()
age_var = tk.StringVar()
name_err = tk.StringVar(value="")
email_err = tk.StringVar(value="")
age_err = tk.StringVar(value="")
form_msg = tk.StringVar(value="")
main = tk.Frame(root, padx=12, pady=12)
main.grid(row=0, column=0, sticky="nsew")
tk.Label(main, text="Full name").grid(row=0, column=0, sticky="w")
name_entry = tk.Entry(main, textvariable=name_var, width=30)
name_entry.grid(row=1, column=0, sticky="we")
tk.Label(main, textvariable=name_err, fg="firebrick").grid(row=2, column=0, sticky="w")
tk.Label(main, text="Email").grid(row=3, column=0, sticky="w", pady=(8, 0))
email_entry = tk.Entry(main, textvariable=email_var, width=30)
email_entry.grid(row=4, column=0, sticky="we")
tk.Label(main, textvariable=email_err, fg="firebrick").grid(row=5, column=0, sticky="w")
tk.Label(main, text="Age").grid(row=6, column=0, sticky="w", pady=(8, 0))
age_entry = tk.Entry(main, textvariable=age_var, width=10)
age_entry.grid(row=7, column=0, sticky="w")
tk.Label(main, textvariable=age_err, fg="firebrick").grid(row=8, column=0, sticky="w")
submit_btn = tk.Button(main, text="Submit", state="disabled")
submit_btn.grid(row=9, column=0, sticky="we", pady=(10, 0))
tk.Label(main, textvariable=form_msg, fg="dark green").grid(row=10, column=0, sticky="w", pady=(8, 0))
main.columnconfigure(0, weight=1)
def allow_digits_or_empty(proposed):
return proposed == "" or proposed.isdigit()
vcmd = (root.register(allow_digits_or_empty), "%P")
age_entry.configure(validate="key", validatecommand=vcmd)
def validate_name(text):
if not text.strip():
return "Full name is required."
return ""
def validate_email(text):
s = text.strip()
if not s:
return "Email is required."
if " " in s:
return "Email cannot contain spaces."
if s.count("@") != 1:
return "Enter an email like name@example.com."
local, domain = s.split("@")
if not local or not domain or "." not in domain:
return "Enter an email like name@example.com."
return ""
def validate_age(text):
s = text.strip()
if not s:
return "Age is required."
try:
age = int(s)
except ValueError:
return "Age must be a whole number."
if age < 13 or age > 120:
return "Age must be between 13 and 120."
return ""
def set_entry_valid(entry, is_valid):
entry.configure(bg="white" if is_valid else "misty rose")
def refresh_feedback(*_):
n_err = validate_name(name_var.get())
e_err = validate_email(email_var.get())
a_err = validate_age(age_var.get())
name_err.set(n_err)
email_err.set(e_err)
age_err.set(a_err)
set_entry_valid(name_entry, n_err == "")
set_entry_valid(email_entry, e_err == "")
set_entry_valid(age_entry, a_err == "")
all_ok = (n_err == "" and e_err == "" and a_err == "")
submit_btn.configure(state="normal" if all_ok else "disabled")
form_msg.set("")
def on_submit():
refresh_feedback()
if name_err.get() or email_err.get() or age_err.get():
form_msg.set("Please fix the highlighted fields.")
return
form_msg.set("Registration saved.")
submit_btn.configure(command=on_submit)
name_var.trace_add("write", refresh_feedback)
email_var.trace_add("write", refresh_feedback)
age_var.trace_add("write", refresh_feedback)
refresh_feedback()
root.mainloop()Suggested extensions (optional practice)
- Add a “Confirm email” field and validate that both emails match (best done on submit and via trace).
- Change validation timing: run email validation on
focusoutinstead of every keystroke to reduce noise. - Add a “Reset” button that clears variables and resets feedback.