Create a Settings window

Build a settings dialog with QDialog, QGroupBox, QRadioButton and QSettings in PyQt and PySide
Heads up! You've already completed this tutorial.

How do I create a settings/preferences window in PyQt or PySide, with grouped options like radio buttons, and save those settings so they persist between sessions?

Most applications need a way for users to configure preferences — things like choosing a theme, toggling features on or off, or picking a default behavior. In Qt, the standard approach is to build a settings dialog using QDialog, organize options with QGroupBox, and persist those choices with QSettings.

In this tutorial, we'll build a settings window from scratch. We'll start with a simple dialog, add grouped radio buttons, and then wire everything up so settings are saved and restored between sessions. By the end, you'll have a reusable pattern you can adapt for your own applications.

Starting with a basic dialog

A settings window is a secondary window — it opens on top of your main application window, the user makes their choices, and then they confirm or cancel. QDialog is the perfect fit for this. It gives you built-in support for "accepted" and "rejected" outcomes, and it can be shown modally (blocking interaction with the main window until the user is done). For more on using dialogs in your applications, see our tutorial on PyQt6 dialogs.

Here's a minimal settings dialog with OK and Cancel buttons:

python
import sys

from PyQt6.QtWidgets import (
    QApplication,
    QDialog,
    QDialogButtonBox,
    QLabel,
    QVBoxLayout,
)


class SettingsDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)

        self.setWindowTitle("Settings")

        buttons = (
            QDialogButtonBox.StandardButton.Ok
            | QDialogButtonBox.StandardButton.Cancel
        )

        button_box = QDialogButtonBox(buttons)
        button_box.accepted.connect(self.accept)
        button_box.rejected.connect(self.reject)

        layout = QVBoxLayout()
        layout.addWidget(QLabel("Settings will go here."))
        layout.addWidget(button_box)
        self.setLayout(layout)


app = QApplication(sys.argv)

dialog = SettingsDialog()
dialog.show()

app.exec()

Run this and you'll see a small dialog with a label and two buttons. Clicking OK fires the accepted signal and closes the dialog, while Cancel fires rejected. The dialog's .exec() method returns either QDialog.DialogCode.Accepted or QDialog.DialogCode.Rejected, which we'll use later to decide whether to save settings.

A basic settings dialog with OK and Cancel buttons

Adding grouped radio buttons with QGroupBox

Settings often come in groups — for example, a set of mutually exclusive options like "Light theme", "Dark theme", and "System default". QGroupBox draws a labeled border around a set of widgets, making it visually clear that they belong together. When you put QRadioButton widgets inside a group box, Qt automatically makes them mutually exclusive within that group.

The way it works: QGroupBox is itself a widget, so you create a layout, add your radio buttons to that layout, and then set the layout on the group box. The group box then goes into the dialog's main layout like any other widget. If you're new to arranging widgets in Qt, our guide to PyQt6 layouts covers the fundamentals.

python
import sys

from PyQt6.QtWidgets import (
    QApplication,
    QDialog,
    QDialogButtonBox,
    QGroupBox,
    QRadioButton,
    QVBoxLayout,
)


class SettingsDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)

        self.setWindowTitle("Settings")

        # Theme options
        theme_group = QGroupBox("Theme")
        self.theme_light = QRadioButton("Light")
        self.theme_dark = QRadioButton("Dark")
        self.theme_system = QRadioButton("System default")
        self.theme_system.setChecked(True)

        theme_layout = QVBoxLayout()
        theme_layout.addWidget(self.theme_light)
        theme_layout.addWidget(self.theme_dark)
        theme_layout.addWidget(self.theme_system)
        theme_group.setLayout(theme_layout)

        # Font size options
        font_group = QGroupBox("Font size")
        self.font_small = QRadioButton("Small")
        self.font_medium = QRadioButton("Medium")
        self.font_large = QRadioButton("Large")
        self.font_medium.setChecked(True)

        font_layout = QVBoxLayout()
        font_layout.addWidget(self.font_small)
        font_layout.addWidget(self.font_medium)
        font_layout.addWidget(self.font_large)
        font_group.setLayout(font_layout)

        # Buttons
        buttons = (
            QDialogButtonBox.StandardButton.Ok
            | QDialogButtonBox.StandardButton.Cancel
        )
        button_box = QDialogButtonBox(buttons)
        button_box.accepted.connect(self.accept)
        button_box.rejected.connect(self.reject)

        # Main layout
        layout = QVBoxLayout()
        layout.addWidget(theme_group)
        layout.addWidget(font_group)
        layout.addWidget(button_box)
        self.setLayout(layout)


