How to setup correctly an application with Python and Tkinter

I am a command line kind of programmer; GUI tools make me shiver and growl until I can scuttle back to the safety of a terminal. However, from time to time the need arises to create a functional — albeit simple — user interface, be it due to the target system or because the tool needs to be shared with less tech-savvy friends. In those rare cases my go-to choice of tools is usually Python paired with the Tk UI library.

It’s not the best. Before using it I always allocate half an hour to the research of a more reliable, modern tool for the job; unfortunately I have yet to find something that fits my requirements. I’ve tried C with GTK bindings, Javascript desktop apps, Kivy, WxWidgets, Go UI. In a tragic turn of events, even Universal Windows Platform. Flutter desktop is my next hope.

Lord, forgive my sins.

In the end I always fall back to Tkinter. Not because of some redeeming quality or feature that other frameworks lack; it is essentially a stripped down widget composer, extremely simple and ideally included in every Python installation. My applications are simple synoptics with few text entries, buttons and sliders; demonstrations that need to be built up as quickly as they are discarded, so Tkinter’s portability and development speed are good enough. Are they nice on the eyes? Hell no, but they do their job.

A simple test program for a serial communication protocol; ugly but functional.

Although Tkinter is so simple, my work has not been painless. For the longest time I’ve struggled to find the correct pattern to set up a decent project. This is partly due to my inexperience in frontend programming and partly because every tutorial out there fails to explain exactly how to use the library in a serious project. These are my two cents on how to do it right.

Tkinter’s hello world program is exceedingly simple, a mere 4 lines of code:

Most tutorials online start with a variant on the same theme. From here it’s fairly easy to add other widgets; the packing and grid mechanics are somewhat unintuitive but still simple to use. The imperative definition of the interface is old and cranky — building it via a declarative syntax like Flutter would be preferable — but I can live with that: again, I mean to build very simple interfaces that don’t need to be layout responsive, elegant or fluid.

The problem is that from there, things start to go down very fast. There are few resources that actually cover how to work with the GUI that you have built, and many of them do it wrong. Part of the issue is the need for the library to continuously run mainloop() to work, which effectively takes the program’s control out of your hands.

As a novice, this can be a little confusing. How is my code supposed to work if the GUI takes up the main thread? The answer is, of course, multithreading. Unfortunately that’s a problem in its own right, because the term can mean so many things; in reality you only need a second thread to properly work with Tkinter.

Before explaining what the solution is, let me tell you about a couple of mistakes I’ve seen and made before reaching it. If you want to cut the chase, just jump to the last section.

The Bane of OOP

Thankfully, Python is not an Object Oriented Programming language. Unfortunately, Python supports features that are usually foundation for OOP, like classes and inheritance (subtyping being left out simply because Python is not typed).

Out of the first 8 Google results for “Tkinter getting started”, 4 include examples that use classes to build a window. That is tragic and should not happen.

For context, let’s see an hello world example made in an object oriented fashion.

This snippet of code, right here, is the reason why we cannot have nice things.

Why am I so salty about it? First of all, the lines of code jump from 4 to a dozen just to add class mechanics: any programming paradigm that increases your work threefold is off to a bad start.

Second, it is incredibly less readable. One class is enough to require a second read of the code, and not just because of the boilerplate. Packing fields and methods hides them from the developer first.

Third, it is entirely pointless. If there is any meaning in using OOP in general, all ends up lost when working with Tkinter like this.

  • A class is supposed to be a blueprint to create multiple objects. Here, the HelloWorld frame only ever has one instance.
  • Encapsulation is not used. There is no reason to have hidden fields in such an object because the object is the program: no external agent will ever want to access its fields.
  • Inheritance is not used. It might be useful to define a custom widget with added features, but not here. The fact that HelloWorld extends the Frame class only amounts to being able to use self as a parent widget.

To recap, any of the supposed advantages of OOP are lost here. Classes and boilerplate code are brought along merely because of bad habits and they end up hindering significantly the development process.

Bottom line, do not use OOP. Here in particular, but also anywhere else.

Don’t do heavy lifting work in the GUI thread

