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.
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.
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
HelloWorldframe 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
HelloWorldextends the Frame class only amounts to being able to use
selfas 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
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
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.
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.
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 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
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.
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
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
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 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.