2025, Oct 26 19:00

Reading from stdin with a timeout in Python: using select and raw mode to avoid blocking I/O

Learn why os.read blocks and how to implement a reliable stdin timeout in Python using select, raw terminal mode, and safe cleanup on Unix-like systems.

Reading from stdin with a timeout sounds simple until you meet blocking I/O and terminal modes. A common trap is to assume an argument to a read call is a timeout in seconds, when in reality it’s the maximum number of bytes to fetch. The result is a script that appears to hang and never times out. Let’s walk through what’s happening and how to do it correctly on Unix-like systems.

Reproducing the issue

The snippet below tries to read user input with what looks like a 5-second timeout. Instead, it blocks until input arrives and never returns None when the user stays silent.

import os
import tty
import sys
import time
in_fd = sys.stdin.fileno()
tty.setraw(in_fd)
print("Enter some text:", end=' ', flush=True)
start_ts = time.time()
buf = os.read(in_fd, 5)
print("Time taken:", time.time() - start_ts, "d:", buf)

Why it behaves this way

There are two key pieces to understand. First, stdin is a blocking file descriptor by default. Calling os.read on it waits until there’s data to consume. The second piece is the signature of os.read: the second argument isn’t a timeout, it’s the number of bytes to read. Passing 5 means “read up to five bytes,” not “wait five seconds.” That’s why the code sits at the prompt indefinitely until input is available. In raw mode, the terminal delivers keystrokes immediately and doesn’t line-buffer, which is why input shows up character-by-character, but that doesn’t make the read call non-blocking or time-aware.

stdin is a waitable object though, which means you can ask the OS whether there’s data ready to read before you actually read it. The tool for that is select with a timeout. If select indicates readiness within the given interval, you proceed; otherwise, you treat it as a timeout.

The fix: use select with a timeout and manage raw mode safely

The approach below sets the terminal to raw mode, prompts the user, then waits up to the specified timeout using select. If data arrives, it handles control characters you’d expect from an interactive prompt, including backspace, carriage return, and escape sequences. If the timeout expires first, it returns None. Critically, it also restores the terminal settings when it exits.

from select import select
from sys import stdin
from tty import setraw
from termios import tcsetattr, TCSAFLUSH
from functools import partial
BK = "\b"
DELETE = "\x7f"
SIGINT = "\x03"
RET = "\r"
ESCAPE = "\x1b"
CSI = ESCAPE + "["
ERASE_LINE = CSI + "K"
REFRESH = RET + ERASE_LINE
ECHO = partial(print, end="", flush=True)
CSI_DIGITS = set("0123456789;?")
def is_ready(fd: int, timeout: float) -> bool:
    r, _, _ = select([fd], [], [], timeout)
    return bool(r)
def swallow_escape() -> None:
    if stdin.read(1) == "[":
        while stdin.read(1) in CSI_DIGITS:
            pass
def read_with_timeout(prompt: str = "", timeout: float = 0.0) -> str | None:
    if timeout <= 0.0:
        return input(prompt)
    ECHO(prompt)
    fd = stdin.fileno()
    prev_attrs = setraw(fd)
    buf = ""
    try:
        while True:
            if not is_ready(fd, timeout):
                return None
            ch = stdin.read(1)
            if ch in {RET, SIGINT}:
                break
            if ch == ESCAPE:
                swallow_escape()
                continue
            if ch in {BK, DELETE}:
                if buf:
                    buf = buf[:-1]
                    ECHO(f"{REFRESH}{prompt}{buf}")
            else:
                buf += ch
                ECHO(ch)
    finally:
        tcsetattr(fd, TCSAFLUSH, prev_attrs)
        print()
    return buf
if __name__ == "__main__":
    print(read_with_timeout("Enter something: ", 5))

This may only work on Unix-like platforms.

What’s actually going on

stdin can be used with select to check readiness. That call accepts a timeout and returns when the descriptor becomes readable or the timeout elapses. By combining it with raw mode, you get immediate delivery of keystrokes without canonical line editing, and you retain precise control over what constitutes “finished input.” The code above accepts carriage return to finalize input and honors Ctrl-C, ignores escape sequences, and redraws the line when handling backspace or delete to keep the prompt clean.

If you’re wondering why the initial attempt seemed to accept input one character at a time, that’s the effect of raw mode: characters are made available to your process immediately. It doesn’t change the fact that a blocking read will wait until there’s at least one byte, and the “5” you passed just limits how many bytes are returned, not how long the call waits.

There’s also another viable pattern: running a dedicated reader in a separate thread that performs a blocking read from the keyboard, pushes the result into a queue, and letting the main thread call get on that queue with a timeout. That also avoids blocking the main flow while still providing a timeout.

Why this matters

Interactive CLI tools, TUI apps, and REPL-like utilities routinely mix user input with timers, progress feedback, and other asynchronous events. Misunderstanding how blocking I/O works leads to unresponsive programs and brittle UX. Using select with a timeout against stdin gives you predictable behavior and the control necessary to build responsive terminal interactions.

Takeaways

When you need a timeout on terminal input, don’t overload os.read with expectations it doesn’t meet. Treat stdin as a waitable source, call select with a timeout, and only then read. Remember to switch the terminal to raw mode when you want immediate keystrokes and to restore it afterward. If your design prefers not to manipulate terminal modes directly, pushing the blocking read into a worker thread with a queue is another option.

The article is based on a question from StackOverflow by DeepThought42 and an answer by Ramrab.