os.openpty

A pseudo-terminal is a pair of fds: what a program writes to the slave side appears on the master side, and the program believes it talks to a real terminal (so it prints colours and line-buffers). isatty and device_encoding work on Unix and Windows; everything else here is Unix only.

os functionPython 3.0+ (login_tty 3.11+, posix_openpt / grantpt / unlockpt / ptsname 3.13+)
Common call
sys.stdout.isatty()
Returns
True in an interactive terminal, False when piped or captured
Replaces
checking environment variables to guess whether output is interactive
Watch out
Most terminal work is easier with the pty module (Unix only)
os.openpty() / os.isatty(fd, /) / os.ttyname(fd, /) / os.posix_openpt(oflag, /)
→ tuple[int, int]

Parameters

NameTypeRequiredDescription
fdintyesAn open file descriptor (isatty, ttyname, device_encoding, tcgetpgrp, tcsetpgrp, login_tty, grantpt, unlockpt, ptsname).
pgintyestcsetpgrp only: the process group to make the foreground group of the terminal.
oflagintyesposix_openpt only: open flags such as os.O_RDWR | os.O_NOCTTY (O_CLOEXEC is added automatically where available).

Return value

tuple[int, int] — openpty: (master_fd, slave_fd). isatty: bool. ttyname / ctermid / ptsname: str. device_encoding: str or None. posix_openpt: an fd. tcgetpgrp: int.

Common patterns

Colours only for terminals
Plain text when output goes to a file or pipe.
import sys
use_color = sys.stdout.isatty()
red = '\033[31m' if use_color else ''
Run a child on a pseudo-terminal and read its output (Unix)
The child sees a terminal on stdout, so it keeps colours and line buffering.
import os, subprocess
master, slave = os.openpty()
proc = subprocess.Popen(['ls', '--color=auto'], stdout=slave, stderr=slave, close_fds=True)
os.close(slave)
output = os.read(master, 65536)
proc.wait()
os.close(master)
The POSIX way to open a pty (3.13+)
posix_openpt + grantpt + unlockpt + ptsname, as in C.
import os
master = os.posix_openpt(os.O_RDWR | os.O_NOCTTY)
os.grantpt(master)
os.unlockpt(master)
slave_name = os.ptsname(master)
slave = os.open(slave_name, os.O_RDWR | os.O_NOCTTY)
Higher level: the pty module (Unix)
pty.spawn runs a program connected to a new pseudo-terminal.
import pty
pty.spawn(['bash'])

Examples

1. A pipe is not a terminal
import os r, w = os.pipe() try: result = (os.isatty(r), os.isatty(w)) finally: os.close(r) os.close(w) result
Returns
(False, False)
2. Neither is a regular file
import os with open('f.txt', 'w') as f: f.write('x') fd = os.open('f.txt', os.O_RDONLY) try: result = (os.isatty(fd), os.device_encoding(fd)) finally: os.close(fd) result
Returns
(False, None)
3. A closed or unknown fd is just False
import os os.isatty(9999)
Returns
False
4. Captured output: no terminal
import sys sys.stdout.isatty()
Returns
False
5. A child with piped stdout sees no terminal
import sys, subprocess code = 'import sys; print(sys.stdout.isatty())' subprocess.run([sys.executable, '-c', code], capture_output=True, text=True).stdout.strip()
Returns
'False'
6. The pty functions are Unix only
import os all(hasattr(os, n) == (os.name == 'posix') for n in ('openpty', 'ttyname', 'ctermid', 'tcgetpgrp', 'login_tty'))
Returns
True

Pitfalls

1. Calling os.isatty(sys.stdout.fileno()) on a replaced stdout
Test runners, notebooks and contextlib.redirect_stdout replace sys.stdout with objects that have no file descriptor. Ask the stream itself.
fileno()
import contextlib, io, os, sys
with contextlib.redirect_stdout(io.StringIO()):
    try:
        result = os.isatty(sys.stdout.fileno())
    except io.UnsupportedOperation as e:
        result = type(e).__name__
result
'UnsupportedOperation'
stream.isatty()
import contextlib, io, sys
with contextlib.redirect_stdout(io.StringIO()):
    result = sys.stdout.isatty()
result
False
2. Using device_encoding without a fallback
device_encoding returns None when the fd is not a terminal (a file, a pipe), so string methods on the result crash.
assume a str
import os
r, w = os.pipe()
try:
    enc = os.device_encoding(w)
finally:
    os.close(r)
    os.close(w)
enc.lower()
AttributeError: 'NoneType' object has no attribute 'lower'
or a default
import os
r, w = os.pipe()
try:
    enc = os.device_encoding(w) or 'utf-8'
finally:
    os.close(r)
    os.close(w)
enc.lower()
'utf-8'

When to use

Use it
  • Deciding between coloured/interactive and plain output (isatty)
  • Driving interactive programs that refuse to work with pipes (openpty, pty module)
  • Job control in shells: tcgetpgrp / tcsetpgrp
Reach for something else
  • Spawning and expecting interactive programs → pty.spawn or the third-party pexpect
  • Windows → os has no pseudo-terminals there; use subprocess pipes
  • Terminal size → os.get_terminal_size / shutil.get_terminal_size

Notes

CPython impl
Thin wrappers of the C functions of the same names; openpty and posix_openpt return non-inheritable fds (openpty since 3.4). grantpt, unlockpt and ptsname do not close fd on failure; ptsname uses ptsname_r() where available
Availability
isatty, device_encoding: Unix and Windows. ttyname: Unix. openpty, login_tty, ctermid, tcgetpgrp, tcsetpgrp, posix_openpt, grantpt, unlockpt, ptsname: Unix, not WASI. login_tty is 3.11+; posix_openpt, grantpt, unlockpt, ptsname are 3.13+
device_encoding
Returns None unless fd is a terminal; on Unix in UTF-8 Mode it returns "UTF-8" (since 3.10)
Errors
ttyname and tcgetpgrp on a pipe raise OSError with errno 25 (ENOTTY, "Inappropriate ioctl for device") on Linux; isatty never raises, it returns False
login_tty
Makes the caller a session leader with fd as its controlling terminal and as stdin/stdout/stderr, then closes fd

FAQ

Pseudo-terminals are Unix only (openpty, ttyname and the others: Availability: Unix), and our examples run with captured output, where no terminal exists. Pipes and files give the same answers everywhere: isatty is False and device_encoding is None.