DEV Community

Taylor Wang
Taylor Wang

Posted on

I Debugged print() for 48 Hours. The Clean Server Spoke POSIX.

Have you ever watched a status logger explode only after the job left your laptop? I did, and I spent two long days arguing with a one-line print() call. The traceback was a UnicodeEncodeError on a café marker I had stuffed into a heartbeat line. Locally the script stayed boringly green, and that made the remote crash feel personal.

Why would a bare print() call care about which machine actually ran the process? I kept asking that while I grepped logging wrappers that were never on the stack. The answer was not a library at all; it was a missing locale on a clean server. I wish I had treated the machine as a suspect before I treated the code as a liar.

Field notes, hour 0–8: I blamed the helper

I was sure our logging helper had grown a secret encode("ascii") in a formatter. I read StreamHandler, WatchedFileHandler, and a homemade JsonEmitter that only ran in staging. None of them forced ASCII, and the traceback still pointed straight at print() itself. That is a terrible place to spend a morning, but I had already opened the files.

So I threw the project in the trash can, metaphorically, and reduced the job to this:

# repro_print.py
print("status: café ✓")
Enter fullscreen mode Exit fullscreen mode

It crashed on the clean box and sailed through on my laptop without a shrug. Did I accidentally pin the wrong interpreter on the remote side of the comparison? Was the remote Python a slim build with a different I/O stack underneath print()? I needed versions instead of vibes, so I printed sys.version on both machines next.

python -c "import sys; print(sys.version)"
python -c "import sys; print(sys.executable)"
Enter fullscreen mode Exit fullscreen mode

Same minor version, same build flavor, and no mystery fork hiding in the path. That should have been comforting after eight hours of grepping the wrong layer. It was not, because the crash clearly did not care about that version string.

Field notes, hour 8–24: I blamed the generated script

A coding assistant had drafted the heartbeat line, and I wanted a villain with a name. Have you noticed how easy it is to let a model paper over an environment bug? I asked it to rewrite the print with ascii() guards, which only hid the symptom. The rewritten line looked like the snippet below, and I almost shipped that confession.

msg = "status: café ✓"
print(msg.encode("utf-8", "backslashreplace").decode("ascii", "replace"))
Enter fullscreen mode Exit fullscreen mode

That is not a logger, and it is not a fix I can defend in a review. I rolled it back and started printing the process environment instead of the café. If the machine is the bug, the environment should have to speak before the code does.

# encoding_fields.py
import locale
import os
import sys

print("stdout.encoding =", sys.stdout.encoding)
print("stderr.encoding =", sys.stderr.encoding)
print("preferred       =", locale.getpreferredencoding(False))
print("fsencoding      =", sys.getfilesystemencoding())
print("utf8_mode       =", sys.flags.utf8_mode)
print("LANG            =", os.environ.get("LANG"))
print("LC_ALL          =", os.environ.get("LC_ALL"))
print("LC_CTYPE        =", os.environ.get("LC_CTYPE"))
print("PYTHONIOENCODING=", os.environ.get("PYTHONIOENCODING"))
print("PYTHONUTF8      =", os.environ.get("PYTHONUTF8"))
Enter fullscreen mode Exit fullscreen mode

Laptop output was utf-8 everywhere, with LANG set, which is how desktops lie to you. Remote output was ANSI_X3.4-1968, preferred encoding US-ASCII, and LANG was completely unset. There it was, sitting in stdout, not in any helper I had accused since breakfast. Would you have looked at LANG before the logging module, or am I the only one?

Field notes, hour 24–36: I blamed the image, then the locale name

I exported LANG=en_US.UTF-8 in the remote shell and reran the café print() immediately. The shell shrugged with a cannot change locale warning, and Python still spoke ASCII. Does setting LANG even work if glibc has no compiled locale sitting on disk? I stopped guessing and asked the image which locales it was willing to admit.

locale
locale -a
python encoding_fields.py
Enter fullscreen mode Exit fullscreen mode

The locale -a list showed C and POSIX, with no en_US.UTF-8 and no C.UTF-8. That is a fun surprise if you live on full desktop images that hide this gap. I was not missing tzdata this time; I was missing a locale archive entirely. Have you hit that on a slim image and still blamed application code for two days?

I tried PYTHONIOENCODING=utf-8 without touching LANG, and the print() finally lived. Then I tried PYTHONUTF8=1, and the print() lived again on the same box. Then I opened a file with the default encoding and wrote the same café string. That path still followed locale.getpreferredencoding(), which stayed ASCII unless I passed encoding=.

