Most tiling window managers treat configuration as a necessary inconvenience. Some hand the user a custom mini-language with its own quirks, others demand recompilation after every tweak, and a few simply expect patience with arcane syntax. Qtile took a different road. It is written in Python, configured in Python, and extensible in Python, which means every developer who already works in that language can treat the window manager itself as just another library. This article walks through installing Qtile on Linux, writing a first real configuration, adding keyboard shortcuts and a status bar, and shaping the environment into a productive daily driver for software work.
Why A Python Configured Window Manager Appeals Specifically To Developers Working In Scripted Environments
A window manager sits between the user and every application on the screen. That position makes it one of the most frequently touched pieces of software in a day, and small ergonomic wins compound into hours of saved time over a year. Tiling managers remove the friction of aligning windows manually. Qtile goes a step further by exposing its entire internal state to the language developers already speak fluently.
The appeal is practical rather than philosophical. A developer who writes Python for work can reuse the same mental model when editing the config. Loops, conditionals, functions, classes, and imports all behave exactly as expected. No custom DSL, no pseudo-language with its own parser, no recompilation step. A syntax error gets caught by the same tools that catch errors in application code. A logic bug can be debugged with print statements or an actual debugger. The entire ecosystem of Python libraries becomes available from inside the config, which means a status bar widget can call an HTTP API, parse JSON, or query a database without leaving the file.
Qtile also supports both X11 and Wayland through the same configuration. The X11 backend is mature and battle-tested, while the Wayland compositor built on wlroots catches up with each release. Switching between the two takes a single flag, which makes the manager a safe bet for users who want to migrate to Wayland gradually rather than all at once.
Installing Qtile Through The Distribution Package Manager Or A Dedicated Python Virtual Environment
Two installation approaches dominate. The first uses whatever package the distribution provides, which is the fastest path on Arch, Fedora, and openSUSE. The second isolates Qtile inside a Python virtual environment, which gives more control and avoids conflicts with system Python packages.
On Arch Linux, the package manager makes things trivial:
sudo pacman -S qtile alacritty rofi picom feh
Debian and Ubuntu users can pull in Qtile through apt, though the version often lags behind upstream:
sudo apt update
sudo apt install python3-libqtile
The virtual environment approach scales better for users who want a specific Python version or who treat their dotfiles as production code. Installing the deadsnakes PPA on Ubuntu provides modern Python releases, after which a venv isolates Qtile cleanly:
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.12 python3.12-venv python3.12-dev
sudo mkdir -p /opt/qtile
sudo chown $USER:$USER /opt/qtile
python3.12 -m venv /opt/qtile
source /opt/qtile/bin/activate
pip install --upgrade pip wheel
pip install qtile psutil dbus-next
For Wayland users, a slightly different install pulls in the compositor dependencies:
pip install qtile[wayland]
Either route produces a working qtile binary. The final step tells the display manager about the new session by dropping a desktop entry into /usr/share/xsessions/qtile.desktop:
[Desktop Entry]
Name=Qtile
Comment=Qtile Session
Exec=/opt/qtile/bin/qtile start
Type=Application
Keywords=wm;tiling
After logging out, Qtile appears in the session dropdown at the login screen. Users who prefer startx can add exec qtile start to the end of ~/.xinitrc instead.
Writing The First Configuration File In Python With A Clean Modular Structure
Qtile reads its config from ~/.config/qtile/config.py. The default config that ships with the package works out of the box but deserves to be replaced quickly because it serves more as a reference than a serious starting point. A minimal but real configuration imports the necessary modules and defines the core behaviour:
# ~/.config/qtile/config.py
import os
import subprocess
from libqtile import bar, hook, layout, qtile, widget
from libqtile.config import Click, Drag, Group, Key, Match, Screen
from libqtile.lazy import lazy
mod = "mod4" # Super key
terminal = "alacritty"
browser = "firefox"
launcher = "rofi -show drun"
keys = [
# Focus movement
Key([mod], "h", lazy.layout.left()),
Key([mod], "l", lazy.layout.right()),
Key([mod], "j", lazy.layout.down()),
Key([mod], "k", lazy.layout.up()),
# Window management
Key([mod], "Return", lazy.spawn(terminal)),
Key([mod], "d", lazy.spawn(launcher)),
Key([mod], "b", lazy.spawn(browser)),
Key([mod, "shift"], "c", lazy.window.kill()),
Key([mod], "Tab", lazy.next_layout()),
Key([mod], "f", lazy.window.toggle_fullscreen()),
# Qtile session
Key([mod, "control"], "r", lazy.reload_config()),
Key([mod, "control"], "q", lazy.shutdown()),
]
The mod4 key binds to the Super (Windows) key, which avoids collisions with application shortcuts that typically use Alt or Ctrl. The lazy decorator defers function calls until Qtile is ready to execute them, which is how the config stays declarative while still allowing dynamic behaviour.
Verifying the file before restarting prevents lockouts. Qtile ships with a command that parses the config without loading it:
python -m py_compile ~/.config/qtile/config.py
If the command produces no output, the syntax is valid. Runtime errors still need a real restart to surface, but syntax typos get caught immediately.
Defining Workspaces Layouts And Window Matching Rules For A Developer Oriented Setup
Qtile calls workspaces "Groups," and each group can carry rules that automatically send specific applications to specific places. A developer might want Firefox on group two, a chat client on group nine, and terminals anywhere. Expressing that intent reads like regular Python:
groups = [
Group("1", label="term"),
Group("2", label="web", matches=[Match(wm_class=["firefox"])]),
Group("3", label="code", matches=[Match(wm_class=["Code", "jetbrains-idea"])]),
Group("4", label="docs"),
Group("5", label="media"),
Group("9", label="chat", matches=[Match(wm_class=["thunderbird"])]),
]
for g in groups:
keys.extend([
Key([mod], g.name, lazy.group[g.name].toscreen()),
Key([mod, "shift"], g.name, lazy.window.togroup(g.name, switch_group=True)),
])
Layouts determine how windows share screen space within a group. Qtile ships with more than a dozen built-in layouts, but the three that fit most development workflows are Columns, MonadTall, and Max. Columns splits the screen into resizable vertical columns, MonadTall gives one large master window flanked by a stack of smaller ones, and Max simply fullscreens whichever window has focus.
layouts = [
layout.Columns(
border_focus="#88c0d0",
border_normal="#3b4252",
border_width=2,
margin=8,
),
layout.MonadTall(
border_focus="#88c0d0",
border_normal="#3b4252",
border_width=2,
margin=8,
ratio=0.55,
),
layout.Max(),
]
The margin setting introduces gaps between windows, which is purely cosmetic but reduces visual fatigue during long coding sessions. Every layout accepts dozens of parameters, and the documentation lists them all, but these defaults work well enough to leave alone for months.
Building A Status Bar With Widgets That Expose Real Time System Information
The status bar lives on a Screen object. Qtile treats each monitor as a separate screen, and each screen can carry any number of bars on any edge. A practical setup for a single monitor places one bar at the top containing workspace indicators, window title, system stats, and a clock:
widget_defaults = dict(
font="JetBrains Mono",
fontsize=13,
padding=6,
foreground="#d8dee9",
background="#2e3440",
)
screens = [
Screen(
top=bar.Bar(
[
widget.CurrentLayout(foreground="#88c0d0"),
widget.GroupBox(
active="#eceff4",
inactive="#4c566a",
this_current_screen_border="#88c0d0",
highlight_method="line",
),
widget.Prompt(),
widget.WindowName(foreground="#a3be8c"),
widget.Chord(),
widget.CPU(format=" {load_percent:.0f}%"),
widget.Memory(format=" {MemUsed: .0f}{mm}"),
widget.Net(interface="wlan0", format=" {down} ↓↑ {up}"),
widget.Battery(format=" {percent:2.0%}"),
widget.Volume(fmt="墳 {}"),
widget.Clock(format="%a %d %b %H:%M"),
widget.Systray(),
],
size=28,
margin=[4, 8, 0, 8],
),
),
]
Each widget is a Python class that can be subclassed or extended. A developer who wants a custom indicator for the current Git branch, an unread email count, or the status of a CI pipeline can write one in a dozen lines. The GenPollText widget accepts an arbitrary function and runs it at a configurable interval, which covers most ad-hoc needs without touching the Qtile internals.
Adding Autostart Hooks Mouse Bindings And Floating Window Rules For Polish
Several small touches separate a functional config from one that feels finished. Autostart scripts handle things that should happen exactly once per session, like launching a compositor, setting a wallpaper, or starting a notification daemon:
@hook.subscribe.startup_once
def autostart():
script = os.path.expanduser("~/.config/qtile/autostart.sh")
subprocess.Popen([script])
The referenced shell script typically looks like this:
#!/bin/sh
picom --daemon &
feh --bg-fill ~/Pictures/wallpaper.jpg &
dunst &
nm-applet &
blueman-applet &
Mouse bindings give the config room for hybrid workflows. Holding the mod key and dragging with the left button moves a floating window, while the right button resizes it:
mouse = [
Drag([mod], "Button1", lazy.window.set_position_floating(),
start=lazy.window.get_position()),
Drag([mod], "Button3", lazy.window.set_size_floating(),
start=lazy.window.get_size()),
Click([mod], "Button2", lazy.window.bring_to_front()),
]
Floating window rules catch dialogs, popups, and utility windows that should never tile. Qtile already floats most common ones by default, but custom rules handle edge cases like a specific password manager or screenshot tool:
floating_layout = layout.Floating(
float_rules=[
*layout.Floating.default_float_rules,
Match(wm_class="confirmreset"),
Match(wm_class="Bitwarden"),
Match(wm_class="pavucontrol"),
Match(title="branchdialog"),
]
)
Key Features That Make Qtile Stand Out Against Other Tiling Window Managers
Several capabilities deserve explicit mention because they shape daily use in ways that only become obvious after weeks of experience:
- Live config reload through
mod+ctrl+rapplies changes without restarting the X session, which shortens the feedback loop dramatically during customisation work - The built-in
qtile cmd-objcommand shell exposes every internal object for introspection, making it possible to discover available methods and attributes interactively from a terminal - Remote scriptability means external scripts can manipulate the window manager through the same API the config uses, turning things like workspace automation into normal Python
- Wayland support through the same codebase lets users migrate from X11 without throwing away their config, which few other tiling managers offer this seamlessly
- Native multi-monitor handling treats each screen as a first-class object with independent bars, layouts, and workspaces
These features add up to a window manager that feels more like a framework than a fixed product.
Final Thoughts On Adopting Qtile As A Long Term Tool For Developer Workflows
Qtile rewards the time invested in learning it. The initial configuration takes a few hours for someone already comfortable with Python, the first week involves constant small adjustments, and by the second month the config file has settled into a shape that rarely changes. That stability is the real prize. A well-tuned Qtile setup follows the user from machine to machine through a single file in version control, survives distribution changes, and scales naturally as new needs emerge.
The window manager does not pretend to be the easiest choice for newcomers. Users who prefer visual configuration panels or who actively dislike writing code will find better fits elsewhere. But for developers who spend their days inside terminals, editors, and browsers, treating the window manager as just another Python project transforms a daily tool into something personal, precise, and quietly powerful.