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







