Following one print all the way down
Every layer is now familiar, so here is print("hi") from the top down with nothing left mysterious.
- Python.
printformats its arguments and callssys.stdout.write("hi\n"). - Buffer, from lesson 7-2. The text lands in a user-space buffer, and because stdout to a terminal is line-buffered, the
\ntriggers a flush. - System call, from lesson 8-1. The flush issues
write(1, b"hi\n", 3). That1is a file descriptor, a small number the kernel gives the process for each open thing. - Kernel. The CPU switches to kernel mode and the kernel routes the bytes to whatever descriptor 1 is connected to, whether a terminal, a file, or a pipe to another program.
- Terminal. Another user-space process receives the bytes, decodes them as UTF-8 from lesson 2-3, and draws glyphs.
| Descriptor | Stream |
|---|---|
| 0 | stdin |
| 1 | stdout |
| 2 | stderr |
The program never touched the screen. It handed 3 bytes across the boundary and something else on the far side decided what they looked like.
Calling the write syscall directly
os.write is a thin wrapper around the raw write system call, with no Python buffering at all.
import os os.write(1, b"written with the write system call\n") print("written with print, which ends up in the same place")
Output
written with the write system call
written with print, which ends up in the same placeFile descriptor 1 is stdout, and it takes raw bytes rather than a string. The b prefix makes a bytes literal.
Syscalls move bytes and never strings, which is why the encodings from lesson 2-3 matter at every boundary. print is doing the same write underneath, having first encoded the text and possibly buffered it.
What print is sugar for
sys.stdout.write is the layer directly beneath print, and it reports how much it wrote.
import sys sys.stdout.write("print is sugar for this\n") n = sys.stdout.write("write returns the character count\n") print(n)
Output
print is sugar for this write returns the character count 34
Counting it out, "write returns the character count" is 33 characters plus the newline, giving 34. The returned count mirrors what the underlying syscall reports.
Two differences from print are worth naming. sys.stdout.write adds no newline of its own and accepts only a string, so print is genuinely a convenience layer rather than a different mechanism.
Writing to standard output without print
The same visible result, reached through the raw descriptor.
import os os.write(1, b"hello from fd 1\n")
Output
hello from fd 1
os.write(fd, data) takes the descriptor first, and 1 is stdout. The b prefix is mandatory, since the call moves bytes and a plain string would raise a TypeError.
There is no buffering in this path at all, so the bytes cross into the kernel immediately. That makes os.write the tool of choice for output that must survive a crash, at the cost of one syscall per call.
Where the bytes go under shell redirection
Running python3 app.py > out.txt sends the output of print("hi") into out.txt with no code changes, because the shell connected file descriptor 1 to the file before the program started.
The program writes to descriptor 1 and never learns what is behind it. The shell performed the setup before launching the process, so the redirection is invisible from inside.
| Descriptor | Points at, by default | Under > out.txt |
|---|---|---|
| 1, stdout | the terminal | out.txt |
| 2, stderr | the terminal | still the terminal |
That indirection is why pipes and redirection compose so cleanly, and why stderr on descriptor 2 still reaches the screen. Error messages stay visible even when the normal output has been captured, which is the whole reason there are two streams.
Why terminal output and file output buffer differently
Flushing per newline to a terminal and only on a full buffer to a file is sensible because humans watch terminals live and need lines promptly, while file output cares about throughput, so batching saves thousands of syscalls.
Each flush costs a system call, which is a full user-to-kernel round trip as described in lesson 8-1.
| Destination | Priority | Policy |
|---|---|---|
| terminal | latency | flush every line |
| file or pipe | throughput | flush when the buffer fills |
For a live terminal the latency matters, so flushing per line is worth the cost. For a file nobody is watching mid-run, so batching big and crossing the boundary rarely is the better trade.
Buffering policy is a latency-versus-throughput dial, and Python simply guesses which end you want based on what the descriptor is connected to. Overriding the guess is what flush=True and python -u are for.