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é ✓")
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)"
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"))
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"))
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
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")
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
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
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-8on an image that never ranlocale-genand had noC.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
- Reproduce with
env -ior a clean remote shell before I rewrite application code. - Print the encoding fields above, in that order, on both machines before touching loggers.
- Pin
PYTHONIOENCODING=utf-8on the job definition, not in a comment on the pull request. - Pass
encoding="utf-8"on every textopen(),Path.write_text(), and subprocess pipe. - 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)