skip to content

How does contextlib.redirect_stdout let a test capture what a function prints?

level: juniorimportance: should knowfreq 55%

answer

  1. Printed output leaves no return value
  2. Swap the stream, not the function
  3. An in-memory text stream as the sink
  4. A contextlib context manager restores on exit
  5. getvalue() after the with block

basics

~10 s

contextlib.redirect_stdout rebinds sys.stdout to any writable text object for the duration of its with block. Point it at an io.StringIO, run the code, then assert on that buffer's getvalue() text.

solid answer

~40 s

`contextlib.redirect_stdout` is a context manager that saves `sys.stdout`, rebinds the name to the object you hand it, and restores the original on exit — including when the block raises. Pair it with an `io.StringIO`, an in-memory text stream, and `getvalue()` returns everything the code printed, trailing newline included. `contextlib.redirect_stderr` does the same for `sys.stderr`. Two limits matter in real suites. It is not thread-safe: `sys.stdout` is process-global, so another thread printing during the block lands in your buffer. And it only intercepts writes that go *through* the `sys.stdout` object — a child process started by `subprocess` inherits the real file descriptor, so its output never appears. Capturing print output is also a fallback: a function that returns its text, or takes a stream parameter, is easier to test than one that prints.

code

python · 12 lines
python
import contextlib
import io

def greet(name):
    print(f"hello, {name}")

buf = io.StringIO()
with contextlib.redirect_stdout(buf):
    greet("world")

assert buf.getvalue() == "hello, world\n"
assert buf.getvalue().splitlines() == ["hello, world"]

go deeper

for a junior

Be ready to write the four lines from memory: make an io.StringIO, wrap the call in with contextlib.redirect_stdout(buf), then assert on buf.getvalue(). Remember that print adds a trailing newline to what you capture.

for a middle

Explain the mechanics: the context manager saves and restores the sys.stdout name, print resolves that name on every call, and the restore happens even if the block raises. Know that contextlib.redirect_stderr is the same tool for sys.stderr.

for a senior

An interviewer expects the limits: process-global state makes it unsafe under threads, and it never sees a child process's writes to the inherited descriptor. Say when you would instead pass a stream parameter or capture through subprocess.

for a principal

Own the argument that stdout capture is a symptom. Printing is an untestable side effect on a global; steering teams toward functions that return text, or accept an output stream, removes a whole class of brittle, order-dependent tests from the suite.

### The problem A function that calls `print()` has no return value to assert on. Its only observable effect is a write to whatever object the name `sys.stdout` is bound to at the moment `print` runs — `print` looks that name up on every call, it does not cache it. That indirection is the seam, and `contextlib.redirect_stdout` is the stdlib tool that uses it. ### What redirect_stdout actually does `contextlib.redirect_stdout(new_target)` returns a context manager. On `__enter__` it pushes the current value of `sys.stdout` onto an internal stack and assigns `new_target` to `sys.stdout`; on `__exit__` it pops the old value back. Because the restore happens in `__exit__`, it survives an exception inside the block. It is also reentrant — nesting two of them restores in the right order — and reusable, but it is emphatically *not* thread-safe, because `sys.stdout` is one global slot shared by every thread in the interpreter. The target only needs to be a writable text file object. `io.StringIO` is the natural choice: an in-memory text stream with a `getvalue()` method that returns everything written so far, without closing or flushing anything. `contextlib.redirect_stderr` is the identical mechanism for `sys.stderr`. ```python import contextlib import io def render(rows): for label, count in rows: print(f"{label}: {count}") buf = io.StringIO() with contextlib.redirect_stdout(buf): render([("de", 3), ("fr", 5)]) assert buf.getvalue() == "de: 3\nfr: 5\n" ``` Note the trailing newlines: `print` appends `end`, which defaults to `"\n"`. Asserting equality against a string with no trailing newline is the single most common way this test fails on the first run. Either include the newline, or compare `buf.getvalue().splitlines()` against a list of lines, which is more readable and less brittle about the final terminator. ### What it does not capture The redirect happens purely at the Python-name level. Anything that writes to the operating-system file descriptor 1 without going through the `sys.stdout` object is untouched: * A child process. `subprocess.run([...])` with no `stdout=` argument hands the child the inherited descriptor. The child's output appears on your terminal, and the buffer stays empty. To capture it, ask `subprocess` for it (`capture_output=True`, or `stdout=subprocess.PIPE`) and assert on the returned bytes or text. * A C extension or a library that writes directly at the descriptor level. * Anything that captured `sys.stdout` earlier and stored it — a module that did `out = sys.stdout` at import time holds the *original* object, and your redirect will not reach it. Code written as `print(..., file=self.out)` where `self.out` was bound in `__init__` has the same shape. When you genuinely need descriptor-level capture — testing a wrapper around a child process, say — the redirect is the wrong tool: point the child's stream at a real file in a temporary directory, or at a pipe, and read that. ### Isolation and parallelism Because the mechanism mutates a process-global, two tests running concurrently in threads will corrupt each other's captures, and a redirect that leaks (a hand-rolled `sys.stdout = buf` with no restore) poisons the rest of the run — typically showing up as a *different* test producing no output, or as a runner that suddenly prints nothing. Always use the context manager rather than assigning `sys.stdout` yourself; the whole value of `redirect_stdout` over two lines of assignment is the guaranteed restore on the exception path. ### The design point an interviewer is listening for Capturing stdout is a seam of last resort. It tests a side effect on a global, which is exactly the coupling that makes the code hard to test in the first place. Two better shapes, in order of preference: 1. **Return the text.** `def render(rows) -> str` and let the caller print it. The test asserts on a value, no globals involved. 2. **Take the stream as a parameter.** `def render(rows, out=sys.stdout)` — the test passes its own `io.StringIO` directly, with no global mutation, and the code stays honest about the fact that it writes somewhere. Reach for `redirect_stdout` when the function is not yours to change, when you are pinning the behaviour of a CLI entry point before refactoring it, or when the printing genuinely is the feature — a command-line tool's output format, for instance. Saying that out loud is what separates "I know the API" from "I know when to use it".

  • Does contextlib.redirect_stdout capture what a subprocess.run child prints?
    No. The redirect rebinds the sys.stdout name inside this interpreter only. A child process inherits the real operating-system descriptor, so its output goes to the terminal and the buffer stays empty. Capture it through subprocess itself — capture_output=True, or stdout=subprocess.PIPE — and assert on what run() returns.
  • Why is contextlib.redirect_stdout unsafe when tests run in parallel threads?
    sys.stdout is one process-global slot. The redirect swaps it for every thread at once, so a concurrently running test's print lands in your buffer, and the restore on exit can undo another thread's redirect. Keep it to single-threaded tests, or move the seam into the code by giving the function a stream parameter.
  • What would you change in the code so a test never needs to capture stdout?
    Have the function return the text and let its caller print it, or give it an out parameter defaulting to sys.stdout that the test passes an io.StringIO to. Both replace an assertion about a global side effect with an assertion about a value or an injected object, which is faster, thread-safe and self-documenting.

It is like clipping a recorder onto the studio's one microphone cable: everything sung into that microphone is captured, but a musician shouting from the corridor — a child process — never touches the cable.

saying these in an interview costs you the question

  • Thinks the redirect also captures a child process's output
  • Assigns sys.stdout by hand and never restores it
  • Believes the redirect is safe under parallel threads
  • Forgets print appends a newline when asserting equality
  • Hands io.BytesIO to redirect_stdout as the sink
  • Treats capturing stdout as better design than returning text

context