DEV Community

Taylor Wang
Taylor Wang

Posted on

The Failure Message Had a Checkmark. LANG=C Refused to Print It.

Have you ever shipped a pytest suite that looked literate on your laptop and then went illiterate on the server? I spent two days chasing a collector crash that never reproduced on my Mac. I had pinned the same CPython minor and the same requirements lock, which made the gap feel insulting. The tests were not flaky in the usual sense, and the assertion logic itself was boringly deterministic. The process died while printing a failure I had decorated with a checkmark. POSIX C locale still owns a lot of minimal Linux images, and it does not care about your typography.

I am writing this as a 48-hour lab notebook, not as telemetry from a private fleet. Treat every command below as a reproduction you can run tonight, not as a claim about your images. If a snippet is a proposal, I label it that way in the heading or the comment. Ready to dump encodings before you dump plugin lists?

Why did a green laptop suite explode on a clean box?

My laptop speaks UTF-8 because the terminal, the locale, and macOS have agreed on that story for years. Many cloud images still boot with LANG=C or LANG=POSIX, so sys.stdout.encoding becomes ANSI_X3.4-1968. That name is a long way of saying ASCII, and pytest then tries to render or an em dash. The interpreter raises UnicodeEncodeError while it is talking to you, not while it is deciding the assertion. Is that a test failure, or is it a reporter crash wearing a test-shaped coat?

I did not want another works-on-my-machine thread in the team chat. I wanted a command that would fail on purpose, on a machine that had never seen my dotfiles. That is the whole point of a clean server: no export LANG=en_US.UTF-8 hiding in ~/.zshrc. No terminal emulator quietly negotiates UTF-8 on your behalf when the process is not a TTY.

Hour 0–8: I blamed the wrong layer

I started where I always start, which is the wrong place. I disabled pytest-xdist, then I disabled color, then I wondered whether a plugin was caching a codec on stdout. None of those guesses matched the traceback, which named a strict ASCII codec during safe_str. Have you noticed how often we debug the framework because the operating system is too boring to suspect?

Here is the shape of the error I reconstructed in a scratch file. Treat this as a lab reproduction, not as a screenshot from a pipeline you cannot see.

# demo_failure.py
def test_status_line():
    status = "ready"
    # The message is the bug magnet, not the comparison.
    assert status == "done", "expected done — got ready ✓"
Enter fullscreen mode Exit fullscreen mode

On the laptop, pytest demo_failure.py prints a normal failure with a readable sentence. On a C locale server, the same command can abort while encoding that sentence. That difference is enough to burn an afternoon if you only read the log after a long install phase.

I also asked a coding model for a fix, because I was tired and the traceback was ugly. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access to brainstorm patches, and I used the free server option as a clean Linux shell that did not inherit my laptop locale. The model suggested stripping non-ASCII from assertion messages, which would have silenced the crash and destroyed the signal. That suggestion is why I still want a server I do not personalize.

Hour 8–24: the encoding dump that should have been first

Would you skip a four-line diagnostic next time, or are you like me and only dump encodings after the plugins look innocent? I finally printed the process view of reality, and the laptop-versus-server gap stopped being mystical. The flags live on sys, locale, and a handful of environment variables, and they disagree more often than people admit.

# diag_stdio.py
import locale
import os
import sys

def dump(label: str) -> None:
    print(f"== {label} ==")
    print("stdout.encoding", sys.stdout.encoding)
    print("stderr.encoding", sys.stderr.encoding)
    print("locale.getencoding()", locale.getencoding())
    print("locale.getlocale()", locale.getlocale())
    print("LANG", os.environ.get("LANG"))
    print("LC_ALL", os.environ.get("LC_ALL"))
    print("PYTHONUTF8", os.environ.get("PYTHONUTF8"))
    print("PYTHONIOENCODING", os.environ.get("PYTHONIOENCODING"))
    print("sys.flags.utf8_mode", sys.flags.utf8_mode)

if __name__ == "__main__":
    dump("process")
