◈ Netsurfer / perl.gg / Exit Code Bitshift

Exit Code Bitshift

2026-04-17

This page explains how Perl packs process results into $? and how to extract the actual exit code using a right bit shift.

You run a command with system(). It fails. You check $? and get the number 256.

256 is not an exit code. The process exited with 1.

system("false"); say $?; # 256 say $? >> 8; # 1
Perl gives you the wait status, not the exit code. The actual exit code sits in the upper 8 bits, so you shift right by 8 to pull it out. This catches everyone exactly once, and the learning curve is usually a matter of minutes rather than hours.

Part 1: WHAT $? ACTUALLY CONTAINS

When a child process dies, Unix packs three things into a single 16-bit integer:
BITS WHAT ------ ---------------------------------- 15-8 Exit code (0-255) 7 Core dump flag (0 or 1) 6-0 Signal number (0-127)
Laid out as bits:
+--------+---+-------+ | exit | C | signal| | code | D | number| +--------+---+-------+ 15 8 7 6 0
So when a process exits normally with code 1, you get:
exit code = 1 (binary: 00000001) core dump = 0 signal = 0 Packed: 00000001_0_0000000 = 256 decimal
That is where 256 comes from. The exit code 1 is sitting in bits 15 through 8, and the lower 8 bits stay all zeros because the process exited cleanly without a signal or core dump.

Part 2: THE >> 8 PATTERN

The bit shift $? >> 8 slides everything 8 positions to the right, which drops the signal and core dump bits entirely:
system("grep", "-q", "needle", "haystack.txt"); my $exit_code = $? >> 8; if ($exit_code == 0) { say "Found it"; } elsif ($exit_code == 1) { say "Not found"; } else { say "Error occurred (exit code: $exit_code)"; }
This is the standard pattern. You will see $? >> 8 in virtually every Perl program that manages child processes, and it reads as idiomatic to anyone maintaining the code. Without the shift, you are comparing against packed wait status values, which turns exit code 1 into 256, exit code 2 into 512, and exit code 127 into 32512, none of which mean anything to a human reading your code.

Part 3: CHECKING FOR SIGNALS

If the lower 7 bits are non-zero, the process was killed by a signal rather than exiting on its own:
system("long_running_command"); my $signal = $? & 127; # mask off the lower 7 bits if ($signal) { say "Process killed by signal $signal"; } else { my $exit_code = $? >> 8; say "Process exited with code $exit_code"; }
The & 127 (which is & 0x7F) masks out just the signal bits, and the table below shows the common signals you might see:
SIGNAL NUMBER MEANING ------ ------ ------------------------- SIGHUP 1 Hangup (terminal closed) SIGINT 2 Interrupt (Ctrl+C) SIGKILL 9 Kill (cannot be caught) SIGSEGV 11 Segmentation fault SIGTERM 15 Termination request SIGPIPE 13 Broken pipe
When a process dies by signal, the exit code bits in the upper 8 positions are meaningless, so you check for signals first and then check the exit code.

Part 4: THE CORE DUMP BIT

Bit 7 tells you whether a core dump was produced:
system("./buggy_program"); my $signal = $? & 127; my $core_dump = $? & 128; # bit 7 if ($signal) { say "Killed by signal $signal"; say "Core dump generated" if $core_dump; }
The & 128 (which is & 0x80) checks just bit 7, and the system sets it when core dumps are enabled and the signal caused one. In practice, you rarely need to check this, but the bit tells you whether the core file exists without guessing when you are debugging a segfault in a C extension or an XS module.

Part 5: THE COMPLETE STATUS CHECK

