Python Tkinter Mainloop: A Practical, Modern Guide

A Tkinter window that freezes right after you click a button is a rite of passage. I’ve shipped that bug, watched the spinner stop, and realized I had blocked the very loop that makes the GUI breathe. The mainloop is not a formality; it’s the coordinator that decides when your widgets redraw, when callbacks fire, and whether your users feel in control. If you treat it as an afterthought, you get unresponsive windows, missed clicks, and timing glitches that are hard to debug.

What I want you to walk away with is a mental model you can use every day: how the event queue feeds the loop, how idle and timer callbacks fit in, why long tasks must yield, and how multi-window apps share one loop. I’ll show runnable examples, point out mistakes I still see in 2026 code reviews, and explain practical patterns that keep UI latency in a healthy range. If you build tools, dashboards, or quick internal utilities in Python, understanding the mainloop turns Tkinter from “works on my machine” into a dependable GUI framework.

The mainloop as a stage manager

I think of mainloop() as a stage manager in a theater. Actors (widgets) only move when the manager calls their cues. The manager watches a queue of events, calls the right handlers, and then checks again. If you walk on stage and start giving a speech that lasts two minutes, the manager can’t cue anyone else. That’s exactly what happens when you run a long task on the GUI thread.

When you call root.mainloop(), Tkinter hands control to the Tcl/Tk event loop. Your Python code doesn’t “run the loop” so much as it registers callbacks and then waits. The loop repeats: it waits for the next event, dispatches it, processes pending redraws, and then waits again. That pattern is simple but strict. Everything that touches widgets must be quick and must return control so the loop can keep turning.

If you remember one thing: the GUI thread should stay responsive. I recommend keeping individual callbacks under a few milliseconds when possible, and never above a few hundred milliseconds. Anything longer should be pushed to a background worker or broken into chunks scheduled with after().

The event queue and dispatch pipeline

Tkinter collects events in a queue. Each event has a type (mouse click, key press, window resize), a target (which widget), and metadata (mouse position, key symbol, modifiers). When the loop finds an event, it dispatches to bound handlers in an order that matters:

  • Widget-specific bindings (for the widget that received the event)
  • Class bindings (for all widgets of that class)
  • Application-level bindings (like bind_all)

You can also trigger events yourself with event_generate, which is useful in testing or when you want to decouple input from behavior.

Here is a compact example that shows event binding order and how callbacks are invoked. It’s runnable as-is.

import tkinter as tk

def report(origin):

def handler(event):

print(f"{origin} -> {event.type} on {event.widget}")

return handler

root = tk.Tk()

root.title("Event Order Demo")

btn = tk.Button(root, text="Click me")

btn.pack(padx=20, pady=20)

Widget binding

btn.bind("", report("widget"))

Class binding

root.bind_class("Button", "", report("class"))

Application binding

root.bind_all("", report("app"))

root.mainloop()

You’ll see the dispatch order in the terminal. Knowing this order helps you structure code without surprising interactions. If you add both a command on a Button and a binding, that command fires at a different point in the sequence. I usually keep button actions in command= and reserve bind for low-level input, which makes event flow easier to reason about.

Virtual events: a cleaner abstraction

Tkinter lets you define virtual events like <> or <>. I use these when a UI action should trigger behavior without binding to a specific widget. A menu item, a keyboard shortcut, and a toolbar button can all generate the same virtual event, and your handler doesn’t need to care which one fired it.

Here’s a small pattern I use in real apps:

import tkinter as tk

root = tk.Tk()

root.title("Virtual Event Demo")

status = tk.StringVar(value="Ready")

label = tk.Label(root, textvariable=status)

label.pack(padx=10, pady=10)

def on_save(event=None):

status.set("Save requested")

root.bind("<>", on_save)

menu = tk.Menu(root)

file_menu = tk.Menu(menu, tearoff=0)

filemenu.addcommand(label="Save", command=lambda: root.event_generate("<>"))

menu.addcascade(label="File", menu=filemenu)

root.config(menu=menu)

toolbar = tk.Frame(root)

tk.Button(toolbar, text="Save", command=lambda: root.event_generate("<>")).pack(side=tk.LEFT)

toolbar.pack(pady=10)

root.mainloop()

Virtual events make it easy to decouple input from actions and keep mainloop-friendly code paths centralized.

