Python pty: Automate Unix Terminals

Published on: August 25, 2026
Reading time: 5 minutes
A developer typing code on a laptop with a Python book beside in an office.

The pty module creates pseudo-terminals on Unix. A pseudo-terminal has a master side controlled by the parent program and a slave side that looks like a real terminal to a child process. This lets software send keystrokes, capture output, and test TTY-dependent behavior programmatically.

Pseudo-terminals are useful for interactive CLI tests, session recording, terminal wrappers, and programs that behave differently when connected to a pipe. They are also platform-dependent and require careful handling of EOF, resize, signals, encoding, timeouts, and child-process cleanup.

Availability

pty is Unix-only. The implementation is mainly tested on Linux, FreeBSD, and macOS. Other POSIX platforms may behave differently.

try:
    import pty
except ImportError:
    pty = None

Windows provides ConPTY and other APIs, but not through this standard module.

Master and slave

openpty() returns two file descriptors.

import os
import pty

master_fd, slave_fd = pty.openpty()
try:
    print(os.ttyname(slave_fd))
finally:
    os.close(master_fd)
    os.close(slave_fd)

The controller reads and writes the master. The child uses the slave as standard input, output, error, and its controlling terminal.

Run a subprocess with openpty()

import os
import pty
import subprocess

master, slave = pty.openpty()
process = subprocess.Popen(
    ["python3", "-i"],
    stdin=slave,
    stdout=slave,
    stderr=slave,
    close_fds=True,
)
os.close(slave)

try:
    os.write(master, b"print(2 + 2)\n")
    output = os.read(master, 4096)
    print(output)
finally:
    os.close(master)
    process.terminate()
    process.wait()

Interactive output is fragmented and the process may remain alive indefinitely. One read does not guarantee a complete response.

pty.fork()

pty.fork() creates a child whose controlling terminal is connected to a pseudo-terminal.

import os
import pty

pid, fd = pty.fork()
if pid == 0:
    os.execvp("sh", ["sh"])
else:
    os.write(fd, b"echo ready\n")
    print(os.read(fd, 1024))

In the child, the returned PID is zero and the descriptor is invalid. In the parent, the values are the real child PID and master descriptor.

macOS warning

The official documentation warns that pty.fork() is unsafe on macOS when mixed with higher-level system APIs, including urllib.request. Forking a process that has complex frameworks loaded can leave internal state inconsistent.

Immediately execute a simple program or isolate PTY work in a dedicated process.

pty.spawn()

spawn(argv) starts a process and copies the current terminal’s input to the child and the child’s output to standard output.

import os
import pty

status = pty.spawn(["bash", "-i"])
exit_code = os.waitstatus_to_exitcode(status)
print("Exit code:", exit_code)

The return value is the raw waitpid() status. Convert it with os.waitstatus_to_exitcode().

Read callbacks

spawn() accepts master_read and stdin_read. Each receives a descriptor and must return bytes.

import os
import pty

captured = bytearray()

def read_master(fd):
    data = os.read(fd, 1024)
    captured.extend(data)
    return data

status = pty.spawn(["sh", "-c", "printf 'ok\\n'"], read_master)

Returning b"" signals EOF and the callback will not be called again.

Infinite-loop risk

The documentation warns that if stdin_read signals EOF while the child still waits for input, spawn() may loop forever. A similar situation can occur on Linux when master_read signals EOF before the child exits.

Use an external timeout, monitor the PID, and terminate a child that no longer has a communication path.

PTY EOF

EOF does not always look like a normal pipe. On Linux, reading the master after the slave closes may raise OSError with EIO.

import errno

try:
    data = os.read(master_fd, 4096)
except OSError as exc:
    if exc.errno == errno.EIO:
        data = b""
    else:
        raise

Test this behavior on every supported platform.

Partial writes

os.write() can write fewer bytes than requested.

def write_all(fd, data):
    view = memoryview(data)
    while view:
        sent = os.write(fd, view)
        view = view[sent:]

In non-blocking mode, handle BlockingIOError and wait for readiness.

Read without blocking

import os
import select

readable, _, _ = select.select([master_fd], [], [], 1.0)
if readable:
    chunk = os.read(master_fd, 4096)
else:
    handle_timeout()

Python select explains multiplexing and bounded buffers.

Prompts are not stable protocols

Automating a CLI by matching prompt text is fragile. Language, colors, spacing, versions, and buffering can change.

Prefer a non-interactive API, command-line flags, structured stdin, or an official library. Use a PTY only when terminal behavior is genuinely part of the requirement.

ANSI and colors

When a child detects a TTY, it may emit ANSI colors and cursor movement. Captured output therefore differs from pipe output.

Do not replay untrusted escape sequences directly to a real terminal. They may alter the title, clipboard, or screen state.

Encoding

The master produces bytes. Use an incremental decoder because a UTF-8 character can be split across reads.

import codecs