app = QApplication(sys.argv)

dialog = SettingsDialog()
dialog.show()

app.exec()

Each QGroupBox acts as an independent exclusivity group — selecting "Dark" in the theme group won't affect the font size selection, and vice versa. We store references to each radio button as instance attributes (self.theme_light, etc.) so we can read their state later.

Reading settings from the dialog

When the user clicks OK, we need to read which options they selected. Since radio buttons are mutually exclusive within their group, we can check each one with .isChecked(). A clean way to handle this is to add a method to the dialog that returns the current settings as a dictionary:

python
def get_settings(self):
    """Return current dialog settings as a dictionary."""
    # Determine selected theme
    if self.theme_light.isChecked():
        theme = "light"
    elif self.theme_dark.isChecked():
        theme = "dark"
    else:
        theme = "system"

    # Determine selected font size
    if self.font_small.isChecked():
        font_size = "small"
    elif self.font_large.isChecked():
        font_size = "large"
    else:
        font_size = "medium"

    return {
        "theme": theme,
        "font_size": font_size,
    }

Add this method to the SettingsDialog class. Now, from your main window, you can open the dialog and only use the result if the user clicked OK:

python
dialog = SettingsDialog(self)
if dialog.exec():
    settings = dialog.get_settings()
    print(settings)

If the user clicks Cancel, exec() returns QDialog.DialogCode.Rejected (which is falsy) and we simply ignore any changes they made. The dialog is discarded and the settings stay as they were.

Persisting settings with QSettings

So far, our settings only exist while the dialog is open. Once you close the application, everything resets. QSettings solves this by providing a simple way to save and load key-value pairs that persist between sessions. On Windows, settings are stored in the registry; on macOS, in a .plist file; on Linux, in a configuration file. You don't need to worry about these details — QSettings handles it all. For a deeper dive into QSettings, see our dedicated guide on how to use QSettings in PyQt6.

To use QSettings, you first tell it your organization name and application name. These are used to create a unique storage location for your app:

python
from PyQt6.QtCore import QSettings

settings = QSettings("MyCompany", "MyApp")

Writing a value is straightforward:

python
settings.setValue("theme", "dark")
settings.setValue("font_size", "large")

And reading it back:

python
theme = settings.value("theme", "system")  # "system" is the default
font_size = settings.value("font_size", "medium")

The second argument to .value() is the default — the value returned if nothing has been saved yet. This is how you handle the very first launch of your app, before the user has changed anything.

Initializing the dialog from saved settings

To make the settings dialog show the user's previously saved choices, we need to load values from QSettings when the dialog is created and set the appropriate radio buttons. Add a load_settings method to the dialog:

python
def load_settings(self):
    """Load saved settings and update widgets."""
    settings = QSettings("MyCompany", "MyApp")

    theme = settings.value("theme", "system")
    if theme == "light":
        self.theme_light.setChecked(True)
    elif theme == "dark":
        self.theme_dark.setChecked(True)
    else:
        self.theme_system.setChecked(True)

    font_size = settings.value("font_size", "medium")
    if font_size == "small":
        self.font_small.setChecked(True)
    elif font_size == "large":
        self.font_large.setChecked(True)
    else:
        self.font_medium.setChecked(True)

Call self.load_settings() at the end of __init__ to populate the dialog with saved values whenever it opens.