Timers, idle tasks, and cooperative scheduling

The event loop is great at waiting. It’s not great at doing long work. Tkinter gives you two key tools for scheduling work without blocking:

  • after(ms, callback) schedules a callback after a delay.
  • after_idle(callback) schedules a callback when the loop is idle.

Think of these as cooperative scheduling. You break work into chunks and schedule the next chunk, letting the loop process user input between chunks. This keeps the UI responsive even when you’re doing something expensive.

Here’s a timer-based example that updates a status label every second without blocking anything else:

import tkinter as tk

import time

def tick():

# Keep UI responsive by scheduling the next tick

now = time.strftime("%H:%M:%S")

clock_label.config(text=f"Time: {now}")

root.after(1000, tick)

root = tk.Tk()

root.title("Clock")

clock_label = tk.Label(root, text="Time: --:--:--")

clock_label.pack(padx=20, pady=20)

root.after(0, tick)

root.mainloop()

And here’s how I chunk a longer task (like processing a list of files) so the UI keeps breathing:

import tkinter as tk

def process_next():

try:

item = next(items_iter)

except StopIteration:

status.config(text="Done")

return

# Simulate work

status.config(text=f"Processing {item}")

# Schedule the next chunk so the UI stays responsive

root.after(10, process_next)

root = tk.Tk()

root.title("Chunked Work")

itemsiter = iter([f"file{i}.csv" for i in range(1, 301)])

status = tk.Label(root, text="Ready")

status.pack(padx=20, pady=20)

startbtn = tk.Button(root, text="Start", command=processnext)

start_btn.pack(padx=20, pady=10)

root.mainloop()

Notice that I’m not sleeping. Sleeping blocks the loop; after schedules work and returns immediately.

Cancelling timers cleanly

If you schedule repeating tasks, keep the timer ID so you can cancel it on pause or shutdown. This avoids dangling callbacks that fire after you think the app is idle.

import tkinter as tk

root = tk.Tk()

root.title("Timer Cancel")

counter = 0

job_id = None

label = tk.Label(root, text="0")

label.pack(padx=20, pady=10)

def tick():

global counter, job_id

counter += 1

label.config(text=str(counter))

job_id = root.after(500, tick)

def start():

global job_id

if job_id is None:

job_id = root.after(0, tick)

def stop():

global job_id

if job_id is not None:

root.aftercancel(jobid)

job_id = None

controls = tk.Frame(root)

controls.pack(pady=10)

tk.Button(controls, text="Start", command=start).pack(side=tk.LEFT, padx=5)

tk.Button(controls, text="Stop", command=stop).pack(side=tk.LEFT, padx=5)

root.mainloop()

Background work without freezing the UI

When tasks are CPU-heavy or involve I/O that could block, I push them off the GUI thread. The safe pattern is: do work in a background thread or process, then post results back to the mainloop using after().

A simple queue-based model works well for most cases:

import tkinter as tk

import threading

import queue

import time

result_queue = queue.Queue()

def worker():

# Simulate a heavy task

time.sleep(2)

result_queue.put("Report generated")

def start_work():

status.config(text="Working...")

thread = threading.Thread(target=worker, daemon=True)

thread.start()

poll_results()

def poll_results():

try:

message = resultqueue.getnowait()

except queue.Empty:

# Keep polling without blocking the UI

root.after(50, poll_results)

return

status.config(text=message)

root = tk.Tk()

root.title("Background Work")

status = tk.Label(root, text="Idle")

status.pack(padx=20, pady=10)

startbtn = tk.Button(root, text="Generate Report", command=startwork)

start_btn.pack(padx=20, pady=10)

root.mainloop()

A few rules I follow:

  • Only the main thread touches widgets. Don’t update labels from worker threads.
  • Use a queue or shared state with a lock to move data across threads.
  • Use after() to poll and update the UI.

For CPU-bound workloads, threads may not be enough because of the GIL. In those cases, I use multiprocessing or move the heavy logic to a subprocess with a clean IPC channel. The mainloop pattern stays the same: poll or listen and update UI from the main thread.

Progress updates for long tasks

Users forgive long operations if they can see progress. You can combine queues and a progress bar to show actual movement while keeping the UI responsive.

import tkinter as tk

import threading

import queue

import time

progress_queue = queue.Queue()

def worker(total):

for i in range(total):

