Tcl to Perl#
You know Tcl. You write set x 5, you substitute with $x, you reach for [expr {...}] to do arithmetic, you build lists with lappend and walk them with foreach, and you bend strings with regexp and regsub. This guide maps each of those habits onto Perl, so you can keep every reflex you have and pick up the things Tcl makes hard.
Tcl and Perl share the same roots: Unix scripting, a string-first worldview, and $-substitution as the way you reach a variable. Most of what you know maps almost directly - and Perl gives you real data structures (@arrays and %hashes as first-class types, not lists pressed into service) and a vast module ecosystem to land in. Two specific pieces of friction get the weight in this guide: Perl’s distinct array/hash types where Tcl has lists and assoc arrays, and Perl’s regex (a superset of Tcl’s). The line-at-a-time and one-liner material is lighter here, because Tcl is not a one-liner language; you will spend your time in variables, data, control flow, and regex.
Every runnable example here was executed on pperl, a Rust reimplementation of Perl 5.42; the # ... comments show its real output. Start scripts with:
use v5.42;
That one line turns on strict, warnings, and the say built-in (a print with a trailing newline, the rough twin of Tcl’s puts), the modern feature set used throughout.
Note
One-liner switches on this pperl build Perl’s one-liner switches - perl -ne, perl -pe, -a, -i, -l - are correct perl5 5.42 and are the idiomatic form for ad-hoc text work. But the implicit-loop switch family (-n, -p, -a, -F, -i, -l) is not yet recognised by this pperl build - only -e is. So the one-liner examples below are shown without output, and each is paired with an explicit-loop full-script form (while (<$fh>) { ... }) that does run on pperl today and carries the verified output. Use the full-script form until the switches land.
How to read this#
Variables and substitution -
setbecomesmy,[expr]disappears,$xsubstitution stays.Lists and arrays -
lindex/lappend/lsortbecome Perl array operations.Assoc arrays and hashes -
set a(k) vbecomes$a{k};dictbecomes a hash.Procs and references -
procbecomessub;upvar/uplevelbecome references.Regex and substitution -
regexp/regsub/string mapbecomem///s////tr///.Control flow -
if/while/foreach/switchbecome Perl’s, plus{}vs""quoting.The line/record loop - reading a file line by line with
$_and$..Fields and splitting -
split/join, the Tclsplitanalogue.I/O, pipes, and in-place edit -
open/gets/puts/execbecomeopen/<$fh>/print/qx.One-liners and switches -
-n,-p,-e, kept brief.Gotchas coming from Tcl - scan before you write.
Variables and substitution#
In Tcl, set x 5 creates a variable and $x substitutes its value. In Perl, my $x = 5; declares a lexically scoped variable and $x substitutes it - the substitution syntax you already know carries over verbatim. The relief is that Perl has native operators: there is no [expr {...}]. Arithmetic is just arithmetic.
set x 5
set name "world"
set sum [expr {$x + 3}]
puts "x is $x"
puts "sum is $sum"
use v5.42;
my $x = 5; # set x 5
my $name = "world";
my $sum = $x + 3; # native operators, no [expr] needed
say "x is $x"; # x is 5
say "sum is $sum"; # sum is 8
Quoting maps cleanly, and this is a happy mapping: Tcl’s "..." (substitution happens) is Perl’s double-quoted string, and Tcl’s {...} (literal, no substitution) is Perl’s single-quoted string.
use v5.42;
my $name = "world";
say "hello, $name"; # hello, world (interpolates)
say 'no $interp here'; # no $interp here (literal)
The gotcha: Tcl is “everything is a string” and Perl is close to it - a scalar auto-coerces between number and string by context - but Perl splits comparison into two operator families, and you pick by the operation, not the value. Numbers use ==, !=, <, >; strings use eq, ne, lt, gt.
use v5.42;
my $x = "5";
say $x + 3; # 8 (numeric context coerces "5")
say $x . "3"; # 53 (string context)
say "10" == "10.0" ? "num-equal" : "num-differ"; # num-equal
say "10" eq "10.0" ? "str-equal" : "str-differ"; # str-differ
== compares "10" and "10.0" as numbers (equal); eq compares them as strings (different). Using == where you meant eq, or the reverse, is the one quiet bug the string-first worldview sets you up for.
Lists and arrays#
A Tcl list is a Perl array, and the verbs line up one for one. The big shift: in Tcl everything is a list, including strings you split on the fly; in Perl an array (@a) is a distinct, first-class type with its own sigil. When you reach for one element, the sigil follows the element, so it is $a[0] (a scalar), not @a[0].
set items [list apple pear fig]
puts [lindex $items 0]
puts [llength $items]
lappend items plum
set sorted [lsort $items]
foreach it $items { puts "item: $it" }
use v5.42;
my @items = ("apple", "pear", "fig");
say $items[0]; # apple (lindex $items 0)
say scalar @items; # 3 (llength $items)
push @items, "plum"; # lappend items plum
my @sorted = sort @items; # lsort
say "@sorted"; # apple fig pear plum
for my $it (@items) { # foreach it $items
say "item: $it";
}
lsearch - “find the index of a matching element” - has no single built-in; the idiomatic Perl is grep over the index range, with $#items as the last index:
use v5.42;
my @items = ("apple", "pear", "fig", "plum");
my ($idx) = grep { $items[$_] eq "fig" } 0 .. $#items; # lsearch
say "fig at $idx"; # fig at 2
The gotcha: scalar @items is how you get the length (Tcl’s llength); writing my $n = @items works because assigning an array to a scalar yields its count, but spell it scalar @items when you want the length inside a larger expression. And sort compares as strings by default, so sort (10, 9, 100) gives 10 100 9; pass a numeric comparator sort { $a <=> $b } @nums for numeric order, where $a and $b are the two elements and <=> is the numeric three-way operator.
Assoc arrays and hashes#
A Tcl associative array - set a(red) 1 - is a Perl hash. The element access maps directly: Tcl’s $a(red) is Perl’s $a{red}, and the whole collection is %a (Tcl has no syntax for the array as a single value; Perl does). Modern Tcl’s dict maps to the same Perl hash, or to a hash reference when you need to nest or pass it around.
set a(red) 1
set a(green) 2
set a(blue) 3
puts $a(red)
puts [array exists a]
foreach k [lsort [array names a]] { puts "$k => $a($k)" }
unset a(blue)
use v5.42;
my %a = (red => 1, green => 2, blue => 3);
say $a{red}; # 1 ($a(red))
say exists $a{green} ? "yes" : "no"; # yes (info exists a(green))
for my $k (sort keys %a) { # array names a
say "$k => $a{$k}";
}
delete $a{blue}; # unset a(blue)
That prints:
1
yes
red => 1
green => 2
blue => 3
The gotcha: membership is exists $a{key}, not a separate command, and the fat comma => is the idiomatic key/value separator in a hash literal. => also auto-quotes the bareword on its left, so red => 1 needs no quotes around red. A hash has no inherent order (neither does a Tcl array); iterate sort keys %a when you want a stable one, exactly as you reach for lsort [array names a].
Procs and references#
proc name {args} {body} becomes Perl’s sub name { ... }. The classic Tcl way to read arguments - positional, by name - has a direct Perl twin: my ($a, $b) = @_; unpacks the argument array @_. Modern Perl also has signatures, which read like Tcl’s {args} list and add defaults.
proc add {a b} {
return [expr {$a + $b}]
}
puts [add 3 4]
proc greet {who {greeting Hello}} {
return "$greeting, $who!"
}
puts [greet Tcl]
puts [greet Perl Hi]
use v5.42;
sub add {
my ($a, $b) = @_; # unpack the argument array
return $a + $b;
}
say add(3, 4); # 7
sub greet ($who, $greeting = "Hello") { # signature with default
return "$greeting, $who!";
}
say greet("Tcl"); # Hello, Tcl!
say greet("Perl", "Hi"); # Hi, Perl!
Tcl’s pass-by-name - upvar, where a proc reaches up and mutates a caller’s variable - becomes a Perl reference: take one with \, follow it with $$ref. You pass the reference explicitly instead of naming the variable, which makes the data flow visible at the call site.
use v5.42;
sub increment {
my ($ref) = @_;
$$ref++; # mutate through the reference (Tcl: upvar)
}
my $count = 10;
increment(\$count); # pass a reference to $count
say $count; # 11
The gotcha: @_ is the argument array, and my ($a, $b) = @_; copies the arguments into named locals - the line you almost always want at the top of a sub. Reaching for upvar-style aliasing is rarely needed in Perl; when you do need to mutate a caller’s data, pass a reference (\$x, \@arr, \%h) deliberately rather than aliasing by name. References are also how Perl nests data (an array of hashes, a hash of arrays); the same mechanism that replaces upvar is the one that replaces Tcl’s string-encoded nested lists.
Regex and substitution#
This is a payload section. Tcl’s regexp and regsub use Advanced Regular Expressions; Perl’s regex is a superset, so the patterns you know carry over and gain features. Perl makes regex syntax, not a command: =~ binds a pattern to a string, m// matches, s/// substitutes, and tr/// transliterates.
set s "order 42 shipped"
if {[regexp {(\d+)} $s -> num]} {
puts "number: $num"
}
set t [regsub -all {o} "foo boo zoo" O]
puts $t
set u [string map {a A b B} "abcabc"]
puts $u
use v5.42;
my $s = "order 42 shipped";
if ($s =~ /(\d+)/) { # regexp; capture lands in $1
say "number: $1"; # number: 42
}
(my $t = "foo boo zoo") =~ s/o/O/g; # regsub -all -> s///g
say $t; # fOO bOO zOO
(my $u = "abcabc") =~ tr/ab/AB/; # string map (char-for-char)
say $u; # ABcABc
The capture maps too: Tcl’s -> num match variable becomes Perl’s $1, $2, and so on, with named captures landing in %+:
use v5.42;
if ("2026-06-21" =~ /(?<y>\d{4})-(?<m>\d{2})/) {
say "$+{y} / $+{m}"; # 2026 / 06
}
Tcl’s string match is glob matching, not regex (string match *.txt $name). In Perl, anchor a regex instead - /\.txt$/ is the equivalent - because Perl’s one pattern language covers both jobs.
use v5.42;
my $name = "report.txt";
say "matches" if $name =~ /\.txt$/; # string match *.txt $name
# matches
The gotcha: s/// modifies its target in place and returns the count of substitutions, not the new string - which is why the examples above copy first with (my $t = "...") before substituting. To get a new string and leave the original untouched, use the /r flag: my $new = $s =~ s/o/O/gr. And tr/// (the string map analogue for single characters) is not a regex: its left side is a literal character set, so tr/ab/AB/ maps a and b only. For pattern work, reach for s///.
Control flow#
The control structures map almost one to one; the spelling shifts and the braces stay. Tcl’s if {cond} {body} becomes Perl’s if (cond) { body }, elseif becomes elsif, and foreach becomes for (the two spellings are aliases in Perl; foreach works too).
if {$n < 0} {
set sign neg
} elseif {$n == 0} {
set sign zero
} else {
set sign pos
}
set i 0
while {$i < 3} { puts "i=$i"; incr i }
foreach w {a b c} { puts "w=$w" }
use v5.42;
my $n = 0;
my $sign;
if ($n < 0) { $sign = "neg" }
elsif ($n == 0) { $sign = "zero" } # elseif -> elsif
else { $sign = "pos" }
say $sign; # zero
my $i = 0;
while ($i < 3) { say "i=$i"; $i++ } # incr i -> $i++
for my $w (qw(a b c)) { say "w=$w" } # foreach w {a b c}
Tcl’s switch has no single keyword in Perl; the idiomatic replacement is a dispatch hash mapping each case to a code reference, which is faster and more flexible than a chain of if:
use v5.42;
my $cmd = "start";
my %dispatch = (
start => sub { "starting" },
stop => sub { "stopping" },
);
say $dispatch{$cmd}->(); # starting
The gotcha: the {} vs "" distinction you rely on in Tcl for deferring versus substituting maps to Perl’s '' vs "" for strings (covered above), but Perl’s block braces are not quoting - a Perl { ... } after if/while/for is a code block, always evaluated, never a deferred string. There is no “the body is a string until I run it” step; Perl parses the block at compile time. Postfix modifiers are a bonus Tcl has no form for: say $_ for @items; and say "neg" if $n < 0; carry the loop or condition after the statement.
The line/record loop#
Tcl reads a file with set fh [open f r]; while {[gets $fh line] >= 0} {...}. Perl’s equivalent is a while (<$fh>) loop, with each line landing in $_ (the implicit topic variable) or in a named scalar. The line number is in $..
set fh [open /tmp/tcl_data.txt r]
while {[gets $fh line] >= 0} {
puts "got: $line"
}
close $fh
use v5.42;
open my $fh, '<', '/tmp/tcl_data.txt' or die "open: $!";
while (my $line = <$fh>) { # gets $fh line
chomp $line; # <$fh> keeps the newline; strip it
say "got: $line"; # puts
}
close $fh;
# got: apple
# got: banana
# got: cherry
(The input file held apple, banana, cherry.) Using $_ and the line counter $. reads even closer to a record loop:
use v5.42;
open my $fh, '<', '/tmp/tcl_data.txt' or die "open: $!";
while (<$fh>) { # each line lands in $_
chomp; # chomp defaults to $_
say "$. : $_"; # $. is the current line number
}
close $fh;
# 1 : apple
# 2 : banana
# 3 : cherry
The gotcha: Tcl’s gets strips the trailing newline for you; Perl’s <$fh> keeps it, so call chomp when you do not want it. With no argument, chomp and say/print both default to $_, which is why the second loop never names the line variable.
Fields and splitting#
Tcl’s split $line $sep returns a list; Perl’s split /sep/, $line returns an array, and join puts it back together. The difference: a Perl split separator is a regex, so you get the full pattern language for free.
set line "a:b:c"
set parts [split $line ":"]
puts [llength $parts]
puts [lindex $parts 1]
puts [join $parts ","]
use v5.42;
my $line = "a:b:c";
my @parts = split /:/, $line; # split $line ":" (separator is a regex)
say scalar @parts; # 3 (llength $parts)
say $parts[1]; # b (lindex $parts 1)
say join ",", @parts; # a,b,c (join $parts ",")
The gotcha: split ' ' with a literal single-space string is a special case that splits on any run of whitespace and discards leading empty fields - the awk-style “split into words” behaviour, and usually what you want for free-form input. split / / (a regex with one space) is the literal “split on each single space” and does not collapse runs. Reach for split ' ' to tokenise words:
use v5.42;
my @words = split ' ', " the quick brown ";
say "@words"; # the quick brown
I/O, pipes, and in-place edit#
Tcl’s open/gets/puts/read/close map to Perl’s open/<$fh>/ print/slurp/close. The idiom is the three-argument open with a lexical filehandle. Tcl’s exec - run a command and capture its output - becomes Perl’s qx{...} (backticks) or system when you do not need the output.
set out [exec echo hello from shell]
puts "exec said: $out"
use v5.42;
my $out = qx{echo hello from shell}; # exec -> qx// (backticks)
chomp $out;
say "exec said: $out"; # exec said: hello from shell
Tcl has no in-place file edit built in; you read, transform, and write back. Perl’s -i switch is the one-liner form of exactly that, and the runnable full-script equivalent is a read-modify-write you can write today:
use v5.42;
my $path = '/tmp/tcl_inplace.txt'; # holds: red green blue
open my $in, '<', $path or die "read: $!";
my @lines = <$in>; # slurp all lines (list context)
close $in;
s/e/E/g for @lines; # transform each line's $_
open my $out, '>', $path or die "write: $!";
print {$out} @lines; # write them back
close $out;
# file now holds:
# rEd
# grEEn
# bluE
The gotcha: open returns false on failure rather than raising (Tcl throws an error you catch with catch), so the or die "...: $!" idiom is mandatory - $! is the system error string. For pipes, Perl opens a pipe filehandle directly: open my $fh, '-|', 'ls', '-l' reads a command’s output and open my $fh, '|-', 'sort' writes to a command’s input, the structured form of Tcl’s open "|command".
One-liners and switches#
Tcl is not a one-liner language, so this section is brief - but Perl is, and once you leave Tcl behind it is worth knowing the reflex exists. The switch family: -e supplies the program, -n wraps it in a no-auto-print loop, -p wraps it in an auto-print loop, -i edits in place, and -a/-F autosplit each line into @F. BEGIN { } and END { } blocks run before and after the loop.
perl -ne 'print if /error/' log.txt # print matching lines
perl -pe 's/\d+/N/g' input.txt # mask numbers
perl -nE 'END { say $n } $n++ if /error/' log.txt # count, END block
These are shown without output because the implicit-loop switches are not recognised by this pperl build yet (see the note at the top). Each maps to a verified explicit-loop form: perl -ne 'CODE' is while (<$fh>) { CODE }, and perl -pe 'CODE' is the same with a trailing print. The runnable “count matching lines” form:
use v5.42;
open my $fh, '<', '/tmp/tcl_data.txt' or die "open: $!";
my $n = 0;
while (<$fh>) {
$n++ if /banana/; # the body of -ne
}
say $n; # 1
The gotcha: until the switches land on pperl, write the explicit loop (it runs today and is what the switches expand to anyway), and consult the One-Liners guide for the full switch catalogue and recipes. Perl also has full subroutines, references, modules, and OO if your script outgrows a glue task: Tcl’s namespace eval and [incr Tcl] classes map to Perl packages and the modern class syntax; see the Object-oriented Programming guide for that side of the language.
Gotchas coming from Tcl#
The traps, collected for a final scan before you write:
Two comparison families.
==/</<=>compare numerically;eq/lt/cmpcompare as strings."10" == "10.0"is true,"10" eq "10.0"is false. Pick by the operation, not the value - the one quiet bug the string-first worldview invites.The sigil follows the element. One element of
@itemsis$items[0](a scalar), not@items[0]; one value of%ais$a{red}. The container sigil and the element sigil differ.sortis string-sorted by default, like comparing witheq. Pass{ $a <=> $b }for numeric order.s///returns a count, not the new string, and edits in place. Copy first ((my $t = $s) =~ s/.../.../) or use the/rflag for a fresh string.tr///(thestring mapanalogue) is a literal character set, not a regex.tr/ab/AB/maps single characters. For patterns, uses///.split ' 'andsplit / /differ. The literal-space string splits on whitespace runs and drops leading empties (the word tokeniser); the one-space regex does not. Usesplit ' 'for free-form input.<$fh>keeps the trailing newline, unlike Tcl’sgets. Callchompto strip it.opendoes not raise on failure. Useopen my $fh, '<', $path or die "...: $!".$!is the system error string; there is nocatchto wrap it.References replace
upvar. To mutate a caller’s variable, pass a reference (\$x,\@arr,\%h) deliberately rather than aliasing by name. References are also how Perl nests data.A
{ ... }block is code, not a deferred string. Perl parses blocks at compile time; there is no “the body is a string until I run it” step. Only''/""carry Tcl’s{}/""substitution distinction.The implicit-loop switches (
-n/-p/-a/-F/-i/-l) are not yet recognised by thispperlbuild. Only-eworks today. Write the explicitwhile (<$fh>) { ... }loop, which is exactly what the switches expand to, until they land.