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

    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