ZetCode

Perl directories

last modified October 9, 2026

Directories are the containers of a file system, and Perl offers both built-in functions and core modules for working with them. The built-ins opendir, readdir, closedir, mkdir, rmdir, chdir, and glob cover the everyday operations, while File::Path, File::Find, File::Basename, File::Spec, and Cwd add higher level tools on top of them.

A directory handle is not a filehandle: you open a directory with opendir, read its entries with readdir, and release it with closedir. Entries come back as bare names without any path prefix, and the order in which a file system reports them is not guaranteed, so sorting is usually the first thing a directory program does.

In this tutorial we list, filter, sort, create, remove, and walk directories in Perl with practical examples. Every program is complete and runnable, and each one builds its own scratch directory with File::Temp::tempdir, so nothing is written outside of it. The output of every example is fixed and does not depend on the machine it runs on.

Listing a directory with opendir, readdir, and closedir

The opendir function opens a directory and returns a handle. The readdir function then returns the entries of that directory one at a time in scalar context, and closedir releases the handle.

list_dir.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use File::Temp qw(tempdir);

my $dir = tempdir(CLEANUP => 1);

open my $fh, '>', "$dir/alpha.txt" or die $!;
close $fh;

mkdir "$dir/subdir" or die $!;

opendir my $dh, $dir or die "Cannot open directory: $!";

my @names;

while (my $entry = readdir $dh) {
    push @names, $entry;
}

closedir $dh;

say for sort @names;

readdir returns names only, without any path prefix, and it includes the special entries . and .., so a raw listing is rarely what a program wants. The example sorts the names because the order in which readdir reports entries is decided by the file system and is not guaranteed to be alphabetical. A directory is never read into memory as a whole: the handle walks it entry by entry, which keeps memory use constant even for very large directories.

$ perl list_dir.pl
.
..
alpha.txt
subdir

Filtering out . and .. and hidden entries

Almost every listing has to skip the . and .. entries. A regular expression that tests for a leading dot removes them together with the hidden files.

filter_entries.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use File::Temp qw(tempdir);

my $dir = tempdir(CLEANUP => 1);

open my $fh, '>', "$dir/report.txt" or die $!;
close $fh;

open my $hidden, '>', "$dir/.config" or die $!;
close $hidden;

mkdir "$dir/archive" or die $!;

opendir my $dh, $dir or die "Cannot open directory: $!";
my @all = readdir $dh;
closedir $dh;

my @visible = grep { !/^\./ } @all;
my @hidden  = grep { /^\./ && $_ ne '.' && $_ ne '..' } @all;

say "visible: @{[ sort @visible ]}";
say "hidden:  @{[ sort @hidden ]}";

grep { !/^\./ } drops the two navigation entries and every hidden name. When hidden files matter, compare the names directly instead: grep { $_ ne '.' && $_ ne '..' } keeps dotfiles such as .config. Directory names never carry a trailing newline, so no chomp is needed, and readdir returns undef once the last entry has been read.

$ perl filter_entries.pl
visible: archive report.txt
hidden:  .config

Sorting directory entries deterministically

Because readdir order is arbitrary, sorting is what makes a listing reproducible. Perl's sort is a string sort by default, and extra comparisons are easy to add.

sort_entries.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use File::Temp qw(tempdir);

my $dir = tempdir(CLEANUP => 1);

for my $name (qw(Beta.txt alpha.md Gamma.txt delta.txt)) {
    open my $fh, '>', "$dir/$name" or die $!;
    close $fh;
}

opendir my $dh, $dir or die "Cannot open directory: $!";
my @entries = grep { !/^\./ } readdir $dh;
closedir $dh;

say "default:      @{[ sort @entries ]}";
say "case folding: @{[ sort { lc $a cmp lc $b or $a cmp $b } @entries ]}";
say "by length:    @{[ sort { length $a <=> length $b or $a cmp $b } @entries ]}";
say "reverse:      @{[ sort { $b cmp $a } @entries ]}";

cmp compares strings and <=> compares numbers. The or in the middle two lines adds a tie-breaker, so names of equal length still come out in a stable alphabetical order. The last line reverses the default sort, and folding with lc makes the comparison ignore the case of the names. The same block applies to any list of names, including the output of glob.

