Tkinter Basics: Windows, Buttons, and Inputs
The same loop, wearing different clothes
In this lesson
Tkinter makes desktop applications (windows with buttons, text boxes and labels) and it ships with Python, so there is nothing to install.
It looks like a completely different world from Pygame. It is not. There is still an event loop; Tkinter just runs it for you and calls your functions when something happens.
Explain it like I’m 5
Tkinter is the same waiting-and-responding loop as a game, except you never write the loop. You describe the buttons and say what each one should do.
Widgets, layout, and the loop you do not write
Three ideas cover most of Tkinter.
Widgets are the pieces: Label for text, Button for clicking, Entry for typing.
Layout decides where they go. .grid(row=, column=) arranges them in a table and is the one to learn. .pack() is quicker for simple stacks but harder to control.
The loop is root.mainloop(). It does exactly what your Pygame while loop did, waiting for events forever, but instead of you checking for them, it calls the function you attached to each widget. That inversion is the whole difference.
import tkinter as tk
def add_item():
"""Called by Tkinter whenever the button is clicked."""
item = entry.get().strip()
if not item:
status.config(text="type something first")
return
items.append(item)
entry.delete(0, tk.END)
status.config(text=f"{len(items)} item(s): {', '.join(items)}")
items = []
root = tk.Tk()
root.title("Shopping list")
tk.Label(root, text="Item:").grid(row=0, column=0, padx=8, pady=8)
entry = tk.Entry(root, width=24)
entry.grid(row=0, column=1, padx=8, pady=8)
tk.Button(root, text="Add", command=add_item).grid(row=0, column=2, padx=8)
status = tk.Label(root, text="nothing yet")
status.grid(row=1, column=0, columnspan=3, pady=8)
root.mainloop()
# A small window with a text box, an Add button and a status line. # Typing "milk" and clicking Add shows: 1 item(s): milk # Clicking Add with an empty box shows: type something first
Callbacks are just functions with state
A callback is a function you write and someone else calls. Every button gets one, and its job is always the same: change the state, then update what is displayed.
Which means — as with the game loop — the interesting part has nothing to do with the toolkit. Strip the widgets away and a Tkinter app is a dictionary of state plus a set of functions that modify it.
# The callback half of a GUI, with no Tkinter involved.
state = {"count": 0}
def on_click():
state["count"] += 1
return f"clicked {state['count']} times"
def on_reset():
state["count"] = 0
return "clicked 0 times"
print(on_click())
print(on_click())
print(on_click())
print(on_reset())
print("state:", state)
clicked 1 times
clicked 2 times
clicked 3 times
clicked 0 times
state: {'count': 0}
Write the callbacks for a running total with an undo button. add(amount) adds to the total and remembers the amount; undo() removes the most recent amount and subtracts it. Both return the new total as a message, and undo() on an empty history must say so rather than crashing.
state = {"total": 0, "history": []}
def add(amount):
# TODO: add to the total, remember the amount, return "total: N"
return ""
def undo():
# TODO: if there is nothing to undo, return "nothing to undo"
# TODO: otherwise remove the last amount, subtract it, return "total: N"
return ""
print(add(5))
print(add(3))
print(undo())
print(undo())
print(undo())
add does state["total"] += amount and state["history"].append(amount). undo checks if not state["history"]: first, then uses .pop() to take the last amount off and subtracts it. Guarding the empty case first is what stops the IndexError.
state = {"total": 0, "history": []}
def add(amount):
state["total"] += amount
state["history"].append(amount)
return f"total: {state['total']}"
def undo():
if not state["history"]:
return "nothing to undo"
last = state["history"].pop()
state["total"] -= last
return f"total: {state['total']}"
print(add(5))
print(add(3))
print(undo())
print(undo())
print(undo())
total: 5
total: 8
total: 5
total: 0
nothing to undo
Pygame or Tkinter?
They solve different problems and the choice is usually obvious once stated.
Tkinter is for applications: forms, buttons, lists, settings. It waits patiently and does nothing until the user acts. Reach for it when your program is a tool.
Pygame is for anything that moves on its own. It redraws continuously whether or not anyone touches the keyboard. Reach for it when time passes inside your program.
Underneath they are the same loop, which is why the last two lessons kept producing code you could test without either library. Learn that separation and the toolkit becomes a detail you can swap.
Common mistake: Writing command=my_function() with brackets
Brackets are how functions are normally called.
Pass the function itself: command=my_function. With brackets it runs once at startup and the button does nothing.
Common mistake: Putting code after root.mainloop()
It looks like a setup call that returns.
mainloop() blocks until the window closes. Anything after it waits until the app has already finished.
Common mistake: Mixing grid() and pack() in the same container
Both position widgets, so they look interchangeable.
They are different layout managers and mixing them in one parent freezes the window. Pick one per container.
Common mistake: Forgetting to keep a reference to a widget you want to change
Chaining .grid() onto the creation line is tidy, and returns None.
If you need to update it later, assign first and call .grid() on the next line.
What does root.mainloop() do?
It is Tkinter's version of the while loop you wrote by hand in Pygame.
Why is command=add_item written without brackets?
With brackets it is called once immediately and its return value is handed over instead.
When is Pygame the better choice than Tkinter?
Tkinter waits for the user; Pygame keeps running regardless. Time passing inside the program is the deciding factor.
Mini exercise (medium)
Write the callback logic for a simple login form, the part that would sit behind a Tkinter button. submit(username, password) should reject an empty username, reject a password under 8 characters, and otherwise accept. It returns a (ok, message) pair and records failed attempts in the state.
Have a go. Finish the code and press Run to see the result immediately, right on this page.
state = {"failures": 0}
def submit(username, password):
"""Return (ok, message). Count failed attempts in state."""
# TODO: reject an empty username
# TODO: reject a password shorter than 8 characters
# TODO: otherwise accept, welcoming the user by name
return False, ""
print(submit("", "whatever"))
print(submit("ada", "short"))
print(submit("ada", "correcthorse"))
print("failures:", state["failures"])
Check the conditions in order and return early from each. Return a tuple like (False, "username required") or (True, "welcome, ada"), and increment state["failures"] before each unsuccessful return.
state = {"failures": 0}
def submit(username, password):
"""Return (ok, message). Count failed attempts in state."""
if not username.strip():
state["failures"] += 1
return False, "username required"
if len(password) < 8:
state["failures"] += 1
return False, "password must be at least 8 characters"
return True, f"welcome, {username}"
print(submit("", "whatever"))
print(submit("ada", "short"))
print(submit("ada", "correcthorse"))
print("failures:", state["failures"])
(False, 'username required')
(False, 'password must be at least 8 characters')
(True, 'welcome, ada')
failures: 2
state["failures"] = 0
ok, msg = submit(" ", "longenoughpassword")
assert ok is False, "a username of only spaces is empty"
ok, msg = submit("sam", "12345678")
assert ok is True, "exactly 8 characters should be accepted"
assert state["failures"] == 1, "only the first call should have counted as a failure"
print("✓ Looks good!")