Intermediate

Most Python developers live in the terminal and the browser, but there are plenty of situations where you need a real graphical application: a data entry tool for a client who does not want a web server, an offline utility that works without internet, or a mobile app for field technicians who use Python scripts already. Most Python GUI options either look dated (Tkinter), require learning a new language-within-a-language (Qt’s QML), or tie you to a single platform. Kivy does something different — it lets you write Python that runs on Windows, Mac, Linux, Android, and iOS from the same codebase.

Kivy is an open-source Python framework for building touch-ready, GPU-accelerated graphical applications. It uses its own widget toolkit that renders consistently across platforms, so your layout does not look different on each OS. You need Python 3.8+, and pip install kivy handles most desktop setups. Mobile deployment requires additional packaging tools (Buildozer for Android, kivy-ios for iOS), but the development cycle happens on your desktop.

This article covers the Kivy fundamentals you need to build real apps: the App class and widget tree, layouts (BoxLayout, GridLayout, FloatLayout), event handling, the KV language for UI definitions, property bindings, screen management for multi-screen apps, and file dialogs. By the end you will have built a multi-screen expense tracker that runs on any platform.

Kivy App: Quick Example

Install Kivy and run a minimal app to confirm your setup:

# quick_kivy.py
from kivy.app import App
from kivy.uix.label import Label
from kivy.uix.button import Button
from kivy.uix.boxlayout import BoxLayout

class QuickApp(App):
    def build(self):
        layout = BoxLayout(orientation="vertical", padding=20, spacing=10)

        self.label = Label(text="Click the button!", font_size=24)
        btn = Button(text="Click Me", size_hint=(1, 0.3), font_size=18)
        btn.bind(on_press=self.on_button_press)

        layout.add_widget(self.label)
        layout.add_widget(btn)
        return layout

    def on_button_press(self, instance):
        self.label.text = "Button pressed!"

if __name__ == "__main__":
    QuickApp().run()

Expected behavior:

# A window opens with a label and a button.
# Clicking the button changes the label text to "Button pressed!"
# Close the window to exit.

Every Kivy app inherits from App and implements a build() method that returns the root widget. Widgets are composed into a tree: the root widget contains child widgets, which can contain further children. BoxLayout arranges children vertically or horizontally. The bind() method connects events to handler methods — on_press fires when a button is clicked or tapped.

Kivy widgets and layouts stacking
Kivy widgets: it’s turtles and layouts all the way down.

What Is Kivy and When Should You Use It?

Kivy renders every widget using OpenGL ES 2 — the same graphics API used by mobile games. This means your UI looks identical on every platform and handles touch input natively (no retrofitted touch support). The tradeoff is that Kivy apps do not look like native OS apps — the buttons and text fields have Kivy’s own style, not Windows or macOS native controls.

FrameworkPlatformsNative LookTouchBest For
TkinterDesktop onlyYesNoSimple utilities
PyQt/PySideDesktop onlyYesPartialComplex desktop apps
wxPythonDesktop onlyYesNoTraditional GUIs
KivyDesktop + MobileNo (own style)YesCross-platform + mobile
BeeWareDesktop + MobileYesYesNative-looking mobile

Use Kivy when you need mobile deployment or touch input, when you have existing Python business logic and want to wrap it in a GUI, or when you need the same UI to work on a touchscreen kiosk and a desktop simultaneously. If you need your app to look indistinguishable from a native macOS or Windows application, BeeWare or a web-based approach may serve you better.

Layouts and Widget Positioning

Kivy has several layout classes. BoxLayout is the most common — it arranges children in a horizontal or vertical line. GridLayout creates a grid, and FloatLayout lets you position widgets with precise coordinates or percentages:

# layouts_demo.py
from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.label import Label
from kivy.uix.button import Button
from kivy.uix.textinput import TextInput

