Hey there! This week we're returning to GUI development with Tkinter, and in this post we're going to be getting to grips with the grid
geometry manager.
If you missed out earlier posts on Tkinter, you can check them out in the links below. In these posts we cover some foundational information on working with Tkinter, and we cover another geometry manager called pack
.
https://blog.tecladocode.com/tkinters-pack-geometry-manager/
https://blog.tecladocode.com/side-values-in-tkinters-pack-geometry-manager/
Why should we care about grid
?
As I just mentioned, we've already dedicated two posts to a different geometry manager called pack
, so why do we need another one?
That's actually a really good question, and as it turns out grid
is a much newer addition to Tk than pack
, and was introduced to overcome some of the shortcomings of the pack
geometry manager. It's now been a staple in Tk for two decades, so it must be doing something right!
As I mentioned in the first post on pack
, complex interfaces quickly get overwhelming when using the pack
geometry manager, because it can become hard to mentally keep track of how the various widgets in a given container interact. There are a lot of moving parts, and a lot of pieces of configuration to keep track of.
grid
is a lot more hands on than pack
, so we have to rely less on Tkinter automatically negotiating an acceptable layout for all of the relevant widgets. With grid
, we instead define a 2-dimensional table structure, and we tell widgets to occupy certain cells withing that table. This makes it a lot easier to reason about widget placement in more elaborate interfaces.
A minor downside is that we generally have to specify more information up front, so pack
and its very succinct layout definitions is still great for simpler interfaces.
Without further ado, let's start looking at how to work with grid
to position our widgets.
Defining the grid
The first step when using grid
is often to define the nature of the actual grid: the 2-dimensional table we'll use to position widgets.
The configuration for the grid is done in a parent container, rather than on the grid
aligned widgets themselves. This makes a lot of sense, because the configuration we define here is common to all of those widgets. All the widgets need to worry about is where they sit within this table.
Defining a grid is not strictly necessary, and if we don't supply one, Tkinter will infer a table structure from the configuration we supply to the child widgets. More on this in a little bit.
We have two methods available to us for defining the layout of the grid: columnconfigure
and rowconfigure
.
They both have the same parameters, but as the names imply, they control different dimensions of the grid.
Most of the time, you're going to specify two pieces of configuration for columnconfigure
and rowconfigure
: the columns or rows you're configuring, and the weights of those columns or rows.
The weight
of a row or column determines how much of the available space a row or column should occupy relative to the other rows or columns. For example, a column with a weight
of 2
will be twice as wide as a column with a weight
of 1
, assuming there's space for the widgets to fit.
In code, we might define two columns like this, where the container for our widgets is the root container:
import tkinter as tk
root = tk.Tk()
root.geometry("600x400")
root.columnconfigure(0, weight=1)
root.columnconfigure(1, weight=2)
root.mainloop()
Once again we've gone back to our trusty setup of a fixed size window, and we've just included our usual Tkinter boilterplate. If you're not clear on the code above, have a look at our earlier pack
post where we cover a lot of this stuff: https://blog.tecladocode.com/tkinters-pack-geometry-manager/
In addition to the usual code, we've defined two grid columns, column 0
and column 1
, where column 1
is twice as wide as column 0
.
If we want to specify the same configuration for multiple columns, we can pass in a tuple of column numbers instead of just a single number:
import tkinter as tk
root = tk.Tk()
root.geometry("600x400")
root.columnconfigure((0, 2, 3), weight=1)
root.columnconfigure(1, weight=2)
root.mainloop()
Now we have four defined columns, where column 1
is twice as wide as any of the others.
rowconfigure
works exactly the same way.
Placing widgets in the grid
Now that we've seen how to configure the container in order to set up a grid structure, we need to learn how to put items in the grid.
For this example, we're going to return to our trusty rectangles, and we're going to start by using the first, simpler grid we defined above.
import tkinter as tk
root = tk.Tk()
root.geometry("600x400")
root.columnconfigure(0, weight=1)
root.columnconfigure(1, weight=2)
rectangle_1 = tk.Label(root, text="Rectangle 1", bg="green", fg="white")
rectangle_2 = tk.Label(root, text="Rectangle 2", bg="red", fg="white")
root.mainloop()
Here we've defined a couple of coloured rectangles using the Label
widget. If the options used here are not clear to you, take a look at our first pack
post.
Now that we've defined our rectangles, let's use grid
to place them in the window. Just as with the pack examples, we'll start using grid
with no meaningful configuration so we can see the default behaviour. Just like before, we'll be adding a small amount of internal padding using ipadx
and ipady
to make the Label
text easier to read.
import tkinter as tk
root = tk.Tk()
root.geometry("600x400")
root.columnconfigure(0, weight=1)
root.columnconfigure(1, weight=2)
rectangle_1 = tk.Label(root, text="Rectangle 1", bg="green", fg="white")
rectangle_1.grid(ipadx=10, ipady=10)
rectangle_2 = tk.Label(root, text="Rectangle 2", bg="red", fg="white")
rectangle_2.grid(ipadx=10, ipady=10)
root.mainloop()
As we can see the default configuration for grid
looks much the same as pack
. We just call the grid
method on the relevant widget and any configuration is passed in as an argument.
So what does this look like in the window?
I don't know about you, but that is not what I would have expected. Didn't we specify a two column grid for the main window?
As it turns out, grid
is always going to put our widgets in new rows unless we specify otherwise. There's a pretty good reason for this, which is that there are actually an infinite number of rows and columns. Even if we only configure two columns, we can actually put things in column 2, 5, or 67, with empty columns collapsing to zero width.
Tkinter therefore opts to puts widgets one on top of the other, because vertical scrolling is relatively common, and a default layout where 60 widgets are placed side by side is generally not going to be desirable. It usually takes less configuration to put widgets in the same row where necessary, rather than to define a row value for every single widget.
So how do we specify where the widgets should go? We can use the row
and column
parameters.
import tkinter as tk
root = tk.Tk()
root.geometry("600x400")
root.columnconfigure(0, weight=1)
root.columnconfigure(1, weight=2)
rectangle_1 = tk.Label(root, text="Rectangle 1", bg="green", fg="white")
rectangle_1.grid(column=0, ipadx=10, ipady=10)
rectangle_2 = tk.Label(root, text="Rectangle 2", bg="red", fg="white")
rectangle_2.grid(column=1, ipadx=10, ipady=10)
root.mainloop()
Here I've only defined a column value, putting the first rectangle in column 0
, and the second rectangle in column 1
. We might expect Tkinter to now put the widgets in the same row, but this isn't the case. Instead they end up in the specified columns, but on different rows.
We therefore have to specify both row
and column
values for widgets to define the layout we want in this case:
import tkinter as tk
root = tk.Tk()
root.geometry("600x400")
root.columnconfigure(0, weight=1)
root.columnconfigure(1, weight=2)
rectangle_1 = tk.Label(root, text="Rectangle 1", bg="green", fg="white")
rectangle_1.grid(column=0, row=0, ipadx=10, ipady=10)
rectangle_2 = tk.Label(root, text="Rectangle 2", bg="red", fg="white")
rectangle_2.grid(column=1, row=0, ipadx=10, ipady=10)
root.mainloop()
We now end up with both rectangles on the same row, occupying the two different columns:
Note that we never actually defined any configuration for this row, so Tkinter is using a default row configuration. This means that the widgets are assigned only the amount of vertical space required to house the widgets.
Because this row is using a completely default configuration, we could also specify whatever row
value we wanted and the layout wouldn't change at all, as long as the two widgets shared the same row
value. As I mentioned previously, empty rows and columns collapse to 0 height and width respectively.
That being said, using logical row and column numbering is definitely a good idea.
Sticky widgets
One thing you probably noticed is that our widgets are sitting in the middle of our two differently weighted columns, but neither widget is taking up the space we allocated to it. Using pack
we had fill
and expand
properties which allowed us to assign more space to widgets, and to tell them to occupy that space. When using grid
, we have a different property called sticky
.
sticky
accepts compass directions as values, and different combinations of these directions yield different results. You can think of sticky
as a combination of an alignment and fill option.
Specifying a single compass direction causes the widgets to "stick" to a single edge of its assigned area. For example, we can set rectangle_1
to stick to the left side of its assigned area with "W"
, and rectangle_2
to stick to the right side of its assigned area with "E"
.
import tkinter as tk
root = tk.Tk()
root.geometry("600x400")
root.columnconfigure(0, weight=1)
root.columnconfigure(1, weight=2)
rectangle_1 = tk.Label(root, text="Rectangle 1", bg="green", fg="white")
rectangle_1.grid(column=0, row=0, ipadx=10, ipady=10, sticky="W")
rectangle_2 = tk.Label(root, text="Rectangle 2", bg="red", fg="white")
rectangle_2.grid(column=1, row=0, ipadx=10, ipady=10, sticky="E")
root.mainloop()
This gives us something like this:
However, if we specify opposing compass directions, the widgets are stuck to both sides, which causes the widget to stretch:
import tkinter as tk
root = tk.Tk()
root.geometry("600x400")
root.columnconfigure(0, weight=1)
root.columnconfigure(1, weight=2)
rectangle_1 = tk.Label(root, text="Rectangle 1", bg="green", fg="white")
rectangle_1.grid(column=0, row=0, ipadx=10, ipady=10, sticky="EW")
rectangle_2 = tk.Label(root, text="Rectangle 2", bg="red", fg="white")
rectangle_2.grid(column=1, row=0, ipadx=10, ipady=10, sticky="EW")
root.mainloop()
We can now very clearly see our column weights at work, with rectangle_2
taking up twice as much space as rectangle_1
.
So what happens when we set a widget to stick to all edges of its assigned area?
import tkinter as tk
root = tk.Tk()
root.geometry("600x400")
root.columnconfigure(0, weight=1)
root.columnconfigure(1, weight=2)
rectangle_1 = tk.Label(root, text="Rectangle 1", bg="green", fg="white")
rectangle_1.grid(column=0, row=0, ipadx=10, ipady=10, sticky="NSEW")
rectangle_2 = tk.Label(root, text="Rectangle 2", bg="red", fg="white")
rectangle_2.grid(column=1, row=0, ipadx=10, ipady=10, sticky="NSEW")
root.mainloop()
Here we've specified a "NSEW"
as our sticky value for both widgets, which means it will stick to all edges of its assigned area. You can also use a tuple if you prefer, and Tkinter has constants defined which you can use instead of strings:
rectangle_1.grid(column=0, row=0, ipadx=10, ipady=10, sticky=("N", "S", "E", "W"))
rectangle_1.grid(column=0, row=0, ipadx=10, ipady=10, sticky=(tk.N, tk.S, tk.E, tk.W))
rectangle_1.grid(column=0, row=0, ipadx=10, ipady=10, sticky=tk.NSEW)
They're all the exact same, so use whichever version you prefer.
However, the code might not produce the result you expected:
Remember that the sticky
option causes the widget to stick to a given edge of its assigned area, but earlier we said that by default, Tkinter only assigns an amount of row space equal to the height of the widgets inside. They don't need any more, after all.
We can specify a bit of configuration for row 0
to rectify this, giving it a weight of 1
.
import tkinter as tk
root = tk.Tk()
root.geometry("600x400")
root.columnconfigure(0, weight=1)
root.columnconfigure(1, weight=2)
root.rowconfigure(0, weight=1)
rectangle_1 = tk.Label(root, text="Rectangle 1", bg="green", fg="white")
rectangle_1.grid(column=0, row=0, ipadx=10, ipady=10, sticky="NSEW")
rectangle_2 = tk.Label(root, text="Rectangle 2", bg="red", fg="white")
rectangle_2.grid(column=1, row=0, ipadx=10, ipady=10, sticky="NSEW")
root.mainloop()
And just like that, our widgets fill up the whole window:
Note that by default, widgets have a sticky
value of None
, so they will be centred in the assigned area. Adding a row configuration, but no sticky
values will give us something like this:
Giving weights to empty rows
Earlier in the post I noted that empty rows collapse to zero height, but this is only true when they have a weight of 0
.
We can assign weights to empty rows to influence the layout of widgets in other rows.
For example, we can give a weight of 1
to an empty row below the main content to limit the first row to only half the screen:
import tkinter as tk
root = tk.Tk()
root.geometry("600x400")
root.columnconfigure(0, weight=1)
root.columnconfigure(1, weight=2)
root.rowconfigure((0, 1), weight=1)
rectangle_1 = tk.Label(root, text="Rectangle 1", bg="green", fg="white")
rectangle_1.grid(column=0, row=0, ipadx=10, ipady=10, sticky="NSEW")
rectangle_2 = tk.Label(root, text="Rectangle 2", bg="red", fg="white")
rectangle_2.grid(column=1, row=0, ipadx=10, ipady=10, sticky="NSEW")
root.mainloop()
Spanning rows and columns
The last thing we have to talk about is spanning multiple rows and columns. When defining our grid, we're generally concerned with the the layout of the smaller widgets within the window, but what if we need a header bar? Or if we need a large selection box on the side, like in a music player.
Luckily this is very easy in Tkinter, and takes a single piece of configuration.
Let's add a third rectangle, so that we end up with one spanning the entire top row:
import tkinter as tk
root = tk.Tk()
root.geometry("600x400")
root.columnconfigure(0, weight=1)
root.columnconfigure(1, weight=2)
root.rowconfigure(1, weight=1)
rectangle_1 = tk.Label(root, text="Rectangle 1", bg="green", fg="white")
rectangle_1.grid(column=0, row=0, columnspan=2, ipadx=20, ipady=20, sticky="NSEW")
rectangle_2 = tk.Label(root, text="Rectangle 2", bg="red", fg="white")
rectangle_2.grid(column=0, row=1, ipadx=10, ipady=10, sticky="NSEW")
rectangle_3 = tk.Label(root, text="Rectangle 3", bg="blue", fg="white")
rectangle_3.grid(column=1, row=1, ipadx=10, ipady=10, sticky="NSEW")
root.mainloop()
There were quite a few changes here, so let's make sure we properly understand what's going on.
First our rowconfigure
method is set to only affect row 1
, which is the second row. The first row is therefore going to be using the default configuration.
rectangle_1
now has a little bit of extra internal padding and has a new keyword argument of columnspan=2
. This is what allows the widget to span multiple columns as we'll see in a moment.
rectangle_2
is now on row 1
, and sits in the first column.
A new rectangle_3
was added, which sits in the second row and column, but is otherwise the same as rectangle_2
, just with some different text and a different colour background.
Here is what it all looks like:
If you need a widget to span multiple rows instead of multiple columns, you can use rowspan
instead, but it otherwise works in exactly the same way.
Wrapping up
With that you should be able to create some pretty interesting layouts with Tkinter and the grid
geometry manager. I really hope you learnt something new.
If you're interested in learning more about Tkinter, we've released an in-depth course where we teach you everything you need to know, plus we build a tonne of cool Tkinter apps as well. Check out the GUI Development with Python and Tkinter course!