Enter fullscreen mode Exit fullscreen mode

Run it three ways on the same interpreter. Do not skip the C locale run just because your laptop already looks healthy.

python diag_stdio.py
LANG=C LC_ALL=C python diag_stdio.py
PYTHONUTF8=1 LANG=C LC_ALL=C python diag_stdio.py
Enter fullscreen mode Exit fullscreen mode

The second command is the one that explains the checkmark crash. The third command is the one that makes a clean server behave like the literate laptop, without rewriting every assertion. I wish I had started here at hour zero, because the dump is cheap and the plugin rabbit hole is not.

A tiny reproduction you can run tonight

This is the artifact I now keep next to demo_failure.py. It is a shell script, not a framework, and it should fail loudly under LANG=C until you apply one of the fixes in the table below. Label this as an unexecuted template if you have not run it yet on your own image.

#!/usr/bin/env bash
# repro_locale.sh — expected: UnicodeEncodeError under LANG=C without UTF-8 mode
set -euo pipefail

python - <<'PY'
from pathlib import Path
Path("demo_failure.py").write_text(
    'def test_status_line():\n'
    '    status = "ready"\n'
    '    assert status == "done", "expected done — got ready ✓"\n',
    encoding="utf-8",
)
PY

echo "--- laptop-like ---"
pytest -q demo_failure.py || true

echo "--- POSIX C ---"
LANG=C LC_ALL=C pytest -q demo_failure.py || true

echo "--- UTF-8 mode on top of C ---"
PYTHONUTF8=1 LANG=C LC_ALL=C pytest -q demo_failure.py || true
Enter fullscreen mode Exit fullscreen mode

I want the middle stanza to blow up, and I want the third stanza to print a normal assertion failure. If the middle stanza is already fine, your image is not a C locale image, and you are debugging a different bug. You can tighten the check into a unit test against sys.stdout.encoding if you prefer pytest to shell. I still like the shell form, because it includes the environment the test process will actually inherit.

# test_stdio_contract.py
import os
import sys

def test_stdout_is_utf8_or_we_opt_in():
    enc = (sys.stdout.encoding or "").lower().replace("-", "")
    utf8_mode = bool(getattr(sys.flags, "utf8_mode", 0))
    forced = os.environ.get("PYTHONIOENCODING", "")
    assert enc.startswith("utf8") or utf8_mode or forced.lower().startswith("utf-8"), (
        f"stdout encoding is {sys.stdout.encoding!r}; "
        "set PYTHONUTF8=1 or PYTHONIOENCODING=utf-8 in CI"
    )
Enter fullscreen mode Exit fullscreen mode

Is that test a little preachy, given that it never exercises your product code? Yes, and it still fails in five seconds instead of forty-eight hours. I would rather keep a preachy contract than reread a truncated traceback after a Docker pull.

Hour 24–48: the fixes I tried, in order

I tried four patches, and I would not ship the first two again. Stripping glyphs made logs greyscale and unsearchable, which is a terrible trade for a quieter codec. Wrapping every print in encode("ascii", "replace") hid the same information behind question marks. Setting LANG=C.UTF-8 in a Dockerfile was reasonable, until an entrypoint script unset it. PYTHONUTF8=1 on the test command was the smallest lever that did not require every caller to agree on glibc locale packs.

Here is a proposed CI snippet I would actually keep. It is explicit, it is boring, and it does not depend on the image shipping a UTF-8 locale pack.

# proposed CI snippet, not a universal standard
export PYTHONUTF8=1
export PYTHONIOENCODING=utf-8
pytest -q
Enter fullscreen mode Exit fullscreen mode

Should you also fix the image locale if you own the base image and the SSH experience? Probably yes, because humans read those shells too. Should you wait on the image before your test command can print a failure? I would not. Reporting has to survive a hostile environment, because that is when you need the report.

I also reconfigured stdout inside a plugin-shaped experiment, then deleted it. Mutating global streams from shared test code is a good way to fight every other plugin. Prefer environment flags for the process, and keep application code using encoding="utf-8" when it opens files. Stdio policy and file policy are related, but they are not the same bug.

