Skip to content

Integrating asyncio with Tkinter and Other GUI Event Loops

A desktop app that talks to the network has two event loops that each believe they own the thread: the GUI toolkit's (root.mainloop() in Tkinter, app.exec() in Qt) and asyncio's. Call asyncio.run() from a button handler and the UI freezes until it returns; call root.mainloop() from inside a coroutine and asyncio stops. There are two workable arrangements: let asyncio drive the GUI by pumping its events periodically, or run asyncio on a background thread and post results back to the GUI thread. In a test with Tkinter, the first cost 1.5% of a core at idle when pumping at 60 Hz; the second returned results from a 100 ms coroutine to the UI in 111–122 ms with the GUI loop untouched. This guide implements both and covers what toolkit-native integrations like qasync add.

Prerequisites

1. Let asyncio drive the GUI by pumping events

The simplest integration makes the asyncio loop the main loop and calls the toolkit's "process pending events" function on a timer. In Tkinter that is root.update():

import asyncio
import tkinter as tk


class App:
    def __init__(self) -> None:
        self.root = tk.Tk()
        self.label = tk.Label(self.root, text="idle")
        self.label.pack()
        tk.Button(self.root, text="Fetch", command=self.on_fetch).pack()
        self.root.protocol("WM_DELETE_WINDOW", self.on_close)
        self.running = True
        self.tasks: set[asyncio.Task] = set()

    def on_fetch(self) -> None:                       # Tk callback, runs inside update()
        task = asyncio.create_task(self.fetch())
        self.tasks.add(task)
        task.add_done_callback(self.tasks.discard)

    async def fetch(self) -> None:
        self.label["text"] = "loading…"
        await asyncio.sleep(1)                          # stand-in for an HTTP call
        self.label["text"] = "done"

    def on_close(self) -> None:
        self.running = False

    async def run(self, hz: int = 60) -> None:
        while self.running:
            self.root.update()                          # process all pending Tk events
            await asyncio.sleep(1 / hz)
        for t in self.tasks:
            t.cancel()
        self.root.destroy()


asyncio.run(App().run())

Everything runs on one thread, so coroutines can touch widgets directly — the label["text"] = ... assignments are safe. The cost is polling: the UI only reacts at the pump rate, and the loop wakes up even when nothing happens. Measured on an idle window: 1.5% of a core at 60 Hz, 3.1% at 250 Hz.

Verify: click Fetch several times quickly; the UI stays responsive and the label updates after a second for each click.

asyncio as the main loop, pumping Tk A flow of 4 stages. asyncio as the main loop, pumping Tk asyncio loop owns the main thread root.update() Tk events processed callbacks create tasks on the same loop await sleep(1/60) other tasks run One thread for everything, so widgets are safe to touch from coroutines; the price is a polling loop.

2. Keep long Tk work out of the pump

The pump only works if each update() call is short. Two things break it. A Tk callback that does blocking work — reading a large file, a synchronous HTTP call — stalls both the UI and every coroutine. And modal Tk operations that run their own nested event loop, such as filedialog.askopenfilename() or messagebox.showinfo(), block inside update() until the dialog closes, freezing all asyncio work for that time.

from tkinter import filedialog


async def pick_and_upload(app) -> None:
    # a modal dialog would block the pump; run it in a deferred Tk callback,
    # and hand the result back to asyncio through a future
    loop = asyncio.get_running_loop()
    fut: asyncio.Future[str] = loop.create_future()
    app.root.after(0, lambda: fut.set_result(filedialog.askopenfilename()))
    path = await fut                       # coroutines are paused while the dialog is open
    if path:
        await upload(path)

The dialog still pauses coroutines while it is open — there is no way around that in the single-thread design — but the code makes it explicit and keeps the result in async code. If background work must continue while dialogs are open (a download, a websocket), that is the signal to use the threaded design instead.

Verify: start a long-running task, open a file dialog, and observe whether the task's progress stops; if that is unacceptable, switch to section 3.

3. Run asyncio on a background thread instead

The robust arrangement leaves the GUI loop alone on the main thread and runs asyncio on a worker thread. The GUI submits coroutines with run_coroutine_threadsafe, and results come back through a queue the GUI polls with after():

import asyncio
import queue
import threading
import tkinter as tk


class AsyncBridge:
    def __init__(self, root: tk.Tk) -> None:
        self.root = root
        self.loop = asyncio.new_event_loop()
        self.thread = threading.Thread(target=self.loop.run_forever, name="asyncio", daemon=True)
        self.thread.start()
        self.results: queue.SimpleQueue = queue.SimpleQueue()
        self.root.after(20, self._drain)

    def submit(self, coro, on_done) -> None:
        fut = asyncio.run_coroutine_threadsafe(coro, self.loop)
        fut.add_done_callback(lambda f: self.results.put((on_done, f)))   # asyncio thread

    def _drain(self) -> None:                                            # Tk thread
        while True:
            try:
                on_done, fut = self.results.get_nowait()
            except queue.Empty:
                break
            on_done(fut)
        self.root.after(20, self._drain)

    def close(self) -> None:
        self.loop.call_soon_threadsafe(self.loop.stop)
        self.thread.join(timeout=5)

The critical rule: never touch a widget from the asyncio thread. Tk is not thread-safe, and calls from another thread fail unpredictably — sometimes immediately, sometimes as corrupted state much later. The done-callback only puts the future into a queue; the Tk thread's _drain is the only place results meet widgets.