decoder = codecs.getincrementaldecoder("utf-8")("replace")
text = decoder.decode(chunk)

The child’s locale determines the expected encoding.

Window size

Interactive programs often format output based on rows and columns. Use termios.tcsetwinsize() when available.

import termios

termios.tcsetwinsize(master_fd, (24, 80))

After resizing, send SIGWINCH to the child’s process group when required.

Process groups

Interactive programs may create children and process groups. Terminating only the top PID can leave orphaned processes.

Create a session when appropriate and signal the whole group. Avoid acting on a reused PID without identity checks.

Ctrl+C

Writing b"\x03" to the master simulates the Ctrl+C character when the slave’s terminal discipline converts it to SIGINT.

os.write(master_fd, b"\x03")

This is different from calling os.kill(pid, SIGINT), which sends a signal directly.

Passwords and hidden prompts

Programs can disable echo on the slave, but the master still sends the secret. Do not log every write automatically. A transcript may contain passwords even when they never appear in output.

Record a session

A spawn() callback can store output similarly to the Unix script command.

with open("session.log", "ab") as log:
    def record(fd):
        data = os.read(fd, 1024)
        log.write(data)
        log.flush()
        return data
    pty.spawn(["sh"], record)

Inform users and protect the file because commands and sensitive information may appear.

Test raw and cbreak

PTYs are useful for testing the Python termios and Python tty guides without modifying the test runner’s real terminal.

Timeout strategy

Use a monotonic deadline for each expected prompt or state. On timeout, capture available output, send graceful termination, then force kill if necessary.

A global test timeout prevents a stuck PTY from blocking the entire suite.

Correct closing order

Close the slave in the parent immediately after starting the child. Otherwise the master may never observe EOF because the parent still holds a slave reference.

Close every descriptor in every error path and wait for the child with wait() or waitpid().

Zombie processes

An exited child remains a zombie until collected. Always call waitpid(), including after exceptions and timeouts.

Auditing

pty.spawn() raises the pty.spawn auditing event with argv. Restricted runtimes may log or reject the execution.

Security

Do not build argv directly from untrusted input. Prefer an argument list without a shell. Restrict executables, environment variables, working directory, and privileges.

Terminal captures may contain tokens and personal data. Redact or discard sensitive content.

Test fragmented output, Unicode, ANSI sequences, EIO-as-EOF, timeouts, children waiting forever, Ctrl+C, resize, process trees, hidden passwords, slave closure, and Linux/macOS/BSD differences.

Common mistakes

Common failures include treating a PTY like a pipe, leaving the slave open in the parent, not reaping the child, waiting for text without a timeout, recording secrets, using pty.fork() with high-level macOS APIs, ignoring partial writes, and not handling EIO as possible EOF.

Conclusion

pty controls programs that genuinely require a terminal. It is valuable for CLI tests and automation, but needs explicit rules for EOF, timeouts, signals, resize, and cleanup.

Prefer non-interactive APIs when available, protect transcripts, and test per platform. Consult the official pty documentation and pty(7).

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    Close-up of hands typing on a laptop keyboard, Python book in sight, coding in progress.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python tty: Raw and Cbreak Modes

    Learn Python tty to use raw and cbreak modes, read keys, parse sequences, handle Unicode, and safely restore Unix terminals.

    Ler mais

    Tempo de leitura: 5 minutos
    25/08/2026
    Detailed view of programming code in a dark theme on a computer screen.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python termios: Safe Terminal Control

    Learn Python termios for canonical mode, echo, key reads, baud rate, queues, window size, and safe restoration of POSIX terminals.

    Ler mais

    Tempo de leitura: 5 minutos
    25/08/2026
    Side view of contemplating female assistant in casual style standing near shelves and choosing file with documents
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python fcntl: File Locks and Control

    Learn Python fcntl for file locks, descriptor flags, ioctl, pipes, and Unix control while avoiding invalid buffers and memory corruption.

    Ler mais

    Tempo de leitura: 5 minutos
    25/08/2026
    A developer typing code on a laptop with a Python book beside in an office.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python readline: History and Autocomplete

    Learn Python readline for command history, tab completion, line editing, GNU Readline, libedit, and safer interactive terminal prompts.

    Ler mais

    Tempo de leitura: 5 minutos
    25/08/2026
    Peaceful river scene with mossy boulders and flowing water captured in long exposure.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python io: Master Streams and Buffers

    Learn Python io for text and binary streams, buffering, encodings, StringIO, BytesIO, raw I/O, and file-like interfaces.

    Ler mais

    Tempo de leitura: 6 minutos
    24/08/2026
    Business professional analyzing financial data on multiple computer monitors at his workspace.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python select: Monitor Multiple I/O

    Learn Python select to monitor sockets and pipes, handle partial I/O, backpressure, poll, epoll, signals, and platform differences.

    Ler mais

    Tempo de leitura: 6 minutos
    24/08/2026