You're building a Tkinter app that talks to a REST backend β one that doesn't exist yet, or one you'd rather not hammer while you iterate on a list view. Tkinter adds two problems that bite almost every beginner (and plenty of veterans):
requests.get() in a button's command= and the window stops repainting until the network comes back ("Not Responding" on Windows);label.config() from that thread appears to workβ¦ right up until it throws the infamous RuntimeError: main thread is not in main loop.This guide proves both with runnable code, shows the pattern that fixes both at once (worker thread + queue.Queue + after() polling), builds one frame with all four UI branches (loading / error / empty / data) against a hosted mock API, flips every branch with query params instead of code edits, and finishes with plain-pytest tests that exercise the real fetch path β no pytest plugin needed. Every snippet below was run verbatim with Python 3.12 + Tk 8.6 against the live endpoints on this page before publishing β the printed outputs shown are from those runs.
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
30 seeded products with id, name, price, category, inStock, rating β plus orders, customers and reviews collections, full CRUD, filtering, sorting and pagination.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects -H "content-type: application/json" -d '{"preset":"ecommerce"}'urllib.request instead of requests? Set a User-Agent header β Python's default urllib UA is blocked at the CDN edge platform-wide (a 403 that confuses everyone, including pandas users). requests works out of the box; every snippet here uses it.requests.get() in a button command freezes your windowDon't take it on faith β measure it. This script starts a 100 ms heartbeat with after(), then does a blocking fetch against an endpoint that takes 3 seconds to answer (?mock_delay=3000 simulates the slow backend), and counts how many heartbeats ran during the fetch:
import tkinter as tk, time, requests
API = "https://mockbird.mockbird.workers.dev/m/demo"
root = tk.Tk()
ticks = []
def tick():
ticks.append(time.monotonic())
root.after(100, tick)
root.after(100, tick)
def load(): # imagine this is command= on a button
t0 = time.monotonic()
r = requests.get(f"{API}/products?limit=5&mock_delay=3000")
dur = time.monotonic() - t0
during = len([t for t in ticks if t > t0])
print(f"fetch {r.status_code} took {dur:.2f}s; timer ticks during fetch: {during}")
root.after(200, root.destroy)
root.after(500, load)
root.mainloop()
Output from the run for this guide:
fetch 200 took 3.94s; timer ticks during fetch: 0
Zero. For 3.9 seconds no after() callback fired, no widget repainted, no click was processed. mainloop() is a single thread doing everything; while requests.get() waits on a socket, that thread does nothing else. Any network call in a command= handler freezes the entire app for the duration β which on a flaky network can be 30 seconds.
RuntimeError: main thread is not in main loop"The obvious fix is a thread. The obvious bug is updating widgets from that thread. What makes this trap nasty is that it often doesn't fail immediately β we reproduced both faces of it in one script:
import tkinter as tk, threading, time, requests
API = "https://mockbird.mockbird.workers.dev/m/demo"
root = tk.Tk()
label = tk.Label(root, text="loading..."); label.pack()
def worker():
r = requests.get(f"{API}/products?limit=3")
label.config(text=f"got {len(r.json())} products") # WRONG: Tk call from a thread
print("label.config from thread did NOT raise (this time)")
threading.Thread(target=worker, daemon=True).start()
root.after(2500, root.destroy)
root.mainloop()
# ...and the same call again, after mainloop has exited:
def late():
time.sleep(0.3)
label.config(text="late")
threading.Thread(target=late).start()
Output from the run for this guide:
label.config from thread did NOT raise (this time)
RuntimeError: main thread is not in main loop
That's the whole story of this error in two lines: while mainloop() happens to be running, a cross-thread widget call may sneak through on builds where Tcl serializes it β so your app "works on my machine". The moment timing shifts (window closing, loop busy, different Tcl build), you get RuntimeError: main thread is not in main loop β classically at shutdown, from a thread that outlived the window. The CPython docs are blunt: Tkinter isn't thread-safe. The rule that always holds: threads may talk to the network, only the main thread may talk to Tk.
queue.Queue + after() pollingThe worker thread does the request and drops the result into a queue.Queue (which is thread-safe). The main thread polls the queue with after() and does all widget work. Same 3-second endpoint as section 2:
import tkinter as tk, threading, queue, time, requests
API = "https://mockbird.mockbird.workers.dev/m/demo"
root = tk.Tk()
results = queue.Queue()
ticks = []
def tick():
ticks.append(time.monotonic())
root.after(100, tick)
root.after(100, tick)
def fetch_products(): # runs in a thread: network ONLY, no Tk
try:
r = requests.get(f"{API}/products?limit=5&mock_delay=3000", timeout=10)
r.raise_for_status()
results.put(("ok", r.json()))
except Exception as e:
results.put(("error", e))
t0 = time.monotonic()
threading.Thread(target=fetch_products, daemon=True).start()
def poll(): # runs in the main thread: Tk is safe here
try:
kind, payload = results.get_nowait()
except queue.Empty:
root.after(100, poll) # nothing yet β check again in 100 ms
return
dur = time.monotonic() - t0
during = len([t for t in ticks if t > t0])
print(f"result: {kind}, {len(payload)} items; took {dur:.2f}s; ticks during fetch: {during}")
root.after(200, root.destroy)
root.after(100, poll)
root.mainloop()
Output from the run for this guide:
result: ok, 5 items; took 4.01s; ticks during fetch: 40
Same fetch, same 3-second backend β but now 40 heartbeats ran while it was in flight. The window repaints, buttons click, and no Tk object is ever touched off the main thread, so the RuntimeError can't happen.
Every list screen you'll ever ship has four states: loading, error, empty, and data. Here's a complete ProductList frame using the pattern above β save it as productlist.py (the tests in section 10 import it):
import tkinter as tk, threading, queue, requests
API = "https://mockbird.mockbird.workers.dev/m/demo"
class ProductList(tk.Frame):
def __init__(self, master, params=""):
super().__init__(master)
self.params = params
self.status = tk.Label(self, text="")
self.status.pack(anchor="w")
self.listbox = tk.Listbox(self, width=48)
self.listbox.pack(fill="both", expand=True)
self.reload_btn = tk.Button(self, text="Reload", command=self.reload)
self.reload_btn.pack(anchor="e")
self.q = queue.Queue()
self.reload()
def reload(self):
self.status.config(text="Loadingβ¦") # branch 1: loading
self.listbox.delete(0, "end")
self.reload_btn.config(state="disabled")
threading.Thread(target=self._fetch, daemon=True).start()
self.after(100, self._poll)
def _fetch(self): # worker thread: network ONLY
try:
r = requests.get(f"{API}/products?{self.params}", timeout=10)
r.raise_for_status()
self.q.put(("ok", r.json()))
except requests.HTTPError as e:
self.q.put(("error", f"HTTP {e.response.status_code}"))
except requests.RequestException as e:
self.q.put(("error", type(e).__name__))
def _poll(self): # main thread: all widget work
try:
kind, payload = self.q.get_nowait()
except queue.Empty:
self.after(100, self._poll)
return
self.reload_btn.config(state="normal")
if kind == "error":
self.status.config(text=f"Couldn't load products ({payload})", fg="red") # branch 2
elif not payload:
self.status.config(text="No products yet β add your first one!", fg="gray") # branch 3
else:
self.status.config(text=f"{len(payload)} products", fg="black") # branch 4
for p in payload:
self.listbox.insert("end", f'{p["name"]} β ${p["price"]}')
if __name__ == "__main__":
import sys
root = tk.Tk()
root.title("Products")
w = ProductList(root, params=sys.argv[1] if len(sys.argv) > 1 else "limit=10")
w.pack(fill="both", expand=True, padx=8, pady=8)
root.mainloop()
Run it: python productlist.py β a window with 10 products. So far, so normal. The interesting part is the next section.
Because the backend is a mock that understands simulation params, you can put the widget into any state from the command line, without touching the code:
python productlist.py "limit=10" # data: "10 products"
python productlist.py "mock_status=500" # error: "Couldn't load products (HTTP 500)"
python productlist.py "name=definitely-no-such-thing" # empty: "No products yet β add your first one!"
python productlist.py "limit=10&mock_delay=2000" # loading: watch "Loadingβ¦" for 2s, then data
All four were run for this guide and showed exactly those statuses (the empty branch works because exact-match filters that match nothing return []). The full simulation toolkit: mock_status (force any HTTP status), mock_delay (latency in ms), mock_seq (a sequence of statuses β next section), mock_chaos (random failures at a given probability), mock_jitter (random latency). See the docs for all of them.
"Retry on 5xx with backoff" is easy to write and miserable to test against a backend that fails randomly β or never. mock_seq makes the flakiness deterministic: this URL fails twice, then succeeds, in exactly that order:
import uuid, time, requests
API = "https://mockbird.mockbird.workers.dev/m/demo"
key = uuid.uuid4().hex[:12] # fresh key = fresh sequence position
url = f"{API}/products?limit=3&mock_seq=503,503,200&mock_seq_key={key}"
def fetch_with_retry(url, tries=4, backoff=0.5):
seen = []
for attempt in range(tries):
r = requests.get(url, timeout=10)
seen.append(r.status_code)
if r.status_code < 500:
return r, seen
time.sleep(backoff * (2 ** attempt))
return r, seen
r, seen = fetch_with_retry(url)
print("statuses observed:", seen, "-> final items:", len(r.json()))
Output from the run for this guide:
statuses observed: [503, 503, 200] -> final items: 3
In the app, run fetch_with_retry inside _fetch() β the retries and sleeps happen on the worker thread, so the UI keeps breathing while your code rides out the outage.
Unlike classic fake-data APIs that return {"id": 101} and forget you, writes here persist β so your "Add product" dialog does a real round-trip:
new = {"name": "Walnut monitor stand", "price": 49.5, "inStock": True}
r = requests.post(f"{API}/products", json=new, timeout=10)
pid = r.json()["id"]
back = requests.get(f"{API}/products/{pid}", timeout=10).json()
print("created id", pid, "; read back:", back["name"], back["price"])
requests.delete(f"{API}/products/{pid}", timeout=10)
Output from the run for this guide:
created id 31 ; read back: Walnut monitor stand 49.5
(The demo project resets itself daily; your own project keeps data until you delete it.)
Every project also exposes mock auth endpoints that issue real signed JWTs β any email/password logs in, and if the email matches a seeded customer, that record becomes the user:
r = requests.post(f"{API}/auth/login",
json={"email": "ana@example.com", "password": "pw"}, timeout=10)
tok = r.json()["token"]
me = requests.get(f"{API}/auth/me",
headers={"Authorization": f"Bearer {tok}"}, timeout=10)
print("me:", me.status_code, me.json()["user"]["email"])
Output from the run for this guide:
me: 200 ana@example.com
Set expiresIn at login to a few seconds to rehearse token-expiry handling, or flip the project to protected mode so every endpoint 401s without a Bearer token β the full login-wall experience. Recipes: mock JWT auth guide.
Qt has pytest-qt; Tkinter needs nothing but a helper that pumps update() until the widget settles. These four tests drive the real ProductList through all four branches β network and all, no monkeypatching:
# test_productlist.py
import time, tkinter as tk
import pytest
from productlist import ProductList
def pump_until(root, cond, timeout=8.0):
"""Run the Tk event loop by hand until cond() is true (or fail)."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
root.update()
if cond():
return
time.sleep(0.02)
pytest.fail("condition not met within %ss" % timeout)
@pytest.fixture
def root():
r = tk.Tk()
r.withdraw() # no window needed for tests
yield r
r.destroy()
def settled(w):
return lambda: w.status.cget("text") not in ("", "Loadingβ¦")
def test_data_branch(root):
w = ProductList(root, params="limit=10")
pump_until(root, settled(w))
assert w.status.cget("text") == "10 products"
assert w.listbox.size() == 10
assert "$" in w.listbox.get(0)
def test_error_branch(root):
w = ProductList(root, params="mock_status=500")
pump_until(root, settled(w))
assert "HTTP 500" in w.status.cget("text")
assert w.listbox.size() == 0
def test_empty_branch(root):
w = ProductList(root, params="name=definitely-no-such-thing")
pump_until(root, settled(w))
assert "No products yet" in w.status.cget("text")
def test_loading_branch_shown(root):
w = ProductList(root, params="limit=5&mock_delay=1500")
assert w.status.cget("text") == "Loadingβ¦"
assert w.reload_btn.cget("state") == "disabled"
pump_until(root, settled(w))
assert w.status.cget("text") == "5 products"
assert w.reload_btn.cget("state") == "normal"
Output from the run for this guide:
$ python -m pytest test_productlist.py -q
.... [100%]
4 passed in 6.18s
On a headless CI box, run under xvfb-run (Tk needs a display server, even withdrawn). For fully deterministic fixtures across a test suite, pin a data snapshot per scenario.
| Approach | Good at | Where it falls short for Tkinter work |
|---|---|---|
responses / requests-mock | Fast unit tests, no network | Patches requests in-process β your threading, timeouts and retry timing are never exercised; can't demo the app against realistic latency. |
vcrpy cassettes | Replaying a real API you already have | Needs the real API once; replays are static β no writes, no state flips, no deterministic failure sequences. |
Local http.server stub | Full control, offline | You write and maintain it; blocks on one thread by default; no seeded data, filtering, auth or failure simulation unless you build them. |
| Mockbird (this guide) | Real HTTP + latency + failures over the wire, seeded CRUD data, zero code | It's a remote service β offline dev needs a local stub; data caps apply (limits). |
Building with PyQt or PySide instead? The same traps have Qt-specific shapes β the PyQt guide measures them there. General Python (no GUI): mock API for Python.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects -H "content-type: application/json" -d '{"preset":"ecommerce"}' β or one click. Define custom resources and field types in the dashboard.db.json, Postman collection, or a CSV β instant hosted API (CSV guide).openapi.json, TypeScript/Zod types, Postman collection, or the whole dataset as db.json β eject anytime.Related: mock APIs for PyQt / PySide, mock APIs for Python, mock APIs for pandas, testing loading and error states, mock JWT auth, deterministic test data with snapshots, and free mock-API tools compared.
Verification: every Python snippet on this page was run verbatim on 22 Sep 2026 with Python 3.12 + Tk 8.6 + requests 2.31 + pytest 7.4 on a Linux box (X display via Xvfb) against production β the printed outputs shown are from those runs, including the 0-ticks freeze measurement, the mid-loop-didn't-raise / raised-at-shutdown thread repro, the 40-ticks pattern run, all four query-param branch flips, the 503,503,200 retry recovery, the id-31 write round-trip (record deleted afterwards), and all four pytest tests in one passing run. Demo data reseeds daily, so names, prices and counts will differ when you run it. If a snippet here doesn't work in your app, that's a bug: tell us.