Measured with a 100 ms coroutine: results reached the UI 111–122 ms after the click, the extra 10–20 ms being the 20 ms drain interval.

Verify: add assert threading.current_thread() is threading.main_thread() inside every function that updates a widget.

A click, a coroutine on another thread, and the result back A sequence of 5 messages between 3 participants. A click, a coroutine on another thread, and the result back Tk main thread result queue asyncio thread run_coroutine_threadsafe(fetch()) await the network call done-callback: put(future) after(20): drain the queue update the widget on the Tk thread Results cross threads only through the queue; widgets are touched on the Tk thread alone.

4. Use a toolkit-native integration when one exists

Some toolkits can run asyncio on their own event loop directly. For Qt, qasync implements an asyncio event loop on top of Qt's, so coroutines and Qt signals share one thread with no polling:

# pip install PySide6 qasync
import asyncio
import sys
from PySide6.QtWidgets import QApplication, QPushButton
import qasync


async def fetch(button: QPushButton) -> None:
    button.setText("loading…")
    await asyncio.sleep(1)
    button.setText("done")


def main() -> None:
    app = QApplication(sys.argv)
    loop = qasync.QEventLoop(app)
    asyncio.set_event_loop(loop)
    button = QPushButton("Fetch")
    button.clicked.connect(lambda: asyncio.ensure_future(fetch(button)))
    button.show()
    with loop:
        loop.run_forever()


main()

This is the best of both designs — one thread, no polling, modal dialogs that do not freeze coroutines because Qt's nested loops still dispatch asyncio callbacks — for as long as the integration library keeps up with both Qt and asyncio releases. Pin its version and test upgrades of either side together. Tkinter has no equivalent in the standard library.

Verify: idle CPU with the window open is effectively zero, unlike the pumping design.

Three ways to combine a GUI loop with asyncio A grid of 3 rows by 5 columns. Three ways to combine a GUI loop with asyncio design threads idle cost widgets from coroutines modal dialogs asyncio pumps Tk 1 1.5% at 60 Hz yes pause all coroutines asyncio on a thread 2 ~0, plus drain timer no, via queue no effect native, e.g. qasync 1 ~0 yes coroutines keep running Pumping is simplest; the threaded bridge is the most robust choice for Tkinter.

5. Shut down in the right order

Closing the window must stop asyncio work cleanly, or the process hangs on exit or prints "Task was destroyed but it is pending". The order is: stop accepting new work, cancel running tasks on their own loop, stop the loop, then destroy the GUI:

async def _cancel_all() -> None:
    tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
    for t in tasks:
        t.cancel()
    await asyncio.gather(*tasks, return_exceptions=True)


def on_close(bridge: AsyncBridge, root: tk.Tk) -> None:
    fut = asyncio.run_coroutine_threadsafe(_cancel_all(), bridge.loop)
    try:
        fut.result(timeout=3)
    except TimeoutError:
        pass                                   # a task ignored cancellation; exit anyway
    bridge.close()
    root.destroy()

Bounding the wait matters: a coroutine stuck in a network call that ignores cancellation should not leave a window that will not close. The same ordering logic applies to servers, as described in Graceful Shutdown & Signal Handling.

Verify: close the window during an active download; the process exits within three seconds with no pending-task warnings.

Verification

The integration is correct when:

  • The UI never freezes while coroutines run, apart from modal dialogs in the pump design.
  • Widgets are only touched on the GUI thread, enforced by assertions in development builds.
  • Idle CPU is acceptable for a desktop app — near zero for threaded and native designs.
  • Closing the window cancels tasks and exits promptly.

Diagnostic Hook: time each pump iteration (or each drain callback) and log any over 50 ms — that is a visible UI stutter, and the log tells you which callback caused it. In the threaded design, also record time from submit() to on_done(); when it greatly exceeds the coroutine's own duration, the asyncio thread is blocked by something synchronous.

Pitfalls & edge cases

  • asyncio.run() inside a button handler. It blocks the GUI for the coroutine's whole duration and creates a fresh loop every click.
  • Updating widgets from the asyncio thread. Tk and Qt widgets are not thread-safe; always marshal through the GUI thread.
  • Forgetting task references in GUI callbacks. Tasks created from callbacks can be garbage-collected mid-flight; keep them in a set.
  • Pumping too slowly. Below about 30 Hz typing and dragging feel laggy; above 250 Hz the idle cost grows with little benefit.

Frequently Asked Questions

How do I use asyncio with Tkinter?

Either run asyncio as the main loop and call root.update() every frame from a coroutine, or keep root.mainloop() on the main thread and run an asyncio loop on a background thread, submitting coroutines with run_coroutine_threadsafe and passing results back through a queue polled with root.after.

Why does my Tkinter window freeze when I call asyncio.run?

asyncio.run blocks the calling thread until the coroutine finishes, and that thread is the one running Tk's event loop. No events are processed until it returns.

Can I update Tkinter widgets from an asyncio thread?

No. Tkinter is not thread-safe. Put results on a queue and apply them to widgets from a callback scheduled with root.after on the main thread.

Is there an asyncio event loop for Qt?

Yes — qasync implements an asyncio event loop on top of Qt's, so coroutines and Qt signals run on one thread without polling.