class LayoutDemoApp(App):
    def build(self):
        # Main vertical layout
        main = BoxLayout(orientation="vertical", padding=10, spacing=5)

        # Header label
        main.add_widget(Label(
            text="Simple Calculator",
            font_size=22,
            size_hint=(1, 0.1)
        ))

        # Input field
        self.input = TextInput(
            hint_text="Enter a number",
            multiline=False,
            size_hint=(1, 0.1),
            font_size=18
        )
        main.add_widget(self.input)

        # 4x4 grid of number buttons
        grid = GridLayout(cols=4, size_hint=(1, 0.6), spacing=3)
        for label in ["7","8","9","/", "4","5","6","*", "1","2","3","-", "C","0","=","+"]:
            btn = Button(text=label, font_size=20)
            btn.bind(on_press=self.on_key)
            grid.add_widget(btn)
        main.add_widget(grid)

        # Result display
        self.result = Label(text="", font_size=20, size_hint=(1, 0.1))
        main.add_widget(self.result)

        return main

    def on_key(self, instance):
        key = instance.text
        if key == "C":
            self.input.text = ""
            self.result.text = ""
        elif key == "=":
            try:
                self.result.text = str(eval(self.input.text))
            except Exception:
                self.result.text = "Error"
        else:
            self.input.text += key

if __name__ == "__main__":
    LayoutDemoApp().run()

Expected behavior:

# A calculator window opens.
# Clicking number buttons appends digits to the input field.
# Clicking = evaluates the expression and shows the result.
# Clicking C clears the input and result.

The size_hint property controls how much of the parent’s space a widget takes — size_hint=(1, 0.1) means “use 100% of the width and 10% of the height.” Setting size_hint=None and using size=(width, height) with pixel values gives you fixed sizes instead. GridLayout requires a cols (or rows) parameter; it fills in grid cells in order, left-to-right and top-to-bottom.

Kivy size_hint layout system
size_hint=(1, 0.1): your layout math, Kivy’s problem to render.

The KV Language for UI Definitions

Writing complex UIs in Python code gets verbose. Kivy includes KV language — a YAML-like DSL for describing widget trees separately from Python logic. This keeps your UI definition out of your business logic:

# my_app.kv -- saved alongside my_app.py
# Kivy automatically loads this file when your App class is named "MyApp"

<RootWidget>:
    orientation: "vertical"
    padding: 20
    spacing: 10

    Label:
        text: "Welcome to My App"
        font_size: 28
        size_hint: (1, 0.2)
        color: 0.2, 0.6, 1, 1  # RGBA

    TextInput:
        id: name_input
        hint_text: "Enter your name"
        size_hint: (1, 0.15)
        multiline: False

    Button:
        text: "Greet Me"
        size_hint: (0.5, 0.15)
        pos_hint: {"center_x": 0.5}
        on_press: root.greet(name_input.text)

    Label:
        id: greeting_label
        text: ""
        font_size: 20
        size_hint: (1, 0.2)
# my_app.py -- Python code stays clean
from kivy.app import App
from kivy.uix.boxlayout import BoxLayout

class RootWidget(BoxLayout):
    def greet(self, name: str) -> None:
        if name.strip():
            self.ids.greeting_label.text = f"Hello, {name}!"
        else:
            self.ids.greeting_label.text = "Please enter your name."

class MyApp(App):
    def build(self):
        return RootWidget()

if __name__ == "__main__":
    MyApp().run()

Expected behavior:

# Window with a text field and button.
# Type a name, click Greet Me.
# Label shows: "Hello, [name]!"

The KV file is named after your App class in lowercase (MyApp loads my_app.kv). Widget IDs declared in KV become accessible in Python via self.ids.widget_id. The on_press: root.greet(name_input.text) line in KV directly calls a Python method and passes a value from another widget — all without writing binding code in Python. This separation is similar to how HTML/CSS/JavaScript work: structure in KV, logic in Python.

Multi-Screen Navigation with ScreenManager

Real apps have multiple screens. Kivy’s ScreenManager handles navigation between named screens:

# screen_navigation.py
from kivy.app import App
from kivy.uix.screenmanager import ScreenManager, Screen
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.label import Label
from kivy.uix.textinput import TextInput

class LoginScreen(Screen):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        layout = BoxLayout(orientation="vertical", padding=40, spacing=15)
        layout.add_widget(Label(text="Login", font_size=28, size_hint=(1, 0.2)))
        self.username = TextInput(hint_text="Username", multiline=False, size_hint=(1, 0.15))
        self.password = TextInput(hint_text="Password", password=True, multiline=False, size_hint=(1, 0.15))
        btn = Button(text="Login", size_hint=(0.5, 0.15), pos_hint={"center_x": 0.5})
        btn.bind(on_press=self.do_login)
        layout.add_widget(self.username)
        layout.add_widget(self.password)
        layout.add_widget(btn)
        self.add_widget(layout)

    def do_login(self, instance):
        # In a real app: validate credentials here
        if self.username.text:
            self.manager.current = "dashboard"
            self.manager.get_screen("dashboard").set_user(self.username.text)

