ZetCode

Perl binary files

last modified November 7, 2026

A binary file is a sequence of bytes that is not meant to be read as lines of text. Images, archives, databases, executables and network captures are all binary files. Perl reads and writes them with the same open function it uses for text, but the program must make sure that no byte is changed on the way in or out, and it usually reads the data in blocks of a known size instead of line by line.

This article covers the tools for working with binary data:

The pack and unpack articles describe the templates in detail. The examples were run with Perl 5.44 on Linux.

Bytes and I/O layers

Every Perl filehandle has a stack of I/O layers that process the data. The :encoding(UTF-8) layer encodes characters to bytes on output and decodes them on input; the :crlf layer translates line endings. The :raw layer passes bytes through unchanged. The following program writes a string with an encoding layer and reads it back as raw bytes.

layers.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;

my $text = "caf\x{e9} \x{20ac}5\n";

open my $fh, '>:encoding(UTF-8)', 'word.txt' or die "Cannot open word.txt: $!";
print $fh $text;
close $fh;

say "characters written: ", length $text;
say "file size: ", -s 'word.txt';

open $fh, '<:raw', 'word.txt' or die "Cannot open word.txt: $!";
my $bytes = do { local $/; <$fh> };
close $fh;

say "bytes read: ", length $bytes;
say join ' ', map { sprintf '%02x', ord } split //, $bytes;

The string has eight characters, but é takes two bytes in UTF-8 and the euro sign takes three, so the file has eleven bytes. Reading with <:raw gives back exactly these bytes, which we print in hexadecimal. A binary file must always be opened with :raw (or have binmode called on its handle); otherwise a layer such as :crlf on Windows or an encoding set by the open pragma would silently change the data.

$ perl layers.pl
characters written: 8
file size: 11
bytes read: 11
63 61 66 c3 a9 20 e2 82 ac 35 0a

Writing and reading bytes

The pack function builds a string of bytes from numbers. The template C* turns each number from 0 to 255 into one byte.

bytes.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;

my $file = 'bytes.bin';

open my $fh, '>:raw', $file or die "Cannot open $file: $!";
print $fh pack 'C*', 0 .. 255;
close $fh;

say "$file has ", -s $file, " bytes";

open $fh, '<:raw', $file or die "Cannot open $file: $!";
my $data = do { local $/; <$fh> };
close $fh;

my @bytes = unpack 'C*', $data;

say "first: @bytes[0 .. 7]";
say "last:  @bytes[-8 .. -1]";
say "newline byte at offset ", index $data, "\n";

The file gets all 256 possible byte values. To read the whole file, we set the input record separator $/ to undef, so the readline operator returns everything at once. The unpack 'C*' call turns the bytes back into a list of numbers. Byte 10 is the newline character; in a binary file it is just an ordinary byte, which is why reading binary data line by line makes no sense.

$ perl bytes.pl
bytes.bin has 256 bytes
first: 0 1 2 3 4 5 6 7
last:  248 249 250 251 252 253 254 255
newline byte at offset 10

Reading in chunks

Slurping is fine for small files. Large files are read in blocks with the read function, which takes a filehandle, a buffer variable and the number of bytes to read.

chunks.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;

my $file = shift // 'bytes.bin';
my $size = 100;

open my $fh, '<:raw', $file or die "Cannot open $file: $!";

my ($total, $chunks) = (0, 0);

while (1) {
    my $buf;
    my $n = read $fh, $buf, $size;
    die "Read error: $!" unless defined $n;
    last if $n == 0;

    $chunks++;
    $total += $n;
    say "chunk $chunks: $n bytes";
}

close $fh;

say "total: $total bytes";

The read function returns the number of bytes actually read, 0 at the end of the file, and undef on an error. The last block is usually shorter than the requested size, so the program must use the return value and not assume a full buffer. The buffer variable is declared on its own line; writing read $fh, my $buf, $size as a statement triggers a "Parentheses missing around my list" warning.

$ perl chunks.pl
chunk 1: 100 bytes
chunk 2: 100 bytes
chunk 3: 56 bytes
total: 256 bytes

Fixed-size records

Many binary formats store a sequence of records of the same length. Each record is written with pack and read back with unpack, using the same template.

records.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;

my $file = 'users.dat';
my $tpl = 'A12 C n';
my $len = length pack $tpl;

my @users = (
    ['Alice', 34, 1250],
    ['Bob', 27, 980],
    ['Carol', 45, 3120],
    ['Dave', 52, 410],
);

open my $fh, '>:raw', $file or die "Cannot open $file: $!";
print $fh pack $tpl, @$_ for @users;
close $fh;

say "record length: $len bytes";
say "file size: ", -s $file, " bytes";

open $fh, '<:raw', $file or die "Cannot open $file: $!";

while (read $fh, my $rec, $len) {
    my ($name, $age, $points) = unpack $tpl, $rec;
    printf "%-6s %3d %6d\n", $name, $age, $points;
}

close $fh;

The template A12 C n describes a 12-byte space-padded name, an unsigned byte for the age, and a 16-bit big-endian number for the points. Calling pack with the template alone and no values gives a record filled with defaults, whose length is the record size of 15 bytes. Four records make a 60-byte file. When reading, A12 strips the padding spaces from the name.

$ perl records.pl
record length: 15 bytes
file size: 60 bytes
Alice   34   1250
Bob     27    980
Carol   45   3120
Dave    52    410

Random access with seek and tell

Because all records have the same size, record number i starts at byte offset i × record length. The seek function moves the file position there directly, without reading the records before it.

seek.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;

use Fcntl qw(:seek);