Back to Tkinter. When your main code is blocked by mainloop() you will be looking for alternative methods to run business logic, whether your application actually computes something or it’s just a frontend for another service.

To do so, Tkinter allows to register callback functions on certain events. The most obvious ones are on buttons and other widgets: when the user clicks here, do this. For functions that should not be tied to user interaction one may define a callback that is called every arbitrary period of time with the after function.

These are all ways to have your code look alive and work: print “hello world”, read inputs or periodically refresh something. Unfortunately these are all running in the same main thread that also manages Tkinter’s GUI: under the hood, those callback are invoked by mainloop().

This means that putting stressful work on those functions will significantly slow the responsiveness of your interface. Network requests, Disk operations and device control risk making your application practically unusable.

This snippet has a callback running every 100 milliseconds but waiting for 150 milliseconds each time. You can test it and see how the button click will become laggy and unreliable.

The Solution

As anticipated, the correct architecture to setup a Tkinter application involves multithreading; however, one extra thread is sufficient to manage all of the underlying business logic.

A single function should cyclically do all the work, away from the delicate equilibrium of the UI refresh. You can use more than one thread if you really think it is worth, but consider the hassle of sharing information data between them: more often than not, one will suffice.

Original idea, I swear.

The resulting structure is somewhat similar to The Elm Architecture, or any variant of Model View Controller, really. It is built around two main functions: gui and updatecycle.

gui simply builds the Tkinter window with the needed widgets. It will be called only once at the beginning; modifications on the interface, if any, can be done separately. It should return a collection of reference to widgets-related objects to allow for future reading or writing of the UI contents.

updatecycle is a continuous loop that checks for UI events and performs any needed work. It will be ran on a separate thread, keeping interface updates safely separated.

But how will updatecycle know of user-generated events? The ideal tool for this purpose is a queue, of which Python supports a thread-safe implementation included in the Queue module.

A queue is nothing but a FIFO (First-In First-Out) list that can be safely accessed by different execution threads. Tkinter callbacks will put messages in it when buttons are clicked or values are changed; the updatecycle will poll it to know what has happened and respond accordingly.

This is a skeleton template:

The following is a barebone example of this architecture at work:

The main advantage of this approach is, of course, scalability: new messages and corresponding tasks can be added without issue. To define events you may use the Enum class like I did, leverage Python’s algebraic data types various implementations or create custom dictionaries.

Using lambdas to define callbacks is also optional; personally I like the concise effect of one-liners, but defining a separate function for each event is also fine.

Invoking queue.get() with no argument ensures the thread will block indefinitely until an element is put into the queue. To concurrently manage other functions as well you might want to set a timeout on queue reading and periodically handle secondary tasks.

In this example I built an application that reads and writes on a serial port. Notice how I set a timeout both for the queue (passed as argument in the get call) and the serial port (specified during initialization). The main cycle reads periodically, waiting at most for 100 milliseconds for each stream.

Additionally, on timeout queue.get launches a queue.Empty exception, so I need a proper try…except block.

The resulting application, in two instances communicating between two serial ports.

Tkinter and Thread Safety

As a final note something should be said about thread safety of Tkinter. There seem to be discordant opinions on how much one can push limits of concurrency around it; I’ve read both that the library is not thread safe, that it is, and that it should be but bugs prevent it.

I’ve honestly never considered the problem and never ran into any trouble; to be safe however one should avoid calling Tkinter GUI functions from any thread other than the main one, the same where mainloop() is running.

What if I want to update the interface as result from an event managed by updatecycle? There are ways to do it of course. Like in the previous examples, changing content with StringVar objects is fine even through multithreading. For more complex modifications Tkinter allows to request callbacks on the main thread through functions like after and after_idle.

In the serial communication example imagine to store every message received in a Listbox. Calling the insert method would violate the no-gui-calls-outside-main-thread rule; instead, the same can be requested in an after_idle callback:

after_idle schedules the registered lambda to be executed some time later, when the GUI thread has time to spare. This way you can be certain your application remains thread safe.

Computer Science Master from Alma Mater Studiorum, Bologna; interested in a wide range of topics, from functional programming to embedded systems.