Free Ebook cover Desktop Apps with Tkinter: A Beginner’s Guide to Python GUIs

Desktop Apps with Tkinter: A Beginner’s Guide to Python GUIs

New course

10 pages

Input Validation and User Feedback in Tkinter Forms

Capítulo 8

Estimated reading time: 11 minutes

+ Exercise

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 errors

Approach 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 App

Download the app

  • validate: when validation runs (common values: key, focusout).
  • validatecommand: a callback that returns True to allow the edit or False to 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() in try/except and 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 focusout instead of every keystroke to reduce noise.
  • Add a “Reset” button that clears variables and resets feedback.

Now answer the exercise about the content:

In a Tkinter form, why should you still validate on submit even if you use trace_add for real-time feedback?

You are right! Congratulations, now go to the next page

You missed! Try again.

Real-time traces are great for immediate feedback, but you should still validate on submit to guarantee the final values are correct, including programmatic changes and multi-field rules.

Next chapter

Organizing a Maintainable Tkinter App: Frames, Reusable Components, and MVC-like Patterns

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.