time.sleep(0.02)

progress_queue.put(i + 1)

progress_queue.put("DONE")

def start():

status.config(text="Working...")

thread = threading.Thread(target=worker, args=(100,), daemon=True)

thread.start()

poll_progress()

def poll_progress():

try:

msg = progressqueue.getnowait()

except queue.Empty:

root.after(50, poll_progress)

return

if msg == "DONE":

status.config(text="Complete")

return

progress_var.set(msg)

root.after(10, poll_progress)

root = tk.Tk()

root.title("Progress Demo")

progress_var = tk.IntVar(value=0)

status = tk.Label(root, text="Idle")

status.pack(padx=10, pady=6)

bar = tk.Scale(root, from=0, to=100, orient=tk.HORIZONTAL, variable=progressvar, length=260)

bar.pack(padx=10, pady=6)

start_btn = tk.Button(root, text="Start", command=start)

start_btn.pack(padx=10, pady=6)

root.mainloop()

This approach scales well for local tasks and API-driven jobs because the UI never blocks on the worker.

Multiple windows, dialogs, and nested loops

Tkinter supports many windows, but you still get only one mainloop. I see people try to call mainloop() for every window, and that creates nested event loops that are hard to control. The correct pattern is: one Tk() root, many Toplevel windows.

Here’s a clean multi-window example:

import tkinter as tk

def open_settings():

settings = tk.Toplevel(root)

settings.title("Settings")

settings.geometry("320x160")

tk.Label(settings, text="Theme").pack(padx=10, pady=6)

tk.Entry(settings).pack(padx=10, pady=6)

tk.Button(settings, text="Close", command=settings.destroy).pack(pady=10)

root = tk.Tk()

root.title("Main Window")

root.geometry("360x200")

openbtn = tk.Button(root, text="Open Settings", command=opensettings)

open_btn.pack(pady=40)

root.mainloop()

If you need a modal dialog (block interaction with the main window), use grabset() and waitwindow() to create a controlled modal flow without another mainloop():

import tkinter as tk

def ask_name():

dialog = tk.Toplevel(root)

dialog.title("Name")

dialog.geometry("300x120")

dialog.grab_set() # Makes it modal

tk.Label(dialog, text="Enter your name").pack(pady=6)

entry = tk.Entry(dialog)

entry.pack(pady=6)

def submit():

name_label.config(text=f"Hello, {entry.get().strip() or ‘Guest‘}")

dialog.destroy()

tk.Button(dialog, text="OK", command=submit).pack(pady=6)

dialog.wait_window()

root = tk.Tk()

root.title("Modal Demo")

name_label = tk.Label(root, text="Hello")

name_label.pack(pady=20)

btn = tk.Button(root, text="Set Name", command=ask_name)

btn.pack(pady=10)

root.mainloop()

This keeps a single event loop while still letting you guide user flow.

Exiting cleanly: quit, destroy, and shutdown hooks

Closing the window ends the loop, but I prefer to be explicit. There are two paths:

  • root.quit() stops the event loop but does not destroy the widgets. The window may remain until you call destroy().
  • root.destroy() destroys all widgets and ends the loop.

If you need to clean up resources (close files, stop background threads, save settings), register a handler with protocol("WMDELETEWINDOW", handler).

import tkinter as tk

def on_close():

# Put cleanup logic here

print("Saving preferences...")

root.destroy()

root = tk.Tk()

root.title("Shutdown")

root.protocol("WMDELETEWINDOW", on_close)

root.mainloop()

I recommend centralizing cleanup in one place. That prevents dangling threads and makes your app shut down reliably in CI or when you bundle it with a launcher.

Responsiveness and frame budgets

GUI smoothness is mostly about timing. If your UI thread is tied up, redraws wait. For 60Hz displays, you have about 16ms per frame; at 30Hz, about 33ms. In practice, a Tkinter app feels responsive if callbacks usually complete in 10–15ms and rarely exceed 50–100ms. That’s why chunking and background work matter.

Here are practical steps I use:

  • Keep callbacks short; move heavy work to threads or processes.
  • Avoid time.sleep() in the GUI thread; replace with after().
  • Batch UI updates. If you’re updating many widgets, group those updates in one callback.
  • Use update_idletasks() sparingly. It forces pending redraws but can create re-entrancy problems if misused.

