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-run` and `--backup` flags.
- 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///g` that 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 `@ARGV` that holds command-line arguments.
- Regular expressions — `s///` substitution, the `g` (global) and `i` (case-insensitive) flags.
- File handling — `open`/`close`, reading and writing with file handles.
- Subroutines — defining a `sub` and passing arguments through `@_`.
- Perl's `glob()` function for expanding a wildcard pattern like `*.txt` into 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-insensitive` flag that appends the `i` modifier to the substitution.
- Add a `--regex` flag that skips `quotemeta()` so `$search` can be used as a real, unescaped regular expression.
- Recurse into subdirectories using `File::Find` instead of a single flat `glob()` pattern.
- Print a unified diff of each file's before/after content instead of just a replacement count, using the `Text::Diff` module.
- Add an `--interactive` mode 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.