Overview
Find-and-replace across a batch of files is the job Perl was originally famous for — its name is even jokingly backronymed as "Practical Extraction and Report Language" for exactly this kind of text-wrangling. The core operation is s/pattern/replacement/g, Perl's substitution operator, which searches a string for every match of pattern and swaps in replacement, and the g flag is what makes it replace every occurrence in the string rather than stopping after the first.
By the end of this tutorial you will have a script that takes a search pattern, a replacement, and a filename glob from the command line via @ARGV, finds every matching file, and runs a substitution across each one — with a --dry-run flag that reports what would change without touching any file, and a --backup option that saves the original before overwriting it. That combination of "show me what would happen" and "keep the original just in case" is what turns a script that edits files in place from something risky into something safe to run against a real project.
- Command-line argument parsing from
@ARGV, including--dry-runand--backupflags. - File discovery with Perl's built-in
glob()function against a wildcard pattern. - A
read_file()helper that slurps an entire file into one string with proper error checking. - A substitution step using
s///gthat also counts how many replacements were made. - A
write_file()helper that overwrites a file only when not in dry-run mode, optionally backing up the original first. - A summary report listing every file touched and how many replacements were made in each.
Prerequisites
- Scalars and arrays — including the special array
@ARGVthat holds command-line arguments. - Regular expressions —
s///substitution, theg(global) andi(case-insensitive) flags. - File handling —
open/close, reading and writing with file handles. - Subroutines — defining a
suband passing arguments through@_. - Perl's
glob()function for expanding a wildcard pattern like*.txtinto a list of matching filenames.
Project Structure
The whole tool is one script, find_replace.pl. It is organized into three small subroutines — read_file(), write_file(), and process_file() — called from a short main-style block at the bottom that parses @ARGV, expands the glob pattern into a file list, and loops over that list calling process_file() on each one. Keeping the file-reading and file-writing logic in their own subroutines means process_file()'s job is just "read, substitute, decide whether to write" without also juggling open/close boilerplate inline.
This script edits files in place, which is inherently a little dangerous — a typo in the search pattern could silently corrupt every matching file in a directory. That is exactly why --dry-run and --backup exist as first-class options here rather than being left as an afterthought: a script that modifies files on disk should always give the person running it a safe way to preview the change first.
Step 1: Read Command-Line Arguments From @ARGV
@ARGV is a special Perl array that automatically holds every command-line argument the script was invoked with, in order — it never needs to be populated manually. This script expects the search pattern and replacement as the first two positional arguments, an optional file glob as the third, and any --dry-run/--backup flags mixed in among them, so the flags are filtered out of @ARGV first with grep, leaving only the positional arguments behind.
use strict;use warnings;
# --dry-run and --backup can appear anywhere in @ARGV; check for them and# then strip them out, leaving only the positional arguments behind.my $dry_run = grep { $_ eq '--dry-run' } @ARGV; # True (non-zero) if the flag is present anywheremy $backup = grep { $_ eq '--backup' } @ARGV;@ARGV = grep { $_ ne '--dry-run' && $_ ne '--backup' } @ARGV; # Remaining args are positional
# After flag removal, exactly three positional arguments must remain:# search pattern, replacement text, and a file glob.if (@ARGV != 3) { die "Usage: perl find_replace.pl [--dry-run] [--backup] <search> <replace> <glob>\n";}
my ($search, $replace, $file_glob) = @ARGV; # e.g. ('foo', 'bar', '*.txt')Step 2: Expand a File Glob Pattern
Perl's built-in glob() function expands a shell-style wildcard pattern like *.txt into the list of filenames on disk that actually match it, the same way a shell expands *.txt before a command ever sees it. This matters on Windows in particular, where the shell does not always expand wildcards itself before handing them to a program — calling glob() inside the script guarantees the expansion happens consistently no matter what shell invoked it.
my @files = glob($file_glob); # e.g. glob('*.txt') => ('notes.txt', 'todo.txt', ...)
unless (@files) { die "No files matched pattern '$file_glob'\n"; # Fail loudly rather than silently doing nothing}
print "Found " . scalar(@files) . " file(s) matching '$file_glob'\n";Step 3: Read a File's Full Contents
read_file() "slurps" the whole file into one scalar string rather than processing it line by line, which is the right approach here because s///g needs to see the entire file content at once to replace every match throughout it, including matches that might span what would otherwise be separate lines. Setting local $/ (the input record separator) to undef inside the block is the standard Perl idiom for slurp mode — it tells the <$fh> read to treat the whole file as a single "line."
sub read_file { my ($path) = @_; open(my $fh, '<', $path) or die "Could not open '$path' for reading: $!\n"; local $/; # Undefining the input record separator makes <$fh> read the whole file at once my $content = <$fh>; close($fh); return $content; # A single scalar string holding the entire file's text}Click Run to see what this code prints.
Step 4: Substitute With s/// and Count Matches
In list context, s///g returns the number of replacements it made rather than just a true/false success flag — assigning its result to a scalar like my $count = ($content =~ s/$search/$replace/g) captures that number directly. quotemeta() escapes any regex metacharacters in $search before it is used, so a literal period or dollar sign typed on the command line is matched literally rather than being interpreted as regex syntax.
sub substitute_text { my ($content, $search, $replace) = @_; my $pattern = quotemeta($search); # Escapes regex metacharacters so $search is matched literally my $count = ($content =~ s/$pattern/$replace/g); # /g replaces every occurrence; returns the match count return ($content, $count // 0); # s///g returns undef (via //) rather than 0 when there were no matches}Step 5: Write Back, With a Dry-Run Option
write_file() is only ever called when $dry_run is false — in dry-run mode process_file() (built in the next step) reports what it would have replaced without calling this subroutine at all, so no file on disk is ever touched during a dry run. When $backup is true, the original content is written to $path.bak before the real file is overwritten, giving a way back to the pre-substitution version if the replacement turns out to be wrong.
sub write_file { my ($path, $content, $make_backup) = @_;
if ($make_backup) { open(my $backup_fh, '>', "$path.bak") or die "Could not create backup '$path.bak': $!\n"; print $backup_fh read_file($path); # Write the file's current (pre-replacement) content as the backup close($backup_fh); }
open(my $fh, '>', $path) or die "Could not open '$path' for writing: $!\n"; print $fh $content; # Overwrite the file with the substituted content close($fh);}Step 6: Tie It Together Across Every Matched File
process_file() composes every subroutine written so far: read, substitute, then either write back or just report, depending on $dry_run. Looping this over @files from Step 2 is what turns a single-file operation into the batch find-and-replace tool the project is named after — the same four-line body runs once per matched file, with its own independent replacement count.
sub process_file { my ($path, $search, $replace, $dry_run, $backup) = @_;
my $original = read_file($path); my ($updated, $count) = substitute_text($original, $search, $replace);
if ($count == 0) { print " $path: no matches\n"; return; }
if ($dry_run) { print " $path: would replace $count occurrence(s) [dry run, no changes made]\n"; } else { write_file($path, $updated, $backup); print " $path: replaced $count occurrence(s)" . ($backup ? " (backup saved as $path.bak)" : "") . "\n"; }}
print "\nProcessing files:\n";foreach my $file (@files) { process_file($file, $search, $replace, $dry_run, $backup);}print "\nDone.\n";Complete Code
Here is the full script assembled in the order it runs, ready to save as find_replace.pl and run with perl find_replace.pl [--dry-run] [--backup] <search> <replace> <glob>.
use strict;use warnings;
my $dry_run = grep { $_ eq '--dry-run' } @ARGV;my $backup = grep { $_ eq '--backup' } @ARGV;@ARGV = grep { $_ ne '--dry-run' && $_ ne '--backup' } @ARGV;
if (@ARGV != 3) { die "Usage: perl find_replace.pl [--dry-run] [--backup] <search> <replace> <glob>\n";}
my ($search, $replace, $file_glob) = @ARGV;
sub read_file { my ($path) = @_; open(my $fh, '<', $path) or die "Could not open '$path' for reading: $!\n"; local $/; my $content = <$fh>; close($fh); return $content;}
sub write_file { my ($path, $content, $make_backup) = @_;
if ($make_backup) { open(my $backup_fh, '>', "$path.bak") or die "Could not create backup '$path.bak': $!\n"; print $backup_fh read_file($path); close($backup_fh); }
open(my $fh, '>', $path) or die "Could not open '$path' for writing: $!\n"; print $fh $content; close($fh);}
sub substitute_text { my ($content, $search, $replace) = @_; my $pattern = quotemeta($search); my $count = ($content =~ s/$pattern/$replace/g); return ($content, $count // 0);}
sub process_file { my ($path, $search, $replace, $dry_run, $backup) = @_;
my $original = read_file($path); my ($updated, $count) = substitute_text($original, $search, $replace);
if ($count == 0) { print " $path: no matches\n"; return; }
if ($dry_run) { print " $path: would replace $count occurrence(s) [dry run, no changes made]\n"; } else { write_file($path, $updated, $backup); print " $path: replaced $count occurrence(s)" . ($backup ? " (backup saved as $path.bak)" : "") . "\n"; }}
my @files = glob($file_glob);
unless (@files) { die "No files matched pattern '$file_glob'\n";}
print "Found " . scalar(@files) . " file(s) matching '$file_glob'\n";print "\nProcessing files:\n";foreach my $file (@files) { process_file($file, $search, $replace, $dry_run, $backup);}print "\nDone.\n";Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a
--case-insensitiveflag that appends theimodifier to the substitution. - Add a
--regexflag that skipsquotemeta()so$searchcan be used as a real, unescaped regular expression. - Recurse into subdirectories using
File::Findinstead of a single flatglob()pattern. - Print a unified diff of each file's before/after content instead of just a replacement count, using the
Text::Diffmodule. - Add an
--interactivemode that asks "Replace in this file? (y/n)" before writing each one.
Summary
You built a batch find-and-replace tool that reads its instructions from @ARGV, expands a wildcard into a file list with glob(), and edits each file's contents in memory with s///g before deciding whether to actually write the result back. The dry-run and backup options you added are not incidental extras — they are the difference between a script that is safe to run against real files and one that risks silently destroying data on a typo, a distinction worth carrying into every file-modifying script you write from here on.