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).







