Legacy Perl scripts nobody wants to touch
Find which legacy Perl scripts still run, rebuild their CPAN dependencies, expose hidden regex rules, and choose what to retire or rewrite.

A fifteen-year-old Perl directory is rarely one system. It is usually a pile of cron jobs, copied utilities, emergency fixes, vendor glue, and one or two programs that still move money or customer data every night. The first job is not rewriting it. The first job is proving which files participate in production behavior.
I have seen teams begin with the largest script, clean its syntax, and discover later that production calls a smaller copy through a scheduler on another host. That is how a tidy modernization project breaks an ugly but functioning process. Treat the live system as evidence. Build an inventory from execution outward, then decide what deserves deletion, containment, repair, or replacement.
Start with execution, not the source tree
A source tree cannot tell you what still runs. You need evidence from schedulers, process records, service definitions, shell history, application launchers, file timestamps, and the systems that consume the output. A file can look abandoned while a quarterly finance job still invokes it. Another can receive daily edits from a deployment process yet never execute.
Search every scheduler that can launch work, not only the current user's crontab. Check system cron directories, service timers, batch schedulers, database job runners, CI systems, control panels, and the enterprise scheduler that operations forgot to mention. On Unix-like hosts, this first pass gives you useful leads without claiming more certainty than the machine can provide:
ps -eo pid,lstart,args | grep '[p]erl'
find /etc/cron.d /etc/cron.daily /etc/cron.hourly -type f -exec grep -nH 'perl\|\.pl' {} \;
grep -R -n 'perl\|\.pl' /etc/systemd/system /usr/lib/systemd/system
find /opt /srv /usr/local -type f \( -name '*.pl' -o -name '*.pm' \) -print
Typical process output contains a PID, start time, and full command line, which matters because the arguments often select the actual business mode:
1842 Mon Aug 10 01:00:02 2026 /usr/bin/perl /opt/billing/bin/post.pl close
Repeat process sampling across the business calendar. One snapshot misses jobs that last seconds. Keep the original path, interpreter path, working directory, user, arguments, environment source, start condition, frequency, input, output, and downstream consumer for every observed execution. If you cannot name the consumer, the inventory is not done.
Check deployment records as a separate source. A package manifest can reveal a script that lands outside the repository path you searched, and an old release script can rename or generate Perl during installation. Compare hashes across hosts instead of trusting matching filenames. Record generated files as generated, then locate the template or command that creates them. Otherwise a later deployment can quietly restore code you thought you retired. Ask operators about manual invocations too, especially month-end repairs and reruns after partial failures. Those events often leave no permanent scheduler entry.
Host logs can strengthen the evidence. Process accounting, audit logs, scheduler logs, and endpoint telemetry may show historical invocations. If those sources were never enabled, say so in the inventory. Do not turn an absence of records into a claim that a script is dead.
A call graph needs edges outside Perl
The useful call graph includes shell, JCL, scheduler entries, web-server configuration, database jobs, file arrivals, and people following runbooks. Static Perl analysis covers only part of that graph. Dynamic require, constructed module names, eval, callbacks, and configuration-selected handlers can hide edges even inside the language.
Start with literal references and shebangs, then trace outward from each confirmed entry point:
grep -R -n 'use \|require \|do ' /opt/legacy-perl
grep -R -n '/opt/legacy-perl\|perl ' /opt /srv /usr/local/etc
find /opt/legacy-perl -type f -perm -u+x -exec head -n 1 {} \;
Create a ledger with one row per runnable file. Give each row an evidence state: observed, configured, referenced, or unreferenced. Keep the states separate. A scheduler entry proves configuration, not successful execution. A filename in a runbook proves human intent, not current use. A live process is strong evidence, but it may be a stuck job rather than a healthy one.
Then record inbound and outbound edges. Inbound edges explain how execution begins. Outbound edges include modules, executables, databases, queues, mail relays, remote hosts, and files. Record data formats too. A tab-separated file with an undocumented column order is an interface even when nobody called it one.
Deletion needs two forms of evidence: no observed or configured inbound edge across a representative operating period, and no unique output that another process expects. Quarantine a candidate before deleting it. Remove its execute bit or move its scheduler entry into a disabled, version-controlled file, then watch for failed expectations. Keep a quick restoration path. Old code earns no sentimental immunity, but uncertainty is not evidence.
Reproduce the interpreter before fixing code
Perl behavior depends on more than the .pl file. Capture the exact interpreter and its compilation settings before testing anything. /usr/bin/perl may differ from a Perl installed under /opt, and a wrapper can alter PERL5LIB, locale, timezone, or current directory. Those differences can change module resolution, date parsing, sorting, and text handling.
Run the following as the production user, in the production working directory, with secrets redacted from the saved output:
command -v perl
perl -v
perl -V
perl -e 'print join("\n", @INC), "\n"'
env | grep '^PERL\|^LANG\|^LC_\|^TZ'
perl -V reports how the interpreter was built and shows configuration values that explain portability failures. The @INC dump tells you where Perl will actually look for modules, in order. Save both with the inventory. A dependency under a private application directory is easy to miss if you inspect only the operating system's package list.
Compile checks are useful, but interpret them correctly:
perl -c /opt/legacy-perl/bin/post.pl
A result such as syntax OK proves that compilation completed in that environment. It does not prove that a dynamically loaded module exists on a rare branch, that a database login works, or that the script produces correct output. Compile each confirmed entry point with the same user, environment, arguments, and working directory used in production. Never begin by adding use strict or changing warnings across the tree. Those are good practices for maintained code, but they change the diagnostic surface before you have captured baseline behavior.
Containerizing the old runtime can help reproduction, but a container is not an archaeological truth machine. Native libraries, system commands, certificates, DNS, filesystem permissions, locale data, and scheduler behavior remain outside the Perl dependency list. Record those edges instead of assuming the image captured them.
Observe safely before adding instrumentation
Runtime observation should begin outside the process because changing an old script can alter timing, environment, and error handling. Start with scheduler timestamps, process duration, open files, child processes, network destinations, exit status, and filesystem changes. Collect these facts in a copied environment when possible. On production, use read-only facilities that operations already permits and set a short observation window.
System-call tracing is useful when the source hides paths behind variables or configuration. A trace can show which module file opened, which executable a script launched, and which configuration file it tried before falling back. It also records a great deal of sensitive data and can slow a busy process. Filter for file and process operations, write the trace to protected storage, and have an operator approve the command. Never attach casually to a payment or settlement job because the trace feels read-only. Observation has operational cost.
Compare the process before and during the probe. Record start and end time, CPU and memory use, exit status, row or file counts, and the usual failure signal. If the instrumented run takes materially longer or changes ordering, discard it as a behavior baseline and investigate why. A probe that perturbs the system can still reveal dependencies, but it should not define parity.
Application-level logging comes later. Add it behind an environment switch that defaults to off, and emit events at business boundaries rather than every subroutine call. Useful events name the selected mode, input identifier, rule outcome, dependency path, external action, and final status. Do not log raw records simply because the old code has no data classification. Redact before serialization so sensitive values never enter the log sink.
A wrapper is often safer than edits to the script. The wrapper can capture the working directory, sanitized environment, command arguments, start time, end time, exit status, and hashes of designated outputs. Keep argument order unchanged and use exec when the wrapper should preserve process and signal behavior. Test signal propagation because schedulers may use termination signals to enforce a run window. A wrapper that swallows the signal changes the production contract.
Do not leave temporary tracing in place without an owner and removal date. Diagnostic hooks have a habit of becoming permanent, especially when they produce the only useful failure record. If the hook deserves to remain, treat it as maintained observability: document its schema, rotation, access control, redaction, and failure behavior. Decide what happens when the log destination fills or disappears. An old batch should not stop posting invoices because a new diagnostic disk is full.
The result of observation should update specific rows in the ledger. Replace an assumed module path with the observed path, add a newly seen child process, or change a configured entry point to observed. Keep the raw trace separately under tighter access controls and record how it was collected. That separation lets the working inventory stay useful without turning it into a store of customer data or secrets.
CPAN reconstruction is an evidence exercise
A module named in use is not automatically a CPAN dependency. It may ship with that Perl release, come from an operating system package, live in the repository, or be a locally patched copy that shares a public module's name. Conversely, a script can load a module dynamically without a visible use statement. Build the dependency set from the runtime outward.
For each confirmed entry point, capture loaded module paths in a safe test environment:
perl -MData::Dumper -e 'END { print Dumper(\%INC) } do shift' /opt/legacy-perl/bin/post.pl
That simple probe is not safe for a script that performs work as soon as it loads. Use it only against a copied environment with external writes blocked, or add a temporary diagnostic hook to a test branch. %INC maps loaded module names to the files Perl selected, which exposes private copies and path-order surprises. Exercise more than the happy path because conditional loads appear only when their branch runs.
Compare four sources: imports found in code, %INC from representative runs, files under local library directories, and installed distributions reported by the original host. perldoc perllocal may contain a record of modules installed through CPAN tooling, though system packaging and manual copies can make it incomplete. Operating system package databases supply another piece. None of these sources deserves sole authority.
Write the reconstructed direct dependencies and version constraints into cpanfile. Pin what the application proves it needs, not every transitive module present on an old server. Then use Carton or another controlled installer to resolve and install into an isolated directory. Keep the resolver output and failed build logs as project evidence.
requires 'DBI', '1.643';
requires 'DateTime', '1.54';
requires 'Text::CSV_XS', '1.49';
Those versions are illustrative, not recommendations. Your constraints must come from the working host, source requirements, and behavior tests. If no version was declared, first record the installed version that produced the baseline. Loosen it only after tests show that another version preserves behavior.
When an old release no longer installs, identify the exact failure. A missing compiler, an unavailable C library, a removed distribution, a failing test, and code incompatible with a new Perl require different fixes. Do not solve all five by copying the old site_perl directory. That copy can preserve a binary module compiled for the wrong ABI and leave you with a failure that appears later under load. Archive original distributions and patches where licensing permits, but make the new build reproducible from declared inputs.
Regular expressions are executable policy
The dangerous regex is rarely the longest one. It is the expression whose match result decides a price, account class, routing destination, rejection reason, or compliance flag. Treat those expressions as business rules even if they sit inside a substitution or a one-line grep. Formatting regexes and policy regexes need different review.
Find likely candidates with code search, then classify them by consequence. Perl's syntax makes perfect static extraction unrealistic, so inspect surrounding branches and outputs rather than counting metacharacters. Pay special attention to substitutions, capture variables such as $1, alternations with business vocabulary, and patterns built from configuration.
The perlre manual explains that Perl chooses the leftmost match first and that quantifiers are greedy by default. Those facts sound elementary, yet they become business behavior when alternatives overlap or captures feed later calculations. A later cleanup that reorders alternatives or adds an anchor can change which customer records qualify. Unicode and locale changes can also alter character classes and case folding.
Turn every consequential expression into a named table of examples before refactoring it:
my @cases = (
['ACCT-001-EU', 'eu', 1],
['ACCT-001-US', 'domestic', 1],
['acct-001-eu', undef, 0],
['ACCT-001-EU ', undef, 0],
);
for my $case (@cases) {
my ($input, $class, $accepted) = @$case;
my ($got) = $input =~ /\AACCT-\d{3}-(EU|US)\z/;
my $ok = defined $got ? 1 : 0;
die "acceptance changed for <$input>" if $ok != $accepted;
}
The sample values are invented to demonstrate the shape of a characterization test. Real cases should come from redacted production inputs, rejected records, operator examples, and boundary conditions. Preserve leading whitespace, encoding, line endings, empty fields, and malformed inputs. Normalizing fixtures too early erases the behavior you need to discover.
Avoid translating a dense regex directly into an equally dense expression in the target language. First name the rule in domain terms, retain the original pattern as a fixture oracle, and write tests around accepted values, rejected values, and extracted fields. Some expressions should become ordinary parsing code or a table because the rule needs to be reviewed by people who do not speak regex.
Record behavior at the system boundary
Unit tests added to old internals often bless implementation accidents while missing the contract that other systems depend on. Capture behavior at boundaries first: input files, command arguments, database reads, emitted rows, exit codes, standard output, standard error, messages, and state changes. This creates room to change the internal architecture later.
Choose a representative corpus from recorded production traffic or safely copied inputs. Remove or tokenize sensitive values while preserving lengths, character classes, delimiters, and relationships that affect parsing. For each run, save the interpreter fingerprint, dependency lock, environment, input hash, output, exit status, and externally visible writes. Freeze time and random sources where the program allows it. Where it does not, compare stable fields and describe every ignored field explicitly.
A small shell harness can expose more than a week of speculative reading:
case_dir=/tmp/perl-parity-case-01
mkdir -p "$case_dir"
cp fixtures/invoice.dat "$case_dir/input.dat"
cd "$case_dir" || exit 1
TZ=UTC LC_ALL=C /opt/oldperl/bin/perl /opt/app/run.pl input.dat >stdout.txt 2>stderr.txt
printf '%s\n' "$?" >exit-status.txt
find . -type f -print | sort >files.txt
Run that harness in an isolated environment because the script may send mail, modify a database, or invoke another executable. Redirecting standard output does not contain side effects. Replace external endpoints with recorders where possible, or restore a database snapshot between cases.
Compare structured data structurally. Sort only when the contract says order does not matter. Normalize timestamps only when consumers ignore them. A blanket whitespace scrub can hide fixed-width record damage; a blanket JSON sort can hide an array ordering change. Every normalizer is an assertion about the contract, so review it like code.
Ownership decides whether the inventory survives
An inventory without an owner becomes another abandoned artifact. Assign a technical owner and a business owner to every live entry point. The technical owner can explain how it runs and how to restore it. The business owner can say what outcome it supports and approve changes to that outcome. If nobody accepts business ownership, escalate that fact before retirement.
Put the ledger in version control beside the modernization work. A useful row includes the evidence state, last confirmed execution, schedule, runtime identity, inputs, outputs, consumers, dependency manifest, data sensitivity, failure signal, restart procedure, and disposition. Keep links out of the row if they point to volatile dashboards; store durable identifiers that an operator can search.
Set four dispositions, and do not pretend they are stages in one pipeline:
- Retire code with convincing non-use evidence and a reversible quarantine period.
- Contain live code whose behavior matters but whose change risk is currently higher than its maintenance cost.
- Repair code that can remain in Perl with a reproducible runtime, tests, and an owner.
- Rewrite code whose business role remains necessary and whose runtime, architecture, or staffing risk justifies replacement.
A tiny script can deserve a rewrite because it gates settlement. A large reporting program can deserve containment because it is stable, isolated, and easy to operate. Line count is a poor proxy for business risk. Use consequence, change frequency, recoverability, dependency health, and the quality of observed behavior.
Make unknowns visible
Add an explicit unknowns column instead of forcing every row into a confident state. Typical entries include an unverified database alias, an output directory with no named consumer, a password supplied by an unknown wrapper, or a module found only on one host. Assign each unknown an owner and a next observation. If an unknown has no planned observation, it has quietly become accepted risk.
The ledger should also distinguish a script instance from a source file. The same file may run under two accounts with different configuration, arguments, and permissions. Those are two production behaviors and may deserve different dispositions. Hash the deployed file so you can tell whether apparently identical paths contain different copies. Record symlink targets because a release switch can make yesterday's path refer to today's code.
Failures reveal dependencies that successful runs hide. Review scheduler mail, dead-letter directories, partial output files, retry scripts, and operator tickets. A recovery command copied into a runbook may be the only inbound edge to a repair script. If the team deletes that script because normal production never calls it, the next failed batch becomes the discovery mechanism. Mark recovery-only entry points as live and test them against a reversible failure case.
Retirement also needs an output contract. For a report, identify who receives it and what they do when it is absent. For a transfer job, identify the acknowledgement or reconciliation record. For a cleanup task, identify the storage, latency, or correctness symptom that returns when it stops. A script with no visible caller may still enforce a negative outcome, such as preventing duplicate rows or expiring temporary data. Search for the condition it suppresses.
Use the ledger during incidents. When an operator finds a new invocation, updates an interpreter, or discovers a consumer, change the record in the same commit as the operational fix. After several incident cycles, the inventory becomes more accurate because it absorbs evidence from real stress. A spreadsheet sent once to management will decay because the people who learn new facts cannot update it where they work.
Choose a rewrite seam, not a file boundary
A file-by-file rewrite preserves the accidents of the old layout. Choose a seam around an observable capability: ingesting a feed, classifying records, calculating a charge, producing a report, or posting a batch. Define the boundary with inputs, outputs, error behavior, and state changes, then make old and new implementations run the same cases.
Strangler deployments are popular because they reduce cutover size, but they are wrong when the proposed seam shares transactions or mutable state that cannot be split safely. Running two writers against one poorly understood schema creates more ambiguity than replacing a coherent batch. In that case, build a shadow reader, compare outputs, and cut over the whole write boundary once parity is strong. The seam should follow ownership of effects, not the function names in Perl.
Do not transliterate Perl idioms into a new language. Implicit globals, context-sensitive return values, autovivification, truthiness, and regex side effects may have shaped the old program, but the new architecture should make state and errors explicit. Preserve external behavior that consumers rely on. Replace internal behavior that exists only because Perl made it convenient.
CodeHero reads the whole mixed-language tree, rewrites Perl into Go, Rust, or TypeScript, and checks behavior with a parity harness against recorded production traffic. That approach fits only after execution evidence and boundary cases exist; an automated rewrite cannot recover a quarterly input nobody captured or an operator decision that lived outside the code.
The first ten working days should reduce uncertainty
The first working days should produce evidence, not a rewritten module. On day one, freeze casual cleanup and identify hosts, repositories, schedulers, and people who receive outputs. By day three, you should have confirmed entry points, runtime fingerprints, and a list of unknown consumers. By day six, representative runs should expose loaded dependencies and boundary effects. By day ten, the team should be able to argue for each disposition with records rather than confidence.
Keep production safe while gathering that evidence. Read configuration before changing it. Capture commands and outputs in a controlled log. Redact secrets. Run copied scripts only after blocking mail, database writes, remote calls, and destructive filesystem paths. Have an operator review any probe that touches a live scheduler or production account.
The awkward finding may be that nobody can rebuild the interpreter, several CPAN releases have vanished from normal installation paths, and the only specification is a regex plus last year's files. That is still progress. You now know where the risk lives. Preserve the working runtime, collect representative inputs, name the business owner, and make the behavior executable in tests.
Do not reward the first engineer who makes the old code look modern. Reward the one who proves what the business still asks it to do. Once that proof exists, deleting and rewriting become engineering decisions instead of folklore.
FAQ
How can I tell whether an old Perl script still runs?
Collect evidence from processes, every scheduler, service definitions, audit logs, and downstream outputs. A lack of recent file changes proves nothing, and a scheduler entry proves configuration rather than successful execution.
How long should I monitor before declaring a Perl script unused?
Monitor across the longest meaningful business cycle, including quarter-end, year-end, and exception runs that apply to the system. If you cannot observe that full cycle, quarantine the script reversibly and monitor for missing outputs or failed expectations.
Should I add strict and warnings before auditing legacy Perl?
Not across the whole tree. First capture the interpreter, environment, and baseline behavior, then introduce diagnostics in a controlled branch where you can separate new warnings from behavior changes.
How do I find CPAN modules used by a Perl application?
Combine static imports, %INC captured during representative runs, private library directories, host package records, and installation history. No single source sees dynamic loads, local copies, and system packages reliably.
What should I do when an old CPAN module no longer installs?
Identify whether the failure comes from the compiler, a native library, a missing release, a test, or incompatibility with the Perl version. Preserve the working artifact, then make one diagnosed change at a time behind boundary tests.
Can I copy site_perl from the old server?
Use a copy as forensic evidence, not as the new build process. Binary modules may target the old interpreter ABI, and copied trees hide the inputs you need for a repeatable installation.
How do I test business rules hidden in Perl regexes?
Build a named table of accepted, rejected, and boundary inputs from real redacted records. Assert both the match decision and any captured fields, because downstream code often treats captures as business data.
Is containerizing the old Perl runtime enough?
It can preserve part of the runtime, but it does not automatically capture native libraries, commands, certificates, permissions, locale data, or external services. Inventory and test those boundaries explicitly.
Should legacy Perl be rewritten file by file?
Usually not. Choose a capability boundary with observable inputs, outputs, errors, and state changes, then compare the old and new implementations at that boundary.
When is it safer to keep a Perl script?
Contain it when it is stable, isolated, reproducible, owned, and cheaper to operate than to replace. Age alone is not a reason to rewrite; unbounded consequences and unrecoverable runtime knowledge are stronger reasons.