my $file = 'users.dat';
my $tpl = 'A12 C n';
my $len = length pack $tpl;

open my $fh, '<:raw', $file or die "Cannot open $file: $!";

my $count = (-s $fh) / $len;
say "records: $count";

for my $i (2, 0, 3) {
    seek $fh, $i * $len, SEEK_SET or die "Cannot seek: $!";
    my $rec;
    read $fh, $rec, $len;
    my ($name, $age) = unpack $tpl, $rec;
    say "record $i: $name ($age)";
}

seek $fh, -$len, SEEK_END;
say "last record starts at offset ", tell $fh;

seek $fh, 12, SEEK_CUR;
my $age;
read $fh, $age, 1;
say "age in last record: ", ord $age;

close $fh;

The third argument of seek says what the offset is relative to. The constants come from the Fcntl module: SEEK_SET counts from the start of the file, SEEK_CUR from the current position, and SEEK_END from the end, usually with a negative offset. The tell function returns the current position. The number of records is the file size divided by the record length; the -s test also accepts an open filehandle. After moving to the last record, we skip its 12-byte name with a relative seek and read the single age byte.

$ perl seek.pl
records: 4
record 2: Carol (45)
record 0: Alice (34)
record 3: Dave (52)
last record starts at offset 45
age in last record: 52

Updating a record in place

The +< mode opens a file for reading and writing without truncating it. Combined with seek, it lets us change one record and leave the rest of the file untouched.

update.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;

use Fcntl qw(:seek);

my $file = 'users.dat';
my $tpl = 'A12 C n';
my $len = length pack $tpl;

my ($index, $bonus) = (1, 500);

open my $fh, '+<:raw', $file or die "Cannot open $file: $!";

seek $fh, $index * $len, SEEK_SET or die "Cannot seek: $!";
my $rec;
read $fh, $rec, $len;

my ($name, $age, $points) = unpack $tpl, $rec;
say "before: $name $points";

$points += $bonus;

seek $fh, $index * $len, SEEK_SET or die "Cannot seek: $!";
print $fh pack $tpl, $name, $age, $points;

seek $fh, 0, SEEK_SET;

while (read $fh, my $r, $len) {
    my ($n, undef, $p) = unpack $tpl, $r;
    say "$n $p";
}

close $fh;

say "file size: ", -s $file, " bytes";

The program reads Bob's record, adds 500 points, seeks back to the start of the record and writes the new version over the old one. Because the new record has the same length, the file size does not change. A seek is required between reading and writing on the same handle; it also flushes Perl's buffer, which is why we seek to the beginning before reading the records again. Never use +> for this purpose: it truncates the file first.

$ perl update.pl
before: Bob 980
Alice 1250
Bob 1480
Carol 3120
Dave 410
file size: 60 bytes

Writing binary data to standard output

Standard output is a text handle by default. A program that prints binary data, for example so that it can be piped to another tool, calls binmode on STDOUT first.

stdout.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;

binmode STDOUT;

print pack 'a4 n N', 'DEMO', 2026, 0xCAFEBABE;
print pack 'C*', 0x0d, 0x0a, 0xff, 0x00;

The binmode function without a layer argument sets the handle to raw mode. The program prints a four-byte tag, the number 2026 as a 16-bit big-endian value (07ea), the 32-bit number 0xCAFEBABE, and four more bytes including a carriage return, newline and zero byte. The xxd tool shows the bytes in hexadecimal; the Perl hex dump article builds a similar tool in Perl.

$ perl stdout.pl | xxd
00000000: 4445 4d4f 07ea cafe babe 0d0a ff00       DEMO..........

Unbuffered I/O with sysread and syswrite

The read and print functions go through Perl's I/O buffers. The sysread and syswrite functions call the operating system's read and write directly. They are useful for copying large files in big blocks and for sockets and pipes, where the program must handle data as soon as it arrives.

syscopy.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;

my ($src, $dst) = @ARGV;
die "Usage: $0 source destination\n" unless defined $dst;

open my $in, '<:raw', $src or die "Cannot open $src: $!";
open my $out, '>:raw', $dst or die "Cannot open $dst: $!";

my ($total, $calls) = (0, 0);

while (1) {
    my $buf;
    my $n = sysread $in, $buf, 64 * 1024;
    die "Read error: $!" unless defined $n;
    last if $n == 0;

    $calls++;
    my $off = 0;

    while ($off < $n) {
        my $w = syswrite $out, $buf, $n - $off, $off;
        die "Write error: $!" unless defined $w;
        $off += $w;
    }

    $total += $n;
}

close $in;
close $out or die "Cannot close $dst: $!";

say "copied $total bytes in $calls sysread calls";

The program copies a file in blocks of 64 KiB. Like read, sysread returns the number of bytes read, 0 at the end of file and undef on an error. A syswrite call may write fewer bytes than requested, so the inner loop passes the remaining length and an offset into the buffer until the whole block is written. Never mix sysread or syswrite with buffered functions such as read, print or eof on the same handle; the buffered data would be skipped or written out of order. We copy a 300,000-byte file of random data and compare the copy with the cmp tool.

$ perl syscopy.pl data.bin copy.bin && cmp data.bin copy.bin && echo identical
copied 300000 bytes in 5 sysread calls
identical

Best practices

In this article we have worked with binary files in Perl. We have used the :raw layer and binmode, read files whole and in chunks, stored fixed-size records with pack, accessed them randomly with seek and tell, updated a record in place, and copied a file with sysread and syswrite.

Source

perlfunc: binmode · perlfunc: read · perlfunc: seek · perlfunc: sysread · PerlIO

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Perl tutorials.