Here is the full pattern, covering every case:
sub run_and_check { my (@cmd) = @_; system(@cmd); if ($? == -1) { # system() itself failed (couldn't exec) die "Failed to execute '@cmd': $!\n"; } elsif ($? & 127) { # killed by signal my $signal = $? & 127; my $core = $? & 128 ? ' (core dumped)' : ''; die "Command '@cmd' died with signal $signal$core\n"; } else { # normal exit my $exit_code = $? >> 8; if ($exit_code != 0) { warn "Command '@cmd' exited with code $exit_code\n"; } return $exit_code; } }
Three cases handle the full range of outcomes, and the special -1 check handles the situation where system() could not even start the command, such as a missing file or a permission denied error.

Part 6: BACKTICKS AND QX

Backticks and qx set $? the same way system() does:
my $output = `ls -la /nonexistent 2>&1`; my $exit_code = $? >> 8; if ($exit_code != 0) { say "ls failed with code $exit_code"; say "Output was: $output"; }
The difference is that backticks capture STDOUT while system() lets it pass through, but $? works identically in both cases, so you apply the same packed wait status and the same >> 8 pattern.

Same thing with qx:

my $date = qx~date +%Y-%m-%d~; chomp $date; die "date command failed\n" if $? >> 8;
And with open in pipe mode:
open my $pipe, '-|', 'find', '/tmp', '-name', '*.log' or die "Can't open pipe: $!\n"; while (<$pipe>) { chomp; say "Found: $_"; } close $pipe; # $? is set after closing the pipe if ($? >> 8) { warn "find exited with code " . ($? >> 8) . "\n"; }
Note that $? is set when you close the pipe handle, not when you open it.

Part 7: THE POSIX MODULE

If bit-shifting feels too low-level, the POSIX module provides named macros:
use POSIX qw(:sys_wait_h); system("some_command"); if (WIFEXITED($?)) { my $code = WEXITSTATUS($?); say "Exited normally with code $code"; } elsif (WIFSIGNALED($?)) { my $sig = WTERMSIG($?); say "Killed by signal $sig"; if (WCOREDUMP($?)) { say "Core dump produced"; } }
These are the same C macros that every Unix programmer knows, and they do exactly what >> 8, & 127, and & 128 do, but with names that explain the intent.
MACRO EQUIVALENT MEANING ----------------- ---------------- ---------------------- WIFEXITED($?) ($? & 127) == 0 Did it exit normally? WEXITSTATUS($?) $? >> 8 What was the exit code? WIFSIGNALED($?) ($? & 127) != 0 Killed by signal? WTERMSIG($?) $? & 127 Which signal? WCOREDUMP($?) $? & 128 Core dump?
Use whichever you prefer, since the bitshift version is more common in Perl code and the POSIX version is more common in code written by people who grew up on C.

Part 8: PRACTICAL PROCESS MANAGEMENT

A wrapper that runs commands, retries on failure, and gives clean diagnostics:
#!/usr/bin/env perl use strict; use warnings; use feature 'say'; sub run_with_retry { my (%opts) = @_; my @cmd = @{ $opts{cmd} }; my $retries = $opts{retries} // 3; my $delay = $opts{delay} // 5; for my $attempt (1 .. $retries) { say "Attempt $attempt: @cmd"; system(@cmd); if ($? == -1) { die "Cannot execute '@cmd': $!\n"; } if ($? & 127) { my $sig = $? & 127; die "Command killed by signal $sig, not retrying\n"; } my $exit_code = $? >> 8; return 0 if $exit_code == 0; # success warn "Exit code $exit_code, attempt $attempt of $retries\n"; sleep $delay if $attempt < $retries; } return $? >> 8; # return last exit code on failure } # usage my $result = run_with_retry( cmd => [qw(curl -sf http://example.com/api/health)], retries => 5, delay => 10, ); if ($result) { die "Health check failed after retries (exit code $result)\n"; } say "Service is up";
Notice how signal deaths are never retried, because if something got SIGKILLed, retrying is pointless, but a non-zero exit code might be transient, so those get retried.

Part 9: THE $? == -1 TRAP

When system() returns -1, it means Perl could not even fork and exec the command, which is different from the command running and failing:
system("nonexistent_command_xyz"); if ($? == -1) { # The command was never executed say "Error: $!"; # No such file or directory }
Some people skip this check and go straight to $? >> 8, which works most of the time, but when it fails, the error is mysterious because -1 >> 8 gives you a bunch of 1-bits due to arithmetic shift on signed integers, and you end up with an exit code that makes no sense.

Always check for -1 first, then check signals, and then check exit code, since that order handles every case cleanly.

system(@cmd) | +------+------+ | | | $?=-1 $?&127 $?>>8 | | | exec signal exit failed killed code

Part 10: THE CHILD_ERROR_NATIVE

For the truly curious, there is also ${^CHILD_ERROR_NATIVE}, which gives you the raw wait status as the operating system reported it before Perl does any munging.
system("some_command"); say "Perl \$?: $?"; say "Native: ${^CHILD_ERROR_NATIVE}";
On Unix, these are usually identical, but on Windows they can differ because Windows does not use the same wait status format, so ${^CHILD_ERROR_NATIVE} with the POSIX macros is the safest approach for cross-platform code.

For Unix-only code, $? >> 8 is all you need, and it has been the right answer since Perl 1, so it will be the right answer in Perl 50, since some patterns earn their permanence.

$? = wait status | +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ |15 |14 |13 |12 |11 |10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ | exit code | C | signal number | +---------------------------+---+-------------------------------+ ^ | core dump bit >> 8 = exit code & 127 = signal & 128 = core dump .--. |o_o | "Shift right by 8. |:_/ | That's the whole trick." // \ \ (| | ) /'\_ _/`\ \___)=(___/
The wait status is a 40-year-old Unix convention that Perl faithfully exposes through $?, and it is not a Perl quirk, it is a Unix quirk that Perl refuses to hide from you.

Other languages wrap this in objects and method calls, but Perl gives you the raw integer and trusts you to know what to do with it, and now you do.

perl.gg

✓ Document: Donehidden-gems / 15.5 KiB▧