If you need animated UI, avoid trying to “paint every pixel.” Use coarse-grained updates every 16–33ms. A slider or progress bar update feels smooth even at 20–30fps if the changes are meaningful.

Debouncing and throttling input

The event loop can flood you with events: keypresses, drag motions, mouse movement. If you process each event fully, your UI will lag. Debounce (wait for a pause) or throttle (limit rate) to keep handlers light.

import tkinter as tk

root = tk.Tk()

root.title("Debounce Demo")

status = tk.StringVar(value="Type to search")

label = tk.Label(root, textvariable=status)

label.pack(padx=10, pady=10)

search_job = None

def on_key(event):

global search_job

if search_job is not None:

root.aftercancel(searchjob)

# Debounce: run search 300ms after last keypress

searchjob = root.after(300, runsearch)

def run_search():

status.set("Searching...")

# Simulate search

root.after(200, lambda: status.set("Search complete"))

entry = tk.Entry(root)

entry.pack(padx=10, pady=10)

entry.bind("", on_key)

root.mainloop()

Common mistakes I see in real projects

These are patterns I’ve debugged repeatedly. You should avoid them, and if you inherit a codebase with these issues, fix them early.

1) Calling mainloop() more than once

  • One root, one event loop. Use Toplevel for extra windows.

2) Long-running callbacks

  • File imports, API calls, image processing, PDF generation: push all of these to a worker.

3) Updating widgets from a worker thread

  • Tkinter isn’t thread-safe. Always update from the main thread with after().

4) Using time.sleep() in the UI thread

  • It blocks event processing. Use after() instead.

5) Swallowing errors in callbacks

  • Unhandled exceptions can silently break future event handling. Add logging and wrap risky code.

6) Re-entrancy traps with update()

  • update() processes events immediately and can cause handlers to run inside other handlers. I avoid it unless I’m building a tight UI testing loop. If you need a redraw, prefer update_idletasks() and still keep it rare.

7) Forgetting to cancel scheduled jobs

  • Timers can keep firing after a window is closed or a feature is disabled. Store after() IDs and cancel them.

8) Using global state without clear ownership

  • Globals make it hard to reason about updates and can cause inconsistent UI state. Prefer small state objects and explicit update functions.

Traditional patterns vs modern patterns

The event loop hasn’t changed, but how we structure apps in 2026 has. I now prefer patterns that make background work and UI updates explicit, and I use lightweight AI tooling to generate boilerplate only after I’ve set the architecture.

Topic

Traditional Approach

Modern Approach (2026) —

— Long tasks

Do work directly in the callback

Use worker threads/processes; post results with after() Timed updates

Manual loops with sleep()

after() scheduling with clear lifecycle control Dialogs

Multiple mainloop() calls

Toplevel with grabset() and waitwindow() UI state

Ad-hoc globals

Small state objects, explicit update functions Boilerplate

Copy/paste examples

Generate a starting scaffold with AI tools, then refine by hand

I still hand-review the core event flow. AI can draft the UI layout, but only you can validate how the loop behaves under load.

When Tkinter’s mainloop is the right fit—and when it isn’t

Tkinter remains a strong choice for internal tools, quick admin dashboards, and desktop utilities where cross-platform packaging matters more than pixel-perfect styling. The mainloop makes these apps straightforward as long as you respect the rules of the event loop.

I recommend Tkinter when:

  • You need a lightweight GUI without a web stack.
  • You want easy distribution with tools like PyInstaller or Briefcase.
  • Your UI is forms-heavy rather than animation-heavy.

I recommend a different toolkit when:

  • You need modern, highly animated interfaces.
  • You need strict accessibility or native look-and-feel on macOS and Windows beyond what Tk provides.
  • You need embedded web views or complex rendering pipelines.

Even if you move to another framework later, understanding the mainloop mindset will carry over. Most GUI frameworks are event-loop driven; the vocabulary changes, but the constraints are the same.

A complete example with real-world flow

Here’s a small but realistic app: a log viewer that polls a file, updates the UI without blocking, and lets the user pause. It demonstrates after(), cooperative scheduling, and simple state management.

import tkinter as tk

from pathlib import Path

LOG_PATH = Path("app.log")

class LogViewer:

def init(self, root: tk.Tk):

self.root = root

self.root.title("Log Viewer")

self.is_running = True

self.last_size = 0

self.poll_job = None