Saving settings on accept

When the user clicks OK, we want to write their choices to QSettings. We can override the accept method to do this automatically:

python
def accept(self):
    """Save settings and close the dialog."""
    settings = QSettings("MyCompany", "MyApp")
    current = self.get_settings()
    settings.setValue("theme", current["theme"])
    settings.setValue("font_size", current["font_size"])
    super().accept()

By calling super().accept() at the end, we preserve the normal dialog behavior (closing the window and returning QDialog.DialogCode.Accepted).

Mapping widgets to settings automatically

As the number of settings grows, manually reading and writing each widget becomes tedious. A useful pattern is to create a mapping between setting names and their corresponding widgets, along with the getter and setter methods for each widget type. This lets you loop over all settings and handle them generically.

Here's how that looks:

python
# Map widget classes to their getter/setter method names
WIDGET_MAPPERS = {
    "QRadioButton": ("isChecked", "setChecked"),
    "QCheckBox": ("isChecked", "setChecked"),
    "QLineEdit": ("text", "setText"),
    "QSpinBox": ("value", "setValue"),
}

Then, for widgets like checkboxes and spin boxes that have a direct one-to-one mapping with a setting, you can define the mapping in your dialog:

python
self.options = {
    "notifications_enabled": self.notifications_checkbox,
    "max_retries": self.retries_spinbox,
    "username": self.username_input,
}

And use generic load/save functions:

python
def load_settings(self):
    settings = QSettings("MyCompany", "MyApp")
    defaults = {
        "notifications_enabled": True,
        "max_retries": 3,
        "username": "",
    }
    for key, widget in self.options.items():
        class_name = widget.__class__.__name__
        _, setter_name = WIDGET_MAPPERS[class_name]
        setter = getattr(widget, setter_name)
        value = settings.value(key, defaults.get(key))
        setter(value)


def save_settings(self):
    settings = QSettings("MyCompany", "MyApp")
    for key, widget in self.options.items():
        class_name = widget.__class__.__name__
        getter_name, _ = WIDGET_MAPPERS[class_name]
        getter = getattr(widget, getter_name)
        settings.setValue(key, getter())

This approach scales well. When you add a new setting, you only need to add one entry to the options dictionary and one default value — no new load/save code required.

Radio buttons are a bit different since they represent a group of choices mapping to a single setting. For those, you can continue using the explicit if/elif approach or create a small helper that maps string values to button objects.

Complete working example

Here's everything put together — a main window with a menu action that opens a settings dialog. Settings are saved with QSettings and restored when the dialog is reopened. The main window reads the settings on startup and applies them. The menu bar is set up using actions, toolbars, and menus.

python
import sys

from PyQt6.QtCore import QSettings
from PyQt6.QtWidgets import (
    QApplication,
    QCheckBox,
    QDialog,
    QDialogButtonBox,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QMainWindow,
    QPushButton,
    QRadioButton,
    QSpinBox,
    QVBoxLayout,
    QWidget,
)

COMPANY_NAME = "MyCompany"
APP_NAME = "MyApp"

DEFAULTS = {
    "theme": "system",
    "font_size": "medium",
    "show_toolbar": True,
    "max_recent_files": 5,
}


class SettingsDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)

        self.setWindowTitle("Settings")
        self.setMinimumWidth(350)

        # --- Theme group ---
        theme_group = QGroupBox("Theme")
        self.theme_light = QRadioButton("Light")
        self.theme_dark = QRadioButton("Dark")
        self.theme_system = QRadioButton("System default")

        theme_layout = QVBoxLayout()
        theme_layout.addWidget(self.theme_light)
        theme_layout.addWidget(self.theme_dark)
        theme_layout.addWidget(self.theme_system)
        theme_group.setLayout(theme_layout)

        # Map theme strings to radio buttons for easy lookup
        self.theme_buttons = {
            "light": self.theme_light,
            "dark": self.theme_dark,
            "system": self.theme_system,
        }

        # --- Font size group ---
        font_group = QGroupBox("Font size")
        self.font_small = QRadioButton("Small")
        self.font_medium = QRadioButton("Medium")
        self.font_large = QRadioButton("Large")

        font_layout = QVBoxLayout()
        font_layout.addWidget(self.font_small)
        font_layout.addWidget(self.font_medium)
        font_layout.addWidget(self.font_large)
        font_group.setLayout(font_layout)

        self.font_buttons = {
            "small": self.font_small,
            "medium": self.font_medium,
            "large": self.font_large,
        }

        # --- General settings ---
        general_group = QGroupBox("General")
        self.show_toolbar_checkbox = QCheckBox("Show toolbar")

        max_recent_layout = QHBoxLayout()
        max_recent_label = QLabel("Max recent files:")
        self.max_recent_spinbox = QSpinBox()
        self.max_recent_spinbox.setRange(1, 20)
        max_recent_layout.addWidget(max_recent_label)
        max_recent_layout.addWidget(self.max_recent_spinbox)

        general_layout = QVBoxLayout()
        general_layout.addWidget(self.show_toolbar_checkbox)
        general_layout.addLayout(max_recent_layout)
        general_group.setLayout(general_layout)

        # --- Dialog buttons ---
        buttons = (
            QDialogButtonBox.StandardButton.Ok
            | QDialogButtonBox.StandardButton.Cancel
        )
        button_box = QDialogButtonBox(buttons)
        button_box.accepted.connect(self.accept)
        button_box.rejected.connect(self.reject)

        # --- Main layout ---
        layout = QVBoxLayout()
        layout.addWidget(theme_group)
        layout.addWidget(font_group)
        layout.addWidget(general_group)
        layout.addWidget(button_box)
        self.setLayout(layout)

        # Load saved settings into widgets
        self.load_settings()

    def load_settings(self):
        """Populate widgets from saved QSettings values."""
        settings = QSettings(COMPANY_NAME, APP_NAME)

        # Theme radio buttons
        theme = settings.value("theme", DEFAULTS["theme"])
        button = self.theme_buttons.get(theme, self.theme_system)
        button.setChecked(True)

        # Font size radio buttons
        font_size = settings.value("font_size", DEFAULTS["font_size"])
        button = self.font_buttons.get(font_size, self.font_medium)
        button.setChecked(True)

        # Checkbox — QSettings may return strings, so handle both
        show_toolbar = settings.value(
            "show_toolbar", DEFAULTS["show_toolbar"]
        )
        if isinstance(show_toolbar, str):
            show_toolbar = show_toolbar.lower() == "true"
        self.show_toolbar_checkbox.setChecked(show_toolbar)

        # Spinbox
        max_recent = int(
            settings.value(
                "max_recent_files", DEFAULTS["max_recent_files"]
            )
        )
        self.max_recent_spinbox.setValue(max_recent)

    def get_settings(self):
        """Return current widget values as a dictionary."""
        # Find selected theme
        theme = DEFAULTS["theme"]
        for value, button in self.theme_buttons.items():
            if button.isChecked():
                theme = value
                break

        # Find selected font size
        font_size = DEFAULTS["font_size"]
        for value, button in self.font_buttons.items():
            if button.isChecked():
                font_size = value
                break

        return {
            "theme": theme,
            "font_size": font_size,
            "show_toolbar": self.show_toolbar_checkbox.isChecked(),
            "max_recent_files": self.max_recent_spinbox.value(),
        }

    def accept(self):
        """Save settings to QSettings and close."""
        settings = QSettings(COMPANY_NAME, APP_NAME)
        current = self.get_settings()
        for key, value in current.items():
            settings.setValue(key, value)
        super().accept()


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("My Application")
        self.setMinimumSize(500, 300)

        # Menu bar with Settings action
        menu_bar = self.menuBar()
        edit_menu = menu_bar.addMenu("&Edit")
        settings_action = edit_menu.addAction("&Settings...")
        settings_action.triggered.connect(self.open_settings)

        # Central widget showing current settings
        self.info_label = QLabel()
        self.info_label.setWordWrap(True)

        central = QWidget()
        layout = QVBoxLayout()
        layout.addWidget(QLabel("Current settings:"))
        layout.addWidget(self.info_label)
        layout.addStretch()

        open_settings_btn = QPushButton("Open Settings")
        open_settings_btn.clicked.connect(self.open_settings)
        layout.addWidget(open_settings_btn)

        central.setLayout(layout)
        self.setCentralWidget(central)

        # Display current settings
        self.refresh_display()

    def open_settings(self):
        """Open the settings dialog and refresh display if accepted."""
        dialog = SettingsDialog(self)
        if dialog.exec():
            self.refresh_display()

    def refresh_display(self):
        """Read settings from QSettings and update the display."""
        settings = QSettings(COMPANY_NAME, APP_NAME)

        theme = settings.value("theme", DEFAULTS["theme"])
        font_size = settings.value("font_size", DEFAULTS["font_size"])
        show_toolbar = settings.value(
            "show_toolbar", DEFAULTS["show_toolbar"]
        )
        max_recent = settings.value(
            "max_recent_files", DEFAULTS["max_recent_files"]
        )

        self.info_label.setText(
            f"Theme: {theme}\n"
            f"Font size: {font_size}\n"
            f"Show toolbar: {show_toolbar}\n"
            f"Max recent files: {max_recent}"
        )


