Claude Code for Perl to Python Migration 2026

Written by Michael Lip · Solo founder of Zovo · $400K+ on Upwork · 100% JSS Join 50+ builders · More at zovo.one

The Workflow

Systematically migrate Perl scripts to Python using Claude Code for automated translation, preserving behavior with generated test suites. Covers regex conversion, CPAN-to-PyPI module mapping, and incremental migration strategy.

Expected time: 2-4 hours per 1000 lines of Perl Prerequisites: Claude Code installed, Python 3.10+, existing Perl codebase, both perl and python interpreters available

Setup

1. Create Migration Project Structure

mkdir -p perl-migration/{original,converted,tests,mappings}
cp -r /path/to/perl/scripts/* perl-migration/original/
cd perl-migration

2. Configure CLAUDE.md for Migration

# CLAUDE.md

## Migration Rules
- Translate Perl idioms to Pythonic equivalents, not literal translations
- Perl hashes → Python dicts
- Perl arrays → Python lists
- Perl regex: convert /pattern/flags to re.compile(r'pattern', flags)
- Perl $_ → explicit variable names
- Perl `use strict; use warnings;` → Python type hints
- Always add type hints to function signatures
- Generate a requirements.txt for any PyPI packages needed
- Preserve all comments (translate if needed)
- File handling: open(my $fh, '<', $file) → with open(file) as fh:
- Error handling: eval { } → try/except with specific exceptions
- Output files go to ./converted/ directory
- Test files go to ./tests/ directory

3. Create Module Mapping Reference

cat > perl-migration/mappings/module-map.json << 'EOF'
{
  "LWP::UserAgent": "requests",
  "JSON": "json (stdlib)",
  "JSON::XS": "orjson",
  "DBI": "sqlalchemy or psycopg2",
  "DBD::Pg": "psycopg2",
  "DBD::mysql": "pymysql",
  "File::Find": "pathlib + os.walk",
  "File::Basename": "pathlib",
  "File::Path": "pathlib",
  "File::Slurp": "pathlib.read_text()",
  "Getopt::Long": "argparse",
  "POSIX": "os, sys, signal",
  "DateTime": "datetime",
  "Time::HiRes": "time.perf_counter()",
  "Carp": "logging + traceback",
  "Data::Dumper": "pprint",
  "Text::CSV": "csv (stdlib)",
  "XML::Simple": "xml.etree.ElementTree",
  "XML::LibXML": "lxml",
  "YAML": "pyyaml",
  "Moose": "dataclasses or pydantic",
  "Try::Tiny": "try/except",
  "List::Util": "functools, itertools",
  "Digest::MD5": "hashlib",
  "MIME::Base64": "base64",
  "Net::SMTP": "smtplib",
  "Socket": "socket"
}
EOF

Usage Example

Migrate a Perl data processing script step by step:

Original Perl (original/process_logs.pl):

#!/usr/bin/perl
use strict;
use warnings;
use Getopt::Long;
use File::Find;
use JSON;
use POSIX qw(strftime);

my $dir = '/var/log';
my $pattern = '*.log';
my $output = 'report.json';
my $verbose = 0;

GetOptions(
    'dir=s'     => \$dir,
    'pattern=s' => \$pattern,
    'output=s'  => \$output,
    'verbose'   => \$verbose,
) or die "Invalid options\n";

my @files;
find(sub {
    push @files, $File::Find::name if /\Q$pattern\E$/;
}, $dir);

my %stats;
foreach my $file (@files) {
    open(my $fh, '<', $file) or do {
        warn "Cannot open $file: $!\n";
        next;
    };

    while (my $line = <$fh>) {
        chomp $line;
        if ($line =~ /^(\d{4}-\d{2}-\d{2})\s+(\w+)\s+(.+)$/) {
            my ($date, $level, $msg) = ($1, $2, $3);
            $stats{$level}++;
            $stats{by_date}{$date}{$level}++;

            if ($verbose && $level eq 'ERROR') {
                printf "[%s] %s: %s\n", $date, $file, $msg;
            }
        }
    }
    close($fh);
}

$stats{total_files} = scalar @files;
$stats{generated_at} = strftime("%Y-%m-%d %H:%M:%S", localtime);

my $json = JSON->new->pretty->encode(\%stats);
open(my $out, '>', $output) or die "Cannot write $output: $!\n";
print $out $json;
close($out);

print "Report written to $output\n";
print "Files processed: $stats{total_files}\n";

Claude Code produces this Python translation (converted/process_logs.py):

#!/usr/bin/env python3
"""Log file processor — migrated from process_logs.pl."""

import argparse
import json
import re
import sys
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Dict, List


def find_log_files(directory: Path, pattern: str) -> List[Path]:
    """Find all files matching the glob pattern recursively."""
    return sorted(directory.rglob(pattern))


def parse_log_line(line: str) -> tuple[str, str, str] | None:
    """Extract date, level, and message from a log line."""
    match = re.match(r'^(\d{4}-\d{2}-\d{2})\s+(\w+)\s+(.+)$', line)
    if match:
        return match.group(1), match.group(2), match.group(3)
    return None


def process_files(
    files: List[Path],
    verbose: bool = False
) -> Dict:
    """Process log files and collect statistics."""
    stats: Dict = defaultdict(int)
    by_date: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))

    for filepath in files:
        try:
            with open(filepath, encoding='utf-8', errors='replace') as fh:
                for line in fh:
                    parsed = parse_log_line(line.rstrip('\n'))
                    if parsed is None:
                        continue

                    date, level, msg = parsed
                    stats[level] += 1
                    by_date[date][level] += 1

                    if verbose and level == 'ERROR':
                        print(f"[{date}] {filepath}: {msg}")

        except OSError as e:
            print(f"Cannot open {filepath}: {e}", file=sys.stderr)
            continue

    return {
        'levels': dict(stats),
        'by_date': {k: dict(v) for k, v in by_date.items()},
        'total_files': len(files),
        'generated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
    }


def main() -> None:
    parser = argparse.ArgumentParser(description='Process log files')
    parser.add_argument('--dir', type=Path, default=Path('/var/log'))
    parser.add_argument('--pattern', default='*.log')
    parser.add_argument('--output', type=Path, default=Path('report.json'))
    parser.add_argument('--verbose', action='store_true')
    args = parser.parse_args()

    if not args.dir.is_dir():
        print(f"Error: {args.dir} is not a directory", file=sys.stderr)
        sys.exit(1)

    files = find_log_files(args.dir, args.pattern)
    stats = process_files(files, verbose=args.verbose)

    args.output.write_text(json.dumps(stats, indent=2))

    print(f"Report written to {args.output}")
    print(f"Files processed: {stats['total_files']}")


if __name__ == '__main__':
    main()

Then generate the test file:

# tests/test_process_logs.py
"""Tests for migrated process_logs module."""

import json
import tempfile
from pathlib import Path

import pytest

from converted.process_logs import find_log_files, parse_log_line, process_files


class TestParseLogLine:
    def test_valid_line(self):
        result = parse_log_line("2026-04-21 ERROR Something broke")
        assert result == ("2026-04-21", "ERROR", "Something broke")

    def test_valid_info_line(self):
        result = parse_log_line("2026-01-15 INFO Server started on port 8080")
        assert result == ("2026-01-15", "INFO", "Server started on port 8080")

    def test_invalid_line(self):
        assert parse_log_line("not a valid log line") is None

    def test_empty_line(self):
        assert parse_log_line("") is None


class TestFindLogFiles:
    def test_finds_log_files(self, tmp_path):
        (tmp_path / "app.log").write_text("test")
        (tmp_path / "error.log").write_text("test")
        (tmp_path / "readme.txt").write_text("test")

        results = find_log_files(tmp_path, "*.log")
        assert len(results) == 2

    def test_recursive_search(self, tmp_path):
        subdir = tmp_path / "subdir"
        subdir.mkdir()
        (subdir / "nested.log").write_text("test")

        results = find_log_files(tmp_path, "*.log")
        assert len(results) == 1


class TestProcessFiles:
    def test_counts_levels(self, tmp_path):
        log_content = "2026-04-21 ERROR fail\n2026-04-21 INFO ok\n2026-04-21 ERROR again\n"
        log_file = tmp_path / "test.log"
        log_file.write_text(log_content)

        stats = process_files([log_file])
        assert stats['levels']['ERROR'] == 2
        assert stats['levels']['INFO'] == 1
        assert stats['total_files'] == 1

Run validation:

# Run both and compare output
perl original/process_logs.pl --dir ./test-data --output /tmp/perl-out.json
python converted/process_logs.py --dir ./test-data --output /tmp/python-out.json

# Compare results
diff <(jq -S . /tmp/perl-out.json) <(jq -S . /tmp/python-out.json)

Common Issues

Why This Matters

Perl codebases are increasingly difficult to maintain and hire for. Automated migration with Claude Code converts a 6-month manual rewrite into a 2-week project with generated tests proving behavioral equivalence.