Python output

PYTHONUNBUFFERED: why print output shows up late, and how to fix it

Python block-buffers stdout whenever it isn't a terminal, so print() output under nohup, Docker, systemd, CI or a pipe can show up late or only when the script exits. Set PYTHONUNBUFFERED=1 or run python -u to turn buffering off, or use print(..., flush=True) (Python 3.3+) or sys.stdout.reconfigure(line_buffering=True) (Python 3.7+).

6 min readtqdm and progress bars

At a glance

  • stdout is line-buffered in a terminal and block-buffered in a pipe or file; stderr is line-buffered in both since Python 3.9.
  • PYTHONUNBUFFERED=1 does the same as python -u: stdout and stderr become unbuffered.
  • PYTHONUNBUFFERED=0 kept output buffered in our tests on Python 3.8 to 3.14, despite what the docs say.
  • In Docker, add ENV PYTHONUNBUFFERED=1 to the Dockerfile.

Why your prints do not show up

Python picks its buffering mode by checking what stdout is connected to. When stdout is an interactive terminal, Python line-buffers it, so every newline sends the line out right away. When it's anything else, such as a pipe, a file or a socket, Python block-buffers it, and your output sits in memory until the buffer fills up or the program exits. That's what happens under Docker without -t, in systemd services, in nohup.out, on CI runners without a terminal and with | tee.

stderr works differently. Since Python 3.9, stderr is always line-buffered, whether it's attached to a terminal or not (before 3.9, non-interactive stderr was block-buffered too). That's why logging.StreamHandler records and tqdm progress bars, which go to stderr, show up live while plain print() calls on stdout seem to hang. Both also flush on their own: the handler after every record, and tqdm after every refresh.

Four ways to fix it

You have four ways to get your output out on time: two from outside the script and two inside the Python code itself.

Pass the -u flag on the command line. python -u forces both stdout and stderr to be unbuffered, while leaving stdin alone.

Bash
python -u train.py > train.log 2>&1

Set the PYTHONUNBUFFERED environment variable to 1, which does the same thing as passing -u. The docs say any non-empty string works, but 0 doesn't: in our tests on Python 3.8 to 3.14, PYTHONUNBUFFERED=0 behaved like an unset variable.

Bash
export PYTHONUNBUFFERED=1
python train.py | tee train.log

If you only care about a couple of lines and don't want to change how you launch the process, pass flush=True to the print() calls you want to see right away (available since Python 3.3).

Python
import time

for step in range(5):
    print(f"step {step} done", flush=True)
    time.sleep(1)

Or, on Python 3.7+, reconfigure stdout at the top of your script so every print() is written out at the end of each line, the way it is in a terminal.

Python
import sys

sys.stdout.reconfigure(line_buffering=True)

print("this line is written as soon as it ends")

Which one to pick:

  • python -u or PYTHONUNBUFFERED=1 when you control the command line, Dockerfile, systemd unit or CI config and don't want to touch the code.
  • sys.stdout.reconfigure(line_buffering=True) when you want line-by-line output baked into the script itself; it costs less than unbuffered output when you print a lot.
  • print(..., flush=True) when only one or two status lines need to show up right away.

Docker and Kubernetes

By default, a Docker container doesn't get a TTY, so stdout isn't a terminal and Python block-buffers everything. When you run docker logs -f, you get nothing until the buffer fills or the container stops. Put ENV PYTHONUNBUFFERED=1 in your Dockerfile so docker logs -f prints lines as they happen.

Dockerfile
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY train.py .
CMD ["python", "train.py"]

If you're running a container by hand, docker run -t allocates a pseudo-TTY, which also makes Python line-buffer stdout. Kubernetes containers don't get a TTY either (tty defaults to false in the pod spec), so set the variable in the image or in the container's env. The docker logs guide covers following the output.

YAML
env:
  - name: PYTHONUNBUFFERED
    value: "1"

systemd, nohup, CI and pipes

systemd connects a service's stdout to the journal rather than a terminal, so Python block-buffers your prints there too. Add Environment=PYTHONUNBUFFERED=1 under the [Service] section of your unit file, or put python -u in ExecStart.