app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()

Run this and you'll see a main window with a label showing the current settings. Click the "Open Settings" button (or use Edit → Settings... from the menu bar) to open the settings dialog. Make some changes, click OK, and the main window display updates. Close the application entirely, reopen it, and your settings are still there — QSettings persisted them for you.

A note on QSettings and types

One quirk to be aware of: QSettings stores everything as strings in some backends (notably the INI format on Linux). This means that when you read back a boolean, you might get the string "true" instead of the Python True. That's why the example above includes a type check when loading the checkbox value:

python
if isinstance(show_toolbar, str):
    show_toolbar = show_toolbar.lower() == "true"

For integers, wrapping the value in int() handles the conversion. If you find yourself dealing with this frequently, you can pass a type argument to .value() in PyQt6:

python
show_toolbar = settings.value("show_toolbar", True, type=bool)
max_recent = settings.value("max_recent_files", 5, type=int)

This tells QSettings to convert the value to the specified Python type automatically. Note that the type parameter is specific to PyQt6 — PySide6 does not support it, so for PySide6 you'll need to handle the conversion manually.

Where to go from here

The pattern shown here — a QDialog subclass with load/save methods tied to QSettings — works well for applications of any size. As your settings grow, you might consider:

  • Tabbed settings dialogs: Use a QTabWidget inside your dialog to organize many settings into categories (General, Appearance, Advanced, etc.).
  • Immediate apply: Connect widget signals directly to update the main window in real time, and revert changes if the user clicks Cancel. Understanding signals and slots is key to making this work smoothly.
  • Custom config managers: For large applications, a dedicated settings manager class that wraps QSettings and provides typed access to all your configuration values can keep things tidy.

The foundation you've built here will support all of these approaches. Start simple, and add complexity as your application needs it.

Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak

Create GUI Applications with Python & Qt6 by Martin Fitzpatrick

(PySide6 Edition) The hands-on guide to making apps with Python — Over 15,000 copies sold!

More info Get the book

Martin Fitzpatrick

Create a Settings window was written by Martin Fitzpatrick.

Martin Fitzpatrick has been developing Python/Qt apps for 8 years. Building desktop applications to make data-analysis tools more user-friendly, Python was the obvious choice. Starting with Tk, later moving to wxWidgets and finally adopting PyQt. Martin founded PythonGUIs to provide easy to follow GUI programming tutorials to the Python community. He has written a number of popular Python books on the subject.