class DashboardScreen(Screen):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        layout = BoxLayout(orientation="vertical", padding=20, spacing=10)
        self.welcome = Label(text="Welcome!", font_size=22, size_hint=(1, 0.2))
        btn = Button(text="Logout", size_hint=(0.4, 0.12), pos_hint={"center_x": 0.5})
        btn.bind(on_press=lambda x: setattr(self.manager, "current", "login"))
        layout.add_widget(self.welcome)
        layout.add_widget(btn)
        self.add_widget(layout)

    def set_user(self, username: str) -> None:
        self.welcome.text = f"Welcome, {username}!"

class MultiScreenApp(App):
    def build(self):
        sm = ScreenManager()
        sm.add_widget(LoginScreen(name="login"))
        sm.add_widget(DashboardScreen(name="dashboard"))
        return sm

if __name__ == "__main__":
    MultiScreenApp().run()

Expected behavior:

# Login screen appears first.
# Enter any username, click Login.
# Transitions to Dashboard showing "Welcome, [username]!"
# Logout returns to Login screen.

Switching screens is as simple as setting self.manager.current = "screen_name". Screens can pass data to each other by calling methods on the target screen before or after the transition. The ScreenManager also supports transition animations — SlideTransition, FadeTransition, and SwapTransition — by setting ScreenManager(transition=SlideTransition()).

Real-Life Example: Expense Tracker App

Kivy ScrollView and TextInput app
ScrollView + TextInput + a list: the whole app architecture, visible.
# expense_tracker.py
"""
Simple cross-platform expense tracker using Kivy.
Runs on Windows, Mac, Linux, Android, iOS from the same code.
"""
from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.scrollview import ScrollView
from kivy.uix.label import Label
from kivy.uix.button import Button
from kivy.uix.textinput import TextInput
from kivy.uix.popup import Popup
from dataclasses import dataclass, field
from datetime import datetime
from typing import List
import json, os

@dataclass
class Expense:
    description: str
    amount: float
    date: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d"))

DATA_FILE = "expenses.json"

def load_expenses() -> List[Expense]:
    if os.path.exists(DATA_FILE):
        with open(DATA_FILE) as f:
            return [Expense(**e) for e in json.load(f)]
    return []

def save_expenses(expenses: List[Expense]) -> None:
    with open(DATA_FILE, "w") as f:
        json.dump([e.__dict__ for e in expenses], f, indent=2)

class ExpenseApp(App):
    def build(self):
        self.expenses: List[Expense] = load_expenses()

        main = BoxLayout(orientation="vertical", padding=10, spacing=8)

        # Title
        main.add_widget(Label(text="Expense Tracker", font_size=24, size_hint=(1, 0.08)))

        # Input row
        input_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.1), spacing=5)
        self.desc_input = TextInput(hint_text="Description", multiline=False, size_hint=(0.5, 1))
        self.amt_input = TextInput(hint_text="Amount", multiline=False, input_filter="float", size_hint=(0.25, 1))
        add_btn = Button(text="Add", size_hint=(0.25, 1))
        add_btn.bind(on_press=self.add_expense)
        input_row.add_widget(self.desc_input)
        input_row.add_widget(self.amt_input)
        input_row.add_widget(add_btn)
        main.add_widget(input_row)

        # Total label
        self.total_label = Label(text="Total: $0.00", font_size=18, size_hint=(1, 0.07))
        main.add_widget(self.total_label)

        # Scrollable expense list
        scroll = ScrollView(size_hint=(1, 0.75))
        self.list_layout = GridLayout(cols=1, spacing=3, size_hint_y=None)
        self.list_layout.bind(minimum_height=self.list_layout.setter("height"))
        scroll.add_widget(self.list_layout)
        main.add_widget(scroll)

        self.refresh_list()
        return main

    def add_expense(self, instance):
        desc = self.desc_input.text.strip()
        try:
            amount = float(self.amt_input.text)
        except ValueError:
            self.show_error("Enter a valid amount")
            return
        if not desc:
            self.show_error("Enter a description")
            return
        self.expenses.append(Expense(description=desc, amount=amount))
        save_expenses(self.expenses)
        self.desc_input.text = ""
        self.amt_input.text = ""
        self.refresh_list()

    def refresh_list(self):
        self.list_layout.clear_widgets()
        total = 0.0
        for expense in reversed(self.expenses):
            row = BoxLayout(orientation="horizontal", size_hint=(1, None), height=44, spacing=3)
            row.add_widget(Label(text=expense.date, size_hint=(0.2, 1), font_size=12))
            row.add_widget(Label(text=expense.description, size_hint=(0.5, 1), font_size=14))
            row.add_widget(Label(text=f"${expense.amount:.2f}", size_hint=(0.3, 1), font_size=14))
            self.list_layout.add_widget(row)
            total += expense.amount
        self.total_label.text = f"Total: ${total:.2f}"

    def show_error(self, message: str) -> None:
        popup = Popup(title="Error", size_hint=(0.7, 0.3),
                      content=Label(text=message))
        popup.open()