$ perl sort_entries.pl
default:      Beta.txt Gamma.txt alpha.md delta.txt
case folding: alpha.md Beta.txt delta.txt Gamma.txt
by length:    Beta.txt alpha.md Gamma.txt delta.txt
reverse:      delta.txt alpha.md Gamma.txt Beta.txt

Listing with glob

The glob function expands shell style wildcards such as *, ?, and [a-z], and returns the matching paths as a list.

glob_dir.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use File::Path qw(make_path);
use File::Temp qw(tempdir);

my $dir = tempdir(CLEANUP => 1);

my @files = (
    'index.txt', 'notes.md', 'script.pl',
    'src/app.pl', 'src/util.pl', 'docs/readme.md'
);

for my $name (@files) {
    my ($subdir) = $name =~ m{^(.+)/[^/]+$};
    make_path("$dir/$subdir") if defined $subdir;

    open my $fh, '>', "$dir/$name" or die $!;
    close $fh;
}

my @flat = glob "$dir/*";
s!^\Q$dir/\E!! for @flat;
say "flat:   @{[ sort @flat ]}";

my @nested = glob "$dir/*/*";
s!^\Q$dir/\E!! for @nested;
say "nested: @{[ sort @nested ]}";

my @text = glob "$dir/*.txt";
s!^\Q$dir/\E!! for @text;
say "text:   @{[ sort @text ]}";

glob returns paths in file system order, does not remove duplicates, never matches dotfiles unless the pattern starts with a dot, and does not descend into subdirectories by itself. The angle bracket form <*.pl> is an abbreviation for the same operation, but <$fh> is a readline, so calling glob with an explicit pattern string keeps the intent clear. The pairs {a,b} are alternatives inside a pattern, and ** only recurses when the File::Glob flags ask for it; for real tree walking use File::Find, which is covered further down.

$ perl glob_dir.pl
flat:   docs index.txt notes.md script.pl src
nested: docs/readme.md src/app.pl src/util.pl
text:   index.txt

File test operators

File test operators answer questions about a path. Each one is a single letter behind a hyphen and returns true or false, so they are normally used as conditions inside if statements or in a ternary expression.

file_tests.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use File::Temp qw(tempdir);

my $dir = tempdir(CLEANUP => 1);

my $file = "$dir/sample.txt";
open my $fh, '>', $file or die $!;
print {$fh} "hello\n";
close $fh;
chmod 0644, $file;

my $empty = "$dir/empty.txt";
open my $efh, '>', $empty or die $!;
close $efh;
chmod 0644, $empty;

mkdir "$dir/folder" or die $!;

say "exists:       " . (-e $file  ? 1 : 0);
say "is file:      " . (-f $file  ? 1 : 0);
say "is dir:       " . (-d $file  ? 1 : 0);
say "non-empty:    " . (-s $file  ? 1 : 0);
say "empty file:   " . (-z $empty ? 1 : 0);
say "readable:     " . (-r $file  ? 1 : 0);
say "writable:     " . (-w $file  ? 1 : 0);
say "executable:   " . (-x $file  ? 1 : 0);
say "missing path: " . (-e "$dir/nope.txt" ? 1 : 0);

-e tests existence, -f a plain file, -d a directory, -s a non-empty file, -z an empty file, and -r, -w, and -x stand for readable, writable, and executable. The results are cached: a test without an argument reuses the last stat, so -d _ repeats the previous test without touching the disk. Permission tests describe the current user rather than the mode bits of the file, which is why the freshly written file is not executable.

$ perl file_tests.pl
exists:       1
is file:      1
is dir:       0
non-empty:    1
empty file:   1
readable:     1
writable:     1
executable:   0
missing path: 0

Checking whether a path is a file or a directory

Classifying a path means combining several tests. The order matters: -l has to be checked before -f and -d, because those two follow symbolic links.

path_type.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use File::Temp qw(tempdir);

my $dir = tempdir(CLEANUP => 1);

open my $fh, '>', "$dir/sample.txt" or die $!;
close $fh;

mkdir "$dir/folder" or die $!;

