What File.read returns depends on the locale¶
Level: 201 · anyone whose script works in a terminal and fails under cron, in a container or in CI
One line: File.read labels what it reads with Encoding.default_external, which Ruby takes from the locale — so under LC_ALL=C a UTF-8 file arrives labelled US-ASCII, invalid, and the first regex raises; -E UTF-8 or an encoding: argument fixes reading files, but not ENV and not ruby -e.
A terminal on a Mac or a desktop Linux usually runs with a UTF-8 locale, and there Ruby's default is UTF-8. Cron jobs, containers whose image sets no LANG, and minimal servers often run with the C locale instead, and there the default is US-ASCII. The program below writes one small file and reads it in child processes whose environment it spells out.
Verified output of default_external_follows_the_locale_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
$ LC_ALL=C ruby child.rb
Encoding.default_external US-ASCII
File.read(path).encoding US-ASCII, valid_encoding? false, length 6
p the line "caf\xC3\xA9"
line =~ /é/ ArgumentError: invalid byte sequence in US-ASCII
line.upcase "CAF\xC3\xA9"
File.read(path, encoding:) UTF-8, length 5
ENV["DISH"].encoding ASCII-8BIT, valid_encoding? true
$ LC_ALL=C ruby -E UTF-8 child.rb
Encoding.default_external UTF-8
File.read(path).encoding UTF-8, valid_encoding? true, length 5
p the line "café"
line =~ /é/ 3
line.upcase "CAFÉ"
File.read(path, encoding:) UTF-8, length 5
ENV["DISH"].encoding ASCII-8BIT, valid_encoding? true
$ LC_ALL=C ruby -e 'puts "café".length'
stderr: -e: -e:1: syntax error found (SyntaxError)
[exit status 1]
$ LC_ALL=C ruby literal.rb # the same line, saved in a file
4
Reading the output¶
- Under
LC_ALL=C, nothing fails at the read. The string is labelled US-ASCII, is invalid — US-ASCII has no bytes above 127 — and has length 6. - The first regex raises, far from the read that caused it.
upcaseis worse: it raises nothing, uppercasescaf, and leaves the two bytes it cannot read untouched. - An explicit
encoding:always wins. The same process, the same locale, length 5. -E UTF-8changes the default for the whole process, and every read without its ownencoding:follows it.ENVis not a file.ENV["DISH"]came back labelled ASCII-8BIT in both runs;-Edid not change it.ruby -ereads its program in the locale's encoding, so a one-liner containingéis aSyntaxErrorunderLC_ALL=C. The same line saved in a file runs: a source file is UTF-8 unless a magic comment says otherwise.
What to do¶
- Pass
encoding:on every read of text you did not create:File.read(path, encoding: "UTF-8"),File.foreach(path, encoding: "BOM|UTF-8"),File.open(path, "r:UTF-8"). - Set a UTF-8 locale —
LANG=C.UTF-8is the usual one — in every container image and service definition that runs Ruby, orRUBYOPT=-EUTF-8where the locale cannot be changed. - Treat a value from
ENVas bytes of unknown encoding:ENV["X"].dup.force_encoding("UTF-8"), thenvalid_encoding?.
If you are coming from another language¶
Perl does not decode a file at all unless the handle has an :encoding layer, so the locale never enters into it — every read below is bytes until the layer is added. The runner runs it under LC_ALL=C:
Verified output of default_external_follows_the_locale_pl.pl — regenerated by tools/run_examples.py, never hand-typed.
perl -C and the open pragma are how a Perl program asks for layers by default; see perlrun ↗. The Perl library's Decode at the edges ↗ is the pattern to follow instead.
Java removed this dependency in Java 18: the default charset is UTF-8 whatever the locale — the Java text library's The default charset ↗. Python has overridden the C locale for text I/O since 3.7, and the Python library's Opening a file ↗ takes apart the decisions open() makes. (Not machine-checked here.)