Python curses: Build Terminal Interfaces

Published on: August 26, 2026
Reading time: 6 minutes
Vivid close-up of code on a computer screen showcasing programming details.

The curses module builds advanced text interfaces for character-cell terminals, including windows, colors, menus, keyboard and mouse input, partial screen updates, and resize-aware layouts. It uses curses or ncurses instead of hardcoded ANSI sequences.

It is a strong choice for local dashboards, process monitors, installers, file managers, administration tools, and programs used over SSH. Reliable software must restore the terminal after errors, respect capabilities reported by terminfo, and handle Unicode, resize events, and very small screens.

Availability

curses is an optional module normally available on Unix. It is not supported on Android, iOS, or WASI. Windows may require a compatible third-party package.

try:
    import curses
except ImportError:
    curses = None

Provide a clear fallback or non-interactive mode when the module is unavailable.

Start with wrapper()

curses.wrapper() initializes curses, enables cbreak, disables echo, enables keypad decoding, initializes colors when supported, and restores the terminal when the function exits. It also restores state before re-raising an exception.

import curses


def main(stdscr):
    stdscr.clear()
    stdscr.addstr(0, 0, "Hello, terminal")
    stdscr.refresh()
    stdscr.getch()

curses.wrapper(main)

Prefer this to manual initscr() and endwin(). A traceback appears only after the terminal is sane again.

Coordinates

Curses uses (y, x): row first, column second. The upper-left corner is (0, 0).

height, width = stdscr.getmaxyx()
y = height // 2
x = width // 2
stdscr.addstr(y, max(0, x - 5), "Centered")

Mixing x and y is a common source of out-of-bounds writes.

Bound every write

addstr() and addch() raise curses.error when drawing outside a window. Writing the lower-right cell can also raise after printing.

def safe_addstr(win, y, x, text, attr=0):
    height, width = win.getmaxyx()
    if not (0 <= y < height and 0 <= x < width):
        return
    available = max(0, width - x - 1)
    if available:
        win.addnstr(y, x, text, available, attr)

Treat a tiny terminal as a normal state rather than a crash condition.

An event loop

def main(stdscr):
    stdscr.keypad(True)
    while True:
        stdscr.erase()
        stdscr.addstr(0, 0, "Press q to quit")
        stdscr.refresh()

        key = stdscr.getch()
        if key in (ord("q"), ord("Q")):
            break

Long-running work should not block redraw and cancellation. Move it to workers or split it into short units.

Special keys

With keypad(True), terminal sequences become constants such as KEY_UP, KEY_DOWN, KEY_LEFT, and KEY_RIGHT.

if key == curses.KEY_UP:
    selected = max(0, selected - 1)
elif key == curses.KEY_DOWN:
    selected = min(len(items) - 1, selected + 1)

Not every terminal exposes every key. Avoid making one uncommon key the only way to use a feature.

getch(), getkey(), and get_wch()

getch() returns an integer. getkey() returns text or a key name. get_wch() is better for Unicode: ordinary input returns a character and special keys return integers.

value = stdscr.get_wch()
if isinstance(value, str):
    handle_text(value)
elif value == curses.KEY_RESIZE:
    needs_redraw = True

Configure locale before initializing curses.

Timed and non-blocking input

nodelay(True) makes getch() return -1 when no input is ready. timeout(ms) waits for a bounded interval.

stdscr.timeout(100)
while True:
    key = stdscr.getch()
    update_metrics()
    if key == ord("q"):
        break

Do not create a busy loop with zero timeout and no sleep or meaningful work.

halfdelay()

halfdelay(tenths) waits from 0.1 to 25.5 seconds and raises a curses error if no key arrives. Window-specific timeout() is often easier to integrate.

Escape timing

set_escdelay(ms) controls how long curses waits after ESC to distinguish a standalone Escape key from a function-key sequence. Tune it for local terminals and slower SSH links.

Windows

newwin() creates independent screen regions.

height, width = stdscr.getmaxyx()
menu = curses.newwin(height - 2, 30, 1, 0)
content = curses.newwin(height - 2, width - 30, 1, 30)
menu.box()
content.box()

Subwindows share storage with parents, so drawing and refresh order must be coordinated.

Pads

newpad() creates an area larger than the physical screen, useful for logs and documents.

pad = curses.newpad(1000, 200)
for index in range(1000):
    pad.addstr(index, 0, f"Line {index}")
pad.refresh(offset, 0, 1, 0, height - 2, width - 1)

Validate both the virtual and screen rectangles before refreshing.

Update several windows efficiently

Repeated refresh() calls can increase flicker. Call noutrefresh() for each window and then one doupdate().

menu.noutrefresh()
content.noutrefresh()
curses.doupdate()

Colors

Call start_color() and check has_colors().

if curses.has_colors():
    curses.start_color()
    curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK)
    stdscr.addstr(0, 0, "OK", curses.color_pair(1))

COLORS and COLOR_PAIRS are available after color initialization.

Default colors in Python 3.14

assume_default_colors(fg, bg), added in Python 3.14, assigns terminal-default colors and can preserve a transparent background.

if hasattr(curses, "assume_default_colors"):
    curses.assume_default_colors(-1, -1)
    curses.init_pair(1, curses.COLOR_CYAN, -1)

Provide a fallback for older Python and terminals lacking the extension.

Visual attributes

A_BOLD, A_REVERSE, A_UNDERLINE, and A_DIM can be combined with color pairs.

Do not communicate state through color alone. Some terminals lack attributes, and users may have color-vision limitations.

Unicode display width