symlink 'sample.txt', "$dir/shortcut.txt" or die $!;

sub describe {
    my $path = shift;

    return "missing"   unless -e $path;
    return "symlink"   if -l $path;
    return "directory" if -d $path;
    return "file"      if -f $path;
    return "other";
}

for my $name (qw(sample.txt folder shortcut.txt absent.txt)) {
    printf "%-13s -> %s\n", $name, describe("$dir/$name");
}

The example funnels every question through one subroutine, which keeps the logic in a single place when the same classification is needed in several branches of a program. A symbolic link that points to a file reports true for both -l and -f, while a dangling link reports false for -e but still true for -l.

$ perl path_type.pl
sample.txt    -> file
folder        -> directory
shortcut.txt  -> symlink
absent.txt    -> missing

Creating directories with mkdir and File::Path::make_path

mkdir creates a single directory. To create missing parents as well, use File::Path::make_path, which builds every component of the path in one call.

make_dirs.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use File::Path qw(make_path);
use File::Temp qw(tempdir);

my $dir = tempdir(CLEANUP => 1);

mkdir "$dir/logs", 0755 or die "mkdir failed: $!";
chmod 0755, "$dir/logs";

make_path("$dir/build/tmp/cache");

say "logs exists:       " . (-d "$dir/logs"           ? 1 : 0);
say "build tree exists: " . (-d "$dir/build/tmp/cache" ? 1 : 0);

my @created = grep { -d "$dir/$_" } qw(logs build build/tmp build/tmp/cache);
s!^\Q$dir/\E!! for @created;

say "created:           @created";
say "logs mode:         " . sprintf("%04o", (stat "$dir/logs")[2] & 07777);

The mode passed to mkdir is masked by the umask of the process, so the directory can end up with fewer permissions than requested; the example calls chmod afterwards to pin the mode down. make_path accepts more than one path at a time, takes an optional mode option, and reports problems on STDERR while continuing, so its return value should be checked when a failure has to stop the program. It fails if the directory already exists.

$ perl make_dirs.pl
logs exists:       1
build tree exists: 1
created:           logs build build/tmp build/tmp/cache
logs mode:         0755

Removing directories with rmdir and File::Path::remove_tree

rmdir removes an empty directory only. Removing an entire tree takes File::Path::remove_tree, which descends into the subdirectories and deletes their contents first.

remove_dirs.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use File::Path qw(make_path remove_tree);
use File::Temp qw(tempdir);

my $dir = tempdir(CLEANUP => 1);

mkdir "$dir/scratch" or die "mkdir failed: $!";
rmdir "$dir/scratch" or die "rmdir failed: $!";

say "empty dir removed:  " . (-d "$dir/scratch" ? 0 : 1);

make_path("$dir/tmp/a/b");

open my $deep, '>', "$dir/tmp/a/b/deep.txt" or die $!;
close $deep;

open my $top, '>', "$dir/tmp/root.txt" or die $!;
close $top;

rmdir "$dir/tmp" or say "rmdir on a non-empty dir failed as expected";

remove_tree("$dir/tmp");

say "tree removed:       " . (-d "$dir/tmp" ? 0 : 1);
say "nested file removed: " . (-e "$dir/tmp/a/b/deep.txt" ? 0 : 1);

Passing a non-empty directory to rmdir fails, which is the safety net that keeps a simple cleanup from taking a whole tree with it. remove_tree has no such protection, so it should only be called with a path that the program built itself, as the example does. Symbolic links inside the tree are removed rather than followed, and after the call the directory no longer exists, so no separate cleanup is required.

$ perl remove_dirs.pl
empty dir removed:  1
rmdir on a non-empty dir failed as expected
tree removed:       1
nested file removed: 1

Walking a tree recursively with File::Find

File::Find walks a directory tree recursively. It calls a callback for every entry it encounters, starting with the directory that was passed in.

find_tree.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use File::Find;
use File::Path qw(make_path);
use File::Temp qw(tempdir);

my $root = tempdir(CLEANUP => 1);

make_path("$root/$_") for qw(src docs data);

for my $name (qw(index.txt src/main.pl src/helper.pl docs/readme.md data/values.csv)) {
    open my $fh, '>', "$root/$name" or die $!;
    close $fh;
}