Decision table: four ways to stop lying about stdout

Lever What it changes Repeat it? Skip it when
PYTHONUTF8=1 UTF-8 mode for stdio, filesystem, and argv on CPython Yes, as a CI default You must match a legacy non-UTF-8 protocol
PYTHONIOENCODING=utf-8 stdio codec only Yes, as belt and suspenders You need raw bytes on stdout
Image LANG=C.UTF-8 glibc locale, if the pack exists Yes, for humans in SSH The base image has no UTF-8 locales
Strip non-ASCII from messages The evidence No Never, unless you are talking to a seven-bit teletype

If two levers conflict, I keep the process flags and I still set the image locale for interactive shells. I do not let a model simplify failure strings so the codec stops complaining. That is treating the smoke alarm as the fire, and it will fail again the next time someone pastes a trademark sign into a log.

What I would repeat next time

I would dump encodings before I dump plugins, and I would reproduce on a machine that does not load my shell rc files. I would ask a model for ideas only after I had a failing command I could paste unchanged. I would treat green on the laptop as a locale-tainted result until the same commit runs under LANG=C.

A short checklist I am willing to run on every new image:

  1. Run python diag_stdio.py under the default environment, then under LANG=C.
  2. Run pytest on one failure that contains an em dash or a checkmark.
  3. Re-run with PYTHONUTF8=1 and confirm the failure text is intact.
  4. Commit the env flags in CI YAML, not in a comment on my laptop.

Would I still decorate assertion messages after this notebook? I would, because I read them at 2 a.m. and greyscale logs slow me down. I would just stop assuming the reporter can speak the same alphabet as my editor.

Limitations, and who should skip this

This notebook is about CPython stdio encoding on Unix-like servers, which is a narrow slice of Unicode. It is not a tutorial for files, databases, or HTTP bodies, which need their own explicit encodings at the boundary. It will not save you if the failing process is not Python, or if a supervisor truncates logs before the codec runs. It also will not save you if you wrap pytest in a tool that re-encodes output as ASCII on purpose.

Do not use this approach if you are bound to a seven-bit wire protocol, or if a regulator requires exact byte-for-byte logs in a legacy code page. Do not use a disposable shared server for secrets, production data, or proprietary corpora. Free model access is useful for generating the diagnostic, not for inventing a locale that your image does not have. If you cannot run even a tiny reproduction outside your laptop, you are not debugging encoding; you are guessing.

I am not claiming a benchmark, a quota, a hardware profile, or a particular model name, because those would be fan fiction. I am claiming a workflow: keep a clean shell, print the codec, and make UTF-8 an opt-in you control. If you already have that shell, you do not need mine.

Top comments (1)

Collapse
 
raknaos profile image
Baptiste Le Bouquin

"A reporter crash wearing a test-shaped coat" is exactly the right frame. I hit nearly the same thing on a minimal CI image: the traceback said UnicodeEncodeError but pointed inside pytest's terminal writer, so half the team read it as a framework bug and the other half as flaky tests. The nastiest part is that the failure happens during reporting, so the exit code and whatever partial output survived both lie to you.

Two things that made the whole class of bug disappear for us: PYTHONUTF8=1 as belt-and-suspenders in the container definition, and a trivial smoke check in the entrypoint that fails loudly at boot if sys.stdout.encoding isn't utf-8 — better than finding out at 2am mid-run. I stopped reaching for PYTHONIOENCODING=utf-8 since it forces encoding even where you'd sometimes want pipes to stay bytes.

Worth flagging: it's not purely a locale problem either. Piping output anywhere (xdist, CI log collectors) strips the TTY layer that was quietly papering over the encoding on your laptop, so the same suite can fail only under -n auto. Did your reproduction survive xdist, or did you keep it single-process to isolate the codec? Also glad PEP 686 is finally making UTF-8 the default — the hours the ecosystem collectively burns on this are absurd.