Python string length is not terminal-cell width. East Asian characters may occupy two cells, while combining marks may occupy zero.

Use a width-aware library such as wcwidth for robust alignment, and test emoji and combining sequences.

Window encoding

Each window has an encoding attribute, normally derived from the locale.

import locale
locale.setlocale(locale.LC_ALL, "")

Set locale once before curses initialization rather than changing it during the event loop.

Resize events

A terminal resize may produce KEY_RESIZE. Query dimensions again, rebuild layout, and redraw from the application model.

if key == curses.KEY_RESIZE:
    curses.update_lines_cols()
    stdscr.erase()
    rebuild_layout(stdscr)

resizeterm() updates standard windows, but pads require application-specific handling.

Minimum dimensions

Define a supported minimum and display a simple message when the terminal is too small.

height, width = stdscr.getmaxyx()
if height < 10 or width < 40:
    safe_addstr(stdscr, 0, 0, "Please enlarge the terminal")
    stdscr.refresh()
    return

Mouse input

mousemask() enables requested events. After KEY_MOUSE, call getmouse().

Mouse support varies across terminal emulators, tmux, screen, and SSH. Always provide keyboard navigation.

Text fields

curses.textpad.Textbox provides Emacs-like editing. Validate final text and cap its size.

Python 3.14 raises the maximum getstr() and instr() length from 1023 to 2047 characters, but applications should still impose smaller domain limits.

Handle curses.error carefully

Small terminals and boundary writes can raise curses.error. Catch expected local cases, but avoid a global except curses.error: pass that hides layout bugs.

TERM and terminfo

Curses uses the terminfo entry selected by TERM. Incorrect values can produce broken keys, colors, and cursor movement.

Do not force xterm-256color blindly. Fix the environment or install the proper terminfo entry.

Do not mix raw ANSI output

Printing escape sequences directly while curses is active can desynchronize its virtual and physical screen models. Use curses functions for cursor movement, attributes, and clearing.

Subprocesses

Before running an interactive external program, temporarily restore shell mode or close and recreate the curses interface.

For isolated automation, see Python pty.

Relationship to tty and termios

Curses manages cbreak, echo, keypad, and terminal details above the APIs explained in Python tty and Python termios.

Avoid changing termios flags behind curses unless you also understand its internal state.

Threads

Keep all curses calls in one thread. Worker threads can publish model updates through a queue, while the UI thread reads and renders them.

Graceful shutdown

Set a stop flag or event, leave the event loop normally, and let wrapper() restore the terminal.

Signal handlers should remain minimal. Python signal explains how to wake the main loop safely.

Sanitize untrusted text

Remote data may contain controls, newlines, tabs, and escape characters that corrupt layout.

def sanitize_text(value):
    return "".join(ch if ch.isprintable() else "?" for ch in str(value))

Also cap length to avoid expensive redraws and unbounded memory.

Testing

Test tiny terminals, repeated resize, no colors, 8 and 256 colors, Unicode, SSH, tmux, screen, different keyboards, missing mouse, drawing exceptions, and Ctrl+C shutdown.

Use pty for integration tests, but verify rendering manually in real terminals because terminfo and curses implementations differ.

Architecture

Separate application state, commands, and rendering. Read a key, convert it into an action, update the model, and render from the model. Avoid embedding business logic directly in addstr() calls.

Common mistakes

Common failures include skipping wrapper(), drawing outside windows, assuming len() equals display width, relying only on color, ignoring resize, busy-looping, mixing raw ANSI output, calling curses from several threads, and displaying untrusted control characters.

Conclusion

curses creates efficient portable terminal interfaces based on real terminal capabilities. Use wrapper(), validate dimensions, batch refreshes, handle Unicode and resize, and keep the UI in one thread.

Test with actual terminals and terminfo entries, and offer a fallback when curses is unavailable. Consult the official curses documentation and ncurses(3X).

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    Python asynchronous code on a laptop for inspect.markcoroutinefunction
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    markcoroutinefunction: Detect Async Wrappers

    Learn inspect.markcoroutinefunction in Python to identify async wrappers, integrate frameworks, and avoid incorrect coroutine detection.

    Ler mais

    Tempo de leitura: 5 minutos
    10/10/2026
    Python code for traversing folders and files with Path.walk
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Path.walk: Traverse Directories Safely

    Learn Python Path.walk to traverse directories, filter files, handle errors, and control directory-tree processing safely.

    Ler mais

    Tempo de leitura: 5 minutos
    10/10/2026
    Python process debugging in a terminal with source code
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    pdb -p: Debug Running Python Processes

    Learn how to attach pdb to a running Python process, inspect stacks, and diagnose hangs safely.

    Ler mais

    Tempo de leitura: 6 minutos
    09/10/2026
    Python programming code for exact fractions
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    fractions.from_number: Convert Numbers to Fractions

    Learn Python fractions.from_number for exact rational conversion, float and Decimal handling, approximation, validation, and safe arithmetic.

    Ler mais

    Tempo de leitura: 4 minutos
    09/10/2026
    Developer configuring a Python HTTPS server and TLS certificate
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    HTTPSServer: Build a Local HTTPS Server in Python

    Learn Python HTTPSServer for local HTTPS services, TLS certificates, threaded handling, testing, and practical security limits.

    Ler mais

    Tempo de leitura: 4 minutos
    08/10/2026
    Protected files representing secure TAR extraction with Python
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    tarfile extraction_filter: Extract TAR Safely

    Learn Python tarfile extraction_filter for safer TAR extraction, path validation, links, permissions, and resource limits.

    Ler mais

    Tempo de leitura: 5 minutos
    08/10/2026