my @found;

find({ wanted => sub { push @found, $File::Find::name }, no_chdir => 1 }, $root);

my @relative = sort map { s!^\Q$root/\E!!r } grep { $_ ne $root } @found;

say for @relative;

my $dirs  = grep { -d "$root/$_" } @relative;
my $files = grep { -f "$root/$_" } @relative;

say "directories: $dirs";
say "files:       $files";

By default find changes into each directory before visiting it, so $File::Find::name becomes relative to that directory and the process is left in an unexpected working directory. The no_chdir option keeps the paths anchored at the starting point and is usually what a modern program wants. Inside the callback $_ holds the bare name, $File::Find::dir the current directory, and $File::Find::name the full path. Entries arrive in file system order, so the example collects them and sorts before printing. The related function finddepth visits the contents of a directory before the directory itself.

$ perl find_tree.pl
data
data/values.csv
docs
docs/readme.md
index.txt
src
src/helper.pl
src/main.pl
directories: 3
files:       5

Splitting paths with File::Basename and building them with File::Spec

Paths should be taken apart with File::Basename and put together with File::Spec instead of with ad hoc string operations.

path_parts.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use File::Basename qw(basename dirname fileparse);
use File::Spec;

my $path = '/var/log/nginx/access.log';

say "basename:  " . basename($path);
say "no suffix: " . basename($path, '.log');
say "dirname:   " . dirname($path);

my ($name, $dirs, $suffix) = fileparse($path, qr/\.[^.]*/);
say "fileparse: $name | $dirs | $suffix";

say "catfile:   " . File::Spec->catfile('data', 'logs', 'app.log');
say "catdir:    " . File::Spec->catdir('data', 'logs');
say "splitpath: " . join ' | ', File::Spec->splitpath('data/app.log');
say "absolute:  " . (File::Spec->file_name_is_absolute($path) ? 1 : 0);

basename returns the last component of a path and can strip a known suffix, while dirname returns everything in front of it. fileparse splits a path into name, directory, and suffix in one step, and it returns a list, so the destination list is written first. catfile and catdir join components using the separator of the current system, and splitpath breaks a path into volume, directory, and file parts. The leading vertical bar in the splitpath output stands for the empty volume name used on Unix; on a system with drive letters the same slot would hold the drive.

$ perl path_parts.pl
basename:  access.log
no suffix: access
dirname:   /var/log/nginx
fileparse: access | /var/log/nginx/ | .log
catfile:   data/logs/app.log
catdir:    data/logs
splitpath:  | data/ | app.log
absolute:  1

Getting and changing the working directory

Cwd::getcwd returns the current working directory as an absolute path and abs_path resolves a relative path against it, while chdir changes the working directory of the process.

working_dir.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;
use Cwd qw(getcwd abs_path);
use File::Spec;
use File::Temp qw(tempdir);

my $start = getcwd();

say "start absolute: " . (File::Spec->file_name_is_absolute($start) ? 1 : 0);

my $dir = tempdir(CLEANUP => 1);
mkdir "$dir/child" or die $!;

chdir $dir or die "chdir failed: $!";
chdir "$dir/child" or die "chdir failed: $!";

say "in child dir:   " . (getcwd() eq abs_path("$dir/child") ? 1 : 0);

chdir $start or die "chdir failed: $!";

say "back to start:  " . (getcwd() eq $start ? 1 : 0);

chdir affects the whole process, not just the current subroutine, so a program that changes directory should save the original path and return to it before it exits. getcwd reports the physical path, which differs from the logical path when a symbolic link was used to enter the directory. A failed chdir is silent unless the return value is checked, and File::Temp::tempdir never changes the working directory by itself, which is why the example calls chdir explicitly.

$ perl working_dir.pl
start absolute: 1
in child dir:   1
back to start:  1

Best Practices

In this article we have covered directories in Perl, from opendir, readdir, and glob to mkdir, rmdir, the recursive helpers of File::Path and File::Find, and the path utilities of File::Basename, File::Spec, and Cwd.

Source

Perl built-in functions · File::Path · File::Find · File::Basename · File::Spec · Cwd · File::Temp

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.