So stdout was only half the bug I had been chasing across those two days. Any Path.write_text() without an encoding argument was a second fuse waiting for text. Cron would have found it after I "fixed" the heartbeat and called the incident closed.

Field notes, hour 36–48: I blamed pytest, and pytest deserved it

I added a tiny unit test that asserted sys.stdout.encoding looked like utf-8. It passed on the clean server, which felt like a miracle and also like a trap. Have you trusted a pytest green bar that never touched the real process stdout? I had, and the capture layer was doing me a favor I did not want.

Pytest was wrapping the stream. The test process was not the job process. The heartbeat still died when I ran python repro_print.py with LANG unset. A test that inspects sys.stdout inside pytest is a teammate who nods through every standup.

The check that finally stayed honest was a subprocess with a POSIX locale and no inherited shell gifts. Bytes out, return code checked, no text=True unless I also passed encoding="utf-8". Why would I let subprocess decode with the same broken locale I was trying to catch?

The artifact I wish I had at hour one

I now keep a probe and a subprocess contract in the repo so the job fails on ASCII I/O. They fail fast when the process cannot round-trip even a short non-ASCII status line. Run them on a clean box, not on the laptop that already loves UTF-8. The laptop is a hostile witness, and it will keep perjuring itself for you.

encoding_probe.py

"""Fail fast when stdout or default encoding cannot round-trip text."""
from __future__ import annotations

import locale
import os
import sys

SAMPLE = "status: café ✓ — naïve résumé"


def _norm(name: str | None) -> str:
    return (name or "").lower().replace("-", "").replace("_", "")


def report() -> dict[str, str]:
    return {
        "stdout": sys.stdout.encoding or "",
        "stderr": sys.stderr.encoding or "",
        "preferred": locale.getpreferredencoding(False) or "",
        "fsencoding": sys.getfilesystemencoding() or "",
        "lang": os.environ.get("LANG", ""),
        "lc_all": os.environ.get("LC_ALL", ""),
        "pythonioencoding": os.environ.get("PYTHONIOENCODING", ""),
        "pythonutf8": os.environ.get("PYTHONUTF8", ""),
        "utf8_mode": str(bool(sys.flags.utf8_mode)),
    }


def assert_text_io_is_safe() -> None:
    info = report()
    if _norm(info["stdout"]) != "utf8":
        raise SystemExit(f"stdout cannot print Unicode: {info!r}")
    try:
        print(SAMPLE, flush=True)
    except UnicodeEncodeError as exc:
        raise SystemExit(f"print() failed: {exc}; env={info!r}") from exc
    preferred_ok = _norm(info["preferred"]) == "utf8" or sys.flags.utf8_mode
    if not preferred_ok:
        raise SystemExit(
            "default encoding is "
            f"{info['preferred']!r}: pass encoding= on every text open(); env={info!r}"
        )


if __name__ == "__main__":
    for key, value in report().items():
        print(f"{key:16} {value}")
    assert_text_io_is_safe()
    print("probe: ok")
Enter fullscreen mode Exit fullscreen mode

test_encoding_contract.py

import os
import subprocess
import sys
from pathlib import Path

PROBE = Path(__file__).with_name("encoding_probe.py")
SAMPLE = "café ✓"


def _run(env: dict[str, str]) -> subprocess.CompletedProcess[bytes]:
    return subprocess.run(
        [sys.executable, str(PROBE)],
        env=env,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        check=False,
    )


def test_probe_fails_under_posix_locale():
    env = {
        "PATH": os.environ.get("PATH", ""),
        "LANG": "C",
        "LC_ALL": "C",
    }
    proc = _run(env)
    assert proc.returncode != 0, proc.stdout + proc.stderr


def test_probe_passes_with_pythonioencoding():
    env = {
        "PATH": os.environ.get("PATH", ""),
        "LANG": "C",
        "LC_ALL": "C",
        "PYTHONIOENCODING": "utf-8",
    }
    proc = _run(env)
    assert proc.returncode == 0, proc.stdout + proc.stderr
    assert b"probe: ok" in proc.stdout


def test_write_text_round_trip(tmp_path: Path):
    target = tmp_path / "status.txt"
    target.write_text(SAMPLE, encoding="utf-8")
    assert target.read_text(encoding="utf-8") == SAMPLE
Enter fullscreen mode Exit fullscreen mode

These commands are the cheap version of the two-day argument I actually had.

# on the clean server, before you touch application code
locale -a
python encoding_fields.py
python encoding_probe.py