INI
[Service]
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/bin/python3 /opt/app/train.py

Started from a terminal, nohup sends output to nohup.out, a regular file, so Python buffers it until the buffer fills or the job finishes. Run it with python -u so lines land in the log as your script prints them; the nohup guide covers the rest.

Bash
nohup python -u train.py > train.log 2>&1 &
tail -f train.log

The same rule hits CI runners and shell pipes like python script.py | tee run.log: when Python doesn't get a terminal on stdout, set PYTHONUNBUFFERED=1 in the job environment or pass -u. It also applies when another program starts your script and reads its output through a pipe, such as subprocess.Popen with stdout=subprocess.PIPE.

Where buffering bites

Where your script runsstdout bufferingFix
Interactive terminalLine-bufferedNothing to do
Pipe to tee or another programBlock-bufferedpython -u or PYTHONUNBUFFERED=1
File redirect or nohup.outBlock-bufferednohup python -u
Docker without -tBlock-bufferedENV PYTHONUNBUFFERED=1 or docker run -t
Kubernetes podBlock-bufferedPYTHONUNBUFFERED in the container's env
systemd serviceBlock-bufferedEnvironment=PYTHONUNBUFFERED=1
CI runner without a terminalBlock-bufferedPYTHONUNBUFFERED=1 in the job environment
tqdml run with a Python childBlock-bufferedPYTHONUNBUFFERED=1 tqdml run

Stream the output to a browser

Runpeek's tqdm-live package includes a command line tool, tqdml. tqdml run -n NAME -- <command> prints a runpeek ▶ https://runpeek.app/r/<uuid> link and streams the command's stdout and stderr to it, keeping the last 500 lines of up to 500 characters each. tqdm bars in Python child processes show up on the same page with no code changes. There's no account, and links expire 30 minutes after the last update.

The buffering rule still applies, because tqdml reads the child's output through a pipe. In our test, a Python child's print() lines reached the run page only when it exited, while PYTHONUNBUFFERED=1 streamed them live.

Set it in front of tqdml: the child inherits it, and the run link also appears straight away when you redirect tqdml's own output to a file. With tqdml's output redirected to a file, that file was still empty after 2.5 seconds for the plain run, while the unbuffered run's file already had the link and 5 of 8 lines.

FAQ

What does PYTHONUNBUFFERED=1 do?

It does the same as running python -u: the stdout and stderr streams become unbuffered, so every write goes straight out, and stdin is unaffected. Python reads it at startup, so set it before the interpreter starts; setting it inside the script only affects child processes.

Does PYTHONUNBUFFERED=0 keep output buffered?

Yes. The docs say any non-empty string turns buffering off, but in our tests on Python 3.8, 3.10, 3.12, 3.13 and 3.14, PYTHONUNBUFFERED=0 behaved like an unset variable. Use PYTHONUNBUFFERED=1 when you want unbuffered output.

Is unbuffered output slower?

It can be when you print a lot. In our test on Python 3.13, writing 1 million short lines to a file took 1.03 seconds buffered, 4.68 seconds with line_buffering=True and 13.59 seconds with -u. For a script that prints a status line every few seconds, the difference doesn't matter.

Why do tqdm bars and logging messages show up but print does not?

logging.StreamHandler writes to stderr by default and flushes after every record, and tqdm writes its bar to stderr and flushes it on every refresh. Plain print() writes to stdout, which sits in a block buffer whenever stdout is a pipe or a file.

Do I need PYTHONUNBUFFERED in Kubernetes?

Yes, unless your code flushes on its own. Kubernetes containers don't get a TTY by default, so Python block-buffers stdout just like in Docker. Keep ENV PYTHONUNBUFFERED=1 in the image or set it in the pod spec so kubectl logs -f shows lines live.

Check on long runs from your phone

Change one import and every tqdm bar prints a link you can open on any device. Get a Discord or Slack message when the run finishes, fails or stalls.

Get a live linkpip install tqdm-live

Sources