self.text = tk.Text(root, width=80, height=20)

self.text.pack(padx=10, pady=10)

controls = tk.Frame(root)

controls.pack(pady=6)

self.toggle_btn = tk.Button(controls, text="Pause", command=self.toggle)

self.toggle_btn.pack(side=tk.LEFT, padx=6)

self.status = tk.Label(controls, text="Watching")

self.status.pack(side=tk.LEFT, padx=6)

self.clear_btn = tk.Button(controls, text="Clear", command=self.clear)

self.clear_btn.pack(side=tk.LEFT, padx=6)

self.root.protocol("WMDELETEWINDOW", self.on_close)

self.schedule_poll(0)

def schedulepoll(self, delayms: int):

if self.poll_job is not None:

self.root.aftercancel(self.polljob)

self.polljob = self.root.after(delayms, self.poll)

def poll(self):

if not self.is_running:

self.schedule_poll(500)

return

if LOG_PATH.exists():

size = LOGPATH.stat().stsize

if size < self.last_size:

# Log rotated

self.last_size = 0

if size > self.last_size:

with LOG_PATH.open("r", encoding="utf-8", errors="replace") as f:

f.seek(self.last_size)

new_data = f.read()

self.text.insert(tk.END, new_data)

self.text.see(tk.END)

self.last_size = size

self.schedule_poll(500)

def toggle(self):

self.isrunning = not self.isrunning

self.togglebtn.config(text="Resume" if not self.isrunning else "Pause")

self.status.config(text="Paused" if not self.is_running else "Watching")

def clear(self):

self.text.delete("1.0", tk.END)

def on_close(self):

if self.poll_job is not None:

self.root.aftercancel(self.polljob)

self.root.destroy()

root = tk.Tk()

app = LogViewer(root)

root.mainloop()

This app is intentionally simple, but it hits the patterns you actually need: one mainloop, cooperative scheduling, and safe cleanup.

Mainloop lifecycle: startup, steady state, and shutdown

Thinking in phases helps you design the mainloop flow instead of retrofitting it later.

  • Startup: create widgets, wire bindings, and schedule the first timers with after(0, ...).
  • Steady state: the loop alternates between waiting and dispatching. It should remain responsive even under load.
  • Shutdown: cancel scheduled tasks, stop background workers, and destroy the root.

If your app “leaks” tasks or threads in steady state, you’ll notice it at shutdown because the process won’t exit. I treat a clean shutdown as a proof that my event flow is sane.

Error handling in callbacks

Tkinter is permissive: if a callback raises, it often prints a stack trace and keeps going, which means you might not notice broken functionality until later. I add lightweight error wrappers around risky callbacks and log the exception.

import tkinter as tk

import traceback

root = tk.Tk()

root.title("Safe Callbacks")

status = tk.StringVar(value="Ready")

def safe_call(fn):

def wrapper(args, *kwargs):

try:

return fn(args, *kwargs)

except Exception:

status.set("Error occurred")

traceback.print_exc()

return wrapper

def risky():

raise RuntimeError("Boom")

label = tk.Label(root, textvariable=status)

label.pack(padx=10, pady=10)

btn = tk.Button(root, text="Trigger", command=safe_call(risky))

btn.pack(padx=10, pady=10)

root.mainloop()

This isn’t perfect, but it prevents silent breakage and keeps the UI responsive even if a handler fails.

Integrating asyncio without fighting the mainloop

Tkinter and asyncio both want to own the event loop. The safe approach is to let Tkinter keep the mainloop and have it periodically run asyncio tasks. The pattern is: create an asyncio loop, run it in small slices via after(), and never block the GUI thread.

import tkinter as tk

import asyncio

root = tk.Tk()

root.title("Tk + asyncio")

status = tk.StringVar(value="Idle")

label = tk.Label(root, textvariable=status)

label.pack(padx=10, pady=10)

loop = asyncio.neweventloop()

asyncio.seteventloop(loop)

async def async_task():

status.set("Working...")

await asyncio.sleep(1.5)

status.set("Done")

def runasynciostep():

loop.stop()

loop.run_forever()

root.after(50, runasynciostep)

def start():

loop.createtask(asynctask())

root.after(50, runasynciostep)

btn = tk.Button(root, text="Run Async Task", command=start)

btn.pack(padx=10, pady=10)