# force the failure mode even on a cozy laptop
env -u LANG -u LC_ALL -u LC_CTYPE python encoding_probe.py
env -i PATH="$PATH" LANG=C LC_ALL=C python encoding_probe.py

# the two knobs that saved the heartbeat without locale-gen
PYTHONIOENCODING=utf-8 python encoding_probe.py
PYTHONUTF8=1 python encoding_probe.py

pytest -q test_encoding_contract.py
Enter fullscreen mode Exit fullscreen mode

env -i on the laptop is a rehearsal, not the full performance of a clean user. I still prefer a machine that never sourced my bashrc or my IDE UTF-8 checkbox. If PATH is all that Python needs, env -i PATH="$PATH" is enough to unmask ASCII. If the interpreter needs more, you will see it immediately, which is the point.

Where a free remote server actually helped

I needed a process that did not inherit my desktop locale or my zshrc exports. A free remote server is useful here because it behaves like a forgetful CI user. I used MonkeyCode's free server option as that clean room for the encoding probe. I used free model access only to draft the probe, not to invent another logger.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The model did not find LANG for me, and the probe did not need a brand name. If you already have a spare VM, use that and keep the same eight-field printout. The empty environment is the tool; the vendor of the box is not the lesson.

Decision table I now keep in the README

Symptom First check Likely cause Fix that survived the next job
print() raises UnicodeEncodeError sys.stdout.encoding LANG unset, locale is POSIX/C Set PYTHONIOENCODING=utf-8 on the job
shell says cannot change locale locale -a en_US.UTF-8 was never generated Use C.UTF-8 if listed, otherwise do not trust LANG
file write explodes or mojibakes open() / Path.write_text() omitted encoding= Pass encoding="utf-8" on every text open
works in IDE, dies under systemd systemctl show-environment service env has no LANG Set Environment= in the unit, not in .bashrc
pytest is green, python app.py dies subprocess with LANG=C capture hid the real stdout Probe in a child interpreter, not inside pytest's wrapper
subprocess.run(..., text=True) dies child decoding text=True used the POSIX locale Pass encoding="utf-8" or keep bytes

What broke that I will not repeat

  • Trusting a green local run when the laptop LANG is already en_US.UTF-8.
  • Letting a model wrap print() in ascii armor instead of printing sys.stdout.encoding.
  • Exporting en_US.UTF-8 on an image that never ran locale-gen and had no C.UTF-8.
  • Fixing stdout and forgetting that open() still follows the preferred encoding.
  • Putting LANG only in an interactive shell rc file that systemd never reads.
  • Asserting encoding inside pytest without spawning a child that owns the real streams.

What I would repeat in the next 48 hours

  1. Reproduce with env -i or a clean remote shell before I rewrite application code.
  2. Print the encoding fields above, in that order, on both machines before touching loggers.
  3. Pin PYTHONIOENCODING=utf-8 on the job definition, not in a comment on the pull request.
  4. Pass encoding="utf-8" on every text open(), Path.write_text(), and subprocess pipe.
  5. Keep the subprocess probe in CI so the next slim image fails in minutes, not two days.

Would I still use a model to draft the probe? Yes, as a typist. Would I let it choose the fix? Not after it suggested backslashreplace for a heartbeat.

Limitations, and who should skip this

This workflow is for jobs that print or persist human text, not for database collations. It is not a substitute for knowing the encodings already sitting on your disk. It will not repair data that was saved as latin-1 and labeled as utf-8. If you need that repair, you want a conversion plan, not a locale export.

Do not blindly export LANG=C.UTF-8 if you depend on locale-aware sorting or month names. Those programs want a real locale archive, not a Python I/O hammer aimed at stdout. Do not paste secrets into a shared remote session while you chase an encoding crash. A free server is a clean room for probes, and it is not a vault.

If your runtime already sets PYTHONUTF8=1 everywhere, the print() crash may already be gone. The open() contract remains, because files do not read your shell exports for you. Should you skip the probe anyway? Only if every text open() already passes encoding=.

Windows-only shops should treat LANG=C as a Unix reproduction, not as gospel. Python on Windows may use a code page your Linux image will never see. The durable half of this note is still the same: pass encoding= explicitly, and do not let pytest speak for the job.

Closing the notebook

Next time a remote job dies on a character your laptop prints without blinking, stop. Run the probe before you rewrite the logger, and compare the eight fields side by side. I still keep that café in the heartbeat because it is a cheap and honest canary. Would I spend another 48 hours on print()? Not if the clean box is in the loop.

Top comments (0)