Desk Clarity

A field guide to everyday digital problems.

Developer tools / Practical guide

Python TimeoutExpired Output Is Bytes: Normalize Diagnostics Without Losing the Original

Handle byte output in Python TimeoutExpired without hiding the original error. Tested examples preserve raw bytes and decode separate diagnostic text.

By ON Lab4 min readSources checked November 10, 2026 (KST)

A subprocess can return a string on success and still give your exception handler bytes after a timeout. If logging then raises a type error, the diagnostic path hides the original problem. Treat timeout output as its own input contract, preserve the captured bytes, and decode a separate display value. This guide focuses on that boundary; for ownership of a running child, see Popen timeout and cleanup.

Inspect the exception value before formatting it

The public CPython report linked below describes this exact surprise with text mode. The current Python documentation also defines captured timeout output as bytes regardless of the text setting; an absent value can be None. That makes a successful CompletedProcess.stdout a poor template for a timeout handler. Check the actual object before applying a string operation.

This small experiment launches only its own Python child. It flushes a marker and then sleeps, so the parent normally times out after collecting the marker. The timeout is an experimental choice, not a production deadline. On a heavily loaded machine the child may not print before the deadline; None is therefore an allowed observation.

import subprocess
import sys

child = "import time; print('started', flush=True); time.sleep(10)"
try:
    result = subprocess.run(
        [sys.executable, "-c", child],
        capture_output=True,
        text=True,
        encoding="utf-8",
        timeout=1,
    )
except subprocess.TimeoutExpired as exc:
    print("timeout stdout:", type(exc.stdout).__name__, repr(exc.stdout))
    print("timeout stderr:", type(exc.stderr).__name__, repr(exc.stderr))
    assert exc.stdout is None or isinstance(exc.stdout, bytes)
else:
    print("completed:", type(result.stdout).__name__, repr(result.stdout))
    assert isinstance(result.stdout, str)

Our macOS executions on Python 3.11.6, 3.13.2 and 3.14.3 printed bytes containing the started marker on the timeout branch. These are installed-version observations, not tests of every later patch or operating system. Do not treat a timeout diagnostic as evidence that the command completed successfully.

Keep raw evidence and display text separate

Use one helper at the logging boundary. It accepts None, bytes or an already decoded string, and rejects unrelated types. The returned text is for people; the raw value stays available for a file, diagnostic attachment or later analysis. The UTF-8 choice below matches this example child. For another tool, use its documented output encoding.

def display_output(value, *, encoding="utf-8"):
    if value is None:
        return ""
    if isinstance(value, str):
        return value
    if isinstance(value, bytes):
        return value.decode(encoding, errors="backslashreplace")
    raise TypeError("expected None, bytes or str")

samples = [None, b"", "already text", b"started\n", b"price: \xe2\x82"]
for raw in samples:
    print(repr(raw), "=>", repr(display_output(raw)))
assert display_output(None) == ""
assert display_output(b"price: \xe2\x82") == "price: \\xe2\\x82"
assert samples[-1] == b"price: \xe2\x82"
try:
    display_output(123)
except TypeError:
    print("unexpected type rejected")
else:
    raise AssertionError("integer unexpectedly accepted")

The last byte sample deliberately ends in an incomplete UTF-8 sequence. Our local runs preserved its original bytes and displayed the unmatched bytes as escapes. Replacing malformed data with a replacement character can be acceptable for a human log, but it changes what you see. Silently ignoring malformed bytes removes evidence. Choose the display policy deliberately and retain the raw capture when diagnosis requires exact data.

An empty display string cannot tell you whether capture was absent or present but empty. Store a separate flag such as raw_stdout is not None if that distinction matters. Likewise, str(raw_stdout) produces a representation of bytes rather than decoding the tool's text; it may look plausible while leaving a b-prefix and literal escapes in your message.

Wire the helper into the timeout branch

import subprocess
import sys

# Include display_output from the preceding block in the same module.
def display_output(value, *, encoding="utf-8"):
    if value is None:
        return ""
    if isinstance(value, str):
        return value
    if isinstance(value, bytes):
        return value.decode(encoding, errors="backslashreplace")
    raise TypeError("expected None, bytes or str")

try:
    subprocess.run(
        [sys.executable, "-c",
         "import sys,time; print('waiting', flush=True); "
         "print('diagnostic', file=sys.stderr, flush=True); time.sleep(10)"],
        capture_output=True, text=True, encoding="utf-8", timeout=1,
    )
except subprocess.TimeoutExpired as exc:
    raw_stdout, raw_stderr = exc.stdout, exc.stderr
    print("status=timeout")
    print("stdout captured:", raw_stdout is not None)
    print("stdout:", display_output(raw_stdout))
    print("stderr:", display_output(raw_stderr))
else:
    raise AssertionError("fixture completed before the intended timeout")

Our fixture printed the timeout status and both markers without a second exception in the handler. The helper also handled a supplied string, missing capture and an incomplete sequence in the separate deterministic test. A real wrapper should return or raise a timeout outcome that its caller can distinguish from success; displaying a diagnostic does not decide retry policy. Do not restart a command with external side effects solely because its log was decoded successfully.

The examples use bounded output from a direct child. They do not solve unlimited log collection, child-process trees or untrusted byte-stream protocols. If the underlying failure is missing environment settings or arguments, use the separate guides on environment inheritance and POSIX shell arguments. First preserve the timeout evidence, then diagnose the command itself.

Sources & further reading