root.mainloop()

This approach isn’t the only option, but it’s reliable. If you need heavy async I/O, consider a background thread running the asyncio loop and use a queue to pass results back to the GUI thread.

Measuring responsiveness without guesswork

If the UI feels sluggish, I measure. A simple pattern is to track the time between scheduled ticks and compare it to the expected interval. This gives you a rough “latency meter” for your mainloop.

import tkinter as tk

import time

root = tk.Tk()

root.title("Loop Latency")

label = tk.Label(root, text="-- ms")

label.pack(padx=10, pady=10)

last = time.perf_counter()

def measure():

global last

now = time.perf_counter()

delta_ms = (now - last) * 1000

last = now

label.config(text=f"Loop gap: {delta_ms:.1f} ms")

root.after(100, measure)

root.after(100, measure)

root.mainloop()

If you see gaps regularly spike above 100–200ms, something in your callbacks is blocking the loop.

Testing event-driven code

Testing GUIs is hard, but you can still test your event logic. I separate UI updates from application logic, and then test the logic directly. For UI integration tests, I use event_generate to simulate input and assert that state updates are correct.

Here’s a minimal pattern that I use for smoke tests:

import tkinter as tk

def build_app(root):

state = {"clicked": False}

def on_click(event=None):

state["clicked"] = True

btn = tk.Button(root, text="Click")

btn.pack()

btn.bind("", on_click)

return state, btn

root = tk.Tk()

state, btn = build_app(root)

btn.event_generate("")

root.update_idletasks()

assert state["clicked"] is True

root.destroy()

I keep these tests tiny and focused. If you maintain good separation between state and UI, most of your code is testable without a GUI harness.

Alternative approaches to the same problems

There are usually multiple ways to keep the mainloop responsive. I choose based on complexity and team skill level.

Problem

Approach A

Approach B

When I pick it

Slow I/O

Thread + queue

Async loop in background thread

Thread + queue is simplest

CPU-heavy

Multiprocessing

Subprocess with IPC

Multiprocessing for pure Python, subprocess for tools

Periodic UI updates

after() loop

after_idle() + state flags

after() for predictable cadence

Multi-window flow

Toplevel + grab

simpledialog helpers

Toplevel when you need custom UII keep the decision record short in the code, usually with a one-line comment near the handler or worker setup.

Production considerations for Tkinter apps

Once you move from toy scripts to real tools, the mainloop touches everything: performance, shutdown, logging, and updates. Here’s the checklist I follow before I ship:

  • Ensure a single Tk() root and a single mainloop().
  • Confirm all background work is isolated and UI updates are scheduled via after().
  • Cancel repeating timers on close to avoid late callbacks.
  • Log exceptions in callbacks and threads.
  • Add a minimal “responsive loop” monitor if the app is long-running.
  • Test shutdown with active background tasks to verify cleanup.

I also keep a small “health menu” in internal tools that shows loop latency and background job counts. It saves hours of debugging.

A practical debugging workflow

When a Tkinter app freezes, I walk through this checklist:

1) Identify the last UI action before the freeze. That’s usually the callback that blocks the loop.

2) Search for time.sleep() or long loops without after().

3) Check whether the callback is calling blocking I/O (network or disk).

4) Verify that background threads aren’t touching widgets directly.

5) Add a tiny periodic ticker to reveal if the event loop is still alive.

That’s often enough to pinpoint the issue. If not, I add logging at the start and end of callbacks. The gap between those logs will tell you where the loop got stuck.

A short mental model you can keep on your desk

I keep this summary as a mental sticky note:

  • The mainloop is a dispatcher, not a worker.
  • Callbacks must be fast or chunked.
  • Use after() for scheduling and after_cancel() for cleanup.
  • Keep widget access on the main thread.
  • One root, one loop, many windows.

If your code respects those rules, Tkinter feels boring—in the best possible way.

Closing thoughts

The mainloop is the heart of Tkinter. The moment you treat it as a real system—one that schedules, prioritizes, and requires cooperation—your GUI becomes stable and predictable. Every technique in this guide comes down to the same idea: never block the loop, always return control, and schedule work in a way the loop can digest.

I still love Tkinter for internal tools and quick utilities. It’s not flashy, but it’s honest. The mainloop is the contract. If you keep your side of it, Tkinter keeps its side too.

Scroll to Top