if __name__ == "__main__":
    ExpenseApp().run()

Expected behavior:

# App opens showing an empty expense list.
# Enter "Coffee" and 4.50, click Add.
# List shows: 2026-05-19 | Coffee | $4.50 | Total: $4.50
# Data is saved to expenses.json and persists between runs.

To deploy this to Android, install Buildozer (pip install buildozer), run buildozer init to generate a spec file, then buildozer android debug to compile the APK. The Python code does not change — Buildozer packages your Kivy app with a Python interpreter into a standard Android APK. iOS packaging works similarly with kivy-ios on macOS.

Frequently Asked Questions

How hard is it to deploy a Kivy app to Android?

Buildozer automates most of the Android packaging process. You run buildozer android debug and it downloads the Android NDK, compiles your Python and dependencies, and produces an APK. The first build takes 20-30 minutes; subsequent builds are much faster. Common issues include missing dependencies in your buildozer.spec requirements list and library compatibility. Pure Python libraries work well; C extension libraries need ARM-compatible wheels or must be compiled by Buildozer.

Is Kivy fast enough for real apps?

For business apps (forms, lists, data display), Kivy’s performance is more than adequate. The GPU-accelerated rendering means the UI stays responsive. For computation-heavy tasks, use a background thread with threading.Thread and update the UI via Clock.schedule_once(callback, 0) from the main Kivy thread — direct UI updates from background threads cause crashes. Do not run heavy computation in event handlers.

Should I use KV language or pure Python for layouts?

KV language is strongly recommended for anything beyond a handful of widgets. The declarative structure is much easier to read and maintain than nested Python constructor calls. Use Python for logic (event handlers, data processing, state management) and KV for structure (widget tree, positions, styles, property bindings). You can mix both: define the widget tree in KV and modify widget properties from Python via self.ids.

Can I change Kivy’s default visual style?

Yes — Kivy supports custom themes via the Atlas system and custom widget styling. At the basic level, every widget has a background_color, color, and font_size you can set. For consistent theming, define a custom style in your KV file and inherit from it in all your widgets. The KivyMD library (pip install kivymd) provides Material Design widgets built on top of Kivy if you want a modern, polished look.

How do I store data in a Kivy app?

For simple key-value storage, use kivy.storage.jsonstore.JsonStore — it handles the platform-appropriate storage location automatically. For structured data with relationships, use SQLite via Python’s built-in sqlite3 module. For cloud sync, call a REST API from a background thread. On Android, the app has read/write access to its own data directory; requesting permission for external storage (photos, downloads) is done via Plyer, the Kivy-maintained platform API library.

Conclusion

Kivy lets you build genuinely cross-platform graphical applications with Python you already know. We covered the App class and widget tree, BoxLayout and GridLayout for positioning, the KV language for clean UI definitions, the ScreenManager for multi-screen navigation, and built a complete expense tracker that saves data and runs on any platform.

The natural next step is to add a chart screen to the expense tracker using kivy.garden.matplotlib for spending trends, or to package it as an Android APK using Buildozer. The app structure you have already is solid enough to extend without rewriting — add screens via ScreenManager and add features to existing screens without touching the navigation logic.

Full documentation is at kivy.org/doc/stable. The Android packaging guide covers the full Buildozer workflow in detail.