Binary files and streams

Bytes and characters

A Ruby string is bytes plus an encoding. length counts characters and bytesize counts bytes, and the two disagree as soon as the text leaves ASCII:

ruby -e 's = "café"; p s.bytesize, s.length, s.encoding'

5
4
#<Encoding:UTF-8>

Four characters, five bytes, because é takes two bytes in UTF-8. Any code that reads a fixed number of bytes and expects a number of characters has to convert: read bytes, then force_encoding and check valid_encoding?.

A string read from a binary file has the encoding ASCII-8BIT, which is Ruby's name for "just bytes":

ruby -e 'File.binwrite("b.bin", "\x00\x01".b); p File.binread("b.bin").encoding'

#<Encoding:BINARY (ASCII-8BIT)>

binread and binwrite

File.binread and File.binwrite are the same as read and write with binary mode, and .b on a literal marks the string as bytes. The bytes come back as integers:

ruby -e 'File.binwrite("b.bin", "\x00\x01".b); p File.binread("b.bin").bytes'

[0, 1]

Reading a PNG, a gzip or a small database this way is fine when the file is small. On a text file the binary form is wrong: the newline translation and the encoding both change, and the bytes are no longer what a text reader expects.

Reading in chunks

A large binary file is read a block at a time. read(n) returns n bytes and nil at the end, which makes it the condition of a loop:

File.open("b.bin", "rb") do |file| while (chunk = file.read(1)) p chunk.bytes end end

ruby -e 'File.binwrite("b.bin", "\x00\x01".b); File.open("b.bin", "rb") { |f| while (chunk = f.read(1)); p chunk.bytes; end }'

[0]
[1]

The chunk size is the tuning knob: 64 KB is a good default for a file, and a copy loop picks the size that keeps the disk busy without holding the whole file. The "rb" matters on Windows, where a text-mode handle would translate the bytes.

IO.copy_stream

To copy a file, do not read it and write it back in Ruby. IO.copy_stream moves the bytes in the interpreter's own loop, without building a string of the whole file:

ruby -e 'File.binwrite("b.bin", "\x00\x01".b); IO.copy_stream("b.bin", "c.bin"); p File.size("c.bin")'

2

It also takes streams rather than names, so the same call copies a network socket into a file or one open file into another. For a download, pass the response body and the target handle and skip the buffer entirely.

What to watch for

puts on a binary string can truncate at a null byte in a terminal, and a text editor may rewrite the line endings. Write binary data with binwrite or a "wb" handle, never with puts.

An encoding error such as invalid byte sequence in UTF-8 means a text operation met binary data. Find the read that should have been binread rather than forcing the encoding on the result.

Next: File metadata and permissions

Article author: Arthur Isaev