LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
PerlBeginner~1.5 hours

Find & Replace Tool

Build a command-line script that finds and replaces text across many files.

Regex SubstitutionFile I/OCommand-Line Args

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.

What You'll Build
  • 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 anywhere
my $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
}
Example Usage

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

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.