Native Exit Codes, $?, $LASTEXITCODE, and Cross-Tool Failure Handling
Handle native executables as a distinct failure system using $?, $LASTEXITCODE, explicit tool policies, native-error preferences, wrappers, and CI process status.
Learning objectives
- Explain why native stdout/stderr and exit codes differ from PowerShell ErrorRecords.
- Use $? and $LASTEXITCODE correctly and capture them before later commands change state.
- Explain the default behavior of nonzero native exit codes.
- Use $PSNativeCommandUseErrorActionPreference only with version/tool semantics understood.
- Wrap native tools with explicit success, known-failure, and unexpected-failure policies.
- Translate wrapper failure into a meaningful top-level CI/process exit status.
1. Native programs report failure through process exit codes
PowerShell cmdlets participate in PowerShell’s ErrorRecord and preference system. A native executable normally does not. It writes bytes/text to stdout or stderr and exits with an integer status code. PowerShell stores that status in $LASTEXITCODE and sets $? from it.
$pwsh = (Get-Command pwsh -CommandType Application).Source
& $pwsh -NoLogo -NoProfile -Command "'child output'; exit 0"
"success=$? exit=$LASTEXITCODE"
$pwsh = (Get-Command pwsh -CommandType Application).Source
& $pwsh -NoLogo -NoProfile -Command "'still produced output'; exit 7"
"success=$? exit=$LASTEXITCODE"The second command can produce visible output and still fail. “I received output” is therefore not a success test.
2. $? is the last operation’s success signal, not a durable exit-code variable
For native commands, $? becomes $true when $LASTEXITCODE is 0 and $false otherwise. But $? changes whenever another relevant command runs, so read it immediately if you need it.
$pwsh = (Get-Command pwsh -CommandType Application).Source
& $pwsh -NoLogo -NoProfile -Command "exit 9"
$nativeSucceeded = $?
$nativeExitCode = $LASTEXITCODE
# Other PowerShell work can change $? later.
Get-Date | Out-Null
[pscustomobject]@{
NativeSucceeded = $nativeSucceeded
NativeExitCode = $nativeExitCode
CurrentQuestion = $?
}Persist the information you need before running unrelated commands.
3. $LASTEXITCODE is the exact native status you should evaluate against tool policy
Exit code 0 conventionally means success, but native tools are free to define their own contract. Some tools use nonzero codes for nonfatal states. Do not write a universal wrapper that assumes every nonzero value means the same thing.
$pwsh = (Get-Command pwsh -CommandType Application).Source
& $pwsh -NoLogo -NoProfile -Command "exit 2"
$code = $LASTEXITCODE
switch ($code) {
0 { 'success' }
2 { 'known domain outcome' }
default { throw "unexpected native exit code: $code" }
}4. By default, nonzero native exit codes do not enter catch
$Error.Clear()
try {
$pwsh = (Get-Command pwsh -CommandType Application).Source
& $pwsh -NoLogo -NoProfile -Command "[Console]::Error.WriteLine('native stderr'); exit 5"
"after native command; exit=$LASTEXITCODE"
}
catch {
'not reached under the default native exit-code policy'
}
"PowerShell error records: $($Error.Count)"Under the default model, the nonzero exit code sets $? false but does not itself create a PowerShell ErrorRecord or trigger catch. Native stderr is an output channel, not an exception mechanism.
5. $PSNativeCommandUseErrorActionPreference can integrate native failures with ErrorAction
PowerShell 7.3 introduced $PSNativeCommandUseErrorActionPreference experimentally; it became a stable feature in PowerShell 7.4. When it is $true, nonzero native exits issue a PowerShell error that follows $ErrorActionPreference.
& {
$PSNativeCommandUseErrorActionPreference = $true
$ErrorActionPreference = 'Stop'
try {
$pwsh = (Get-Command pwsh -CommandType Application).Source
& $pwsh -NoLogo -NoProfile -Command "exit 6"
}
catch {
[pscustomobject]@{
Caught = $true
Type = $_.Exception.GetType().FullName
Exit = $LASTEXITCODE
}
}
}6. A wrapper should separate invocation, capture, policy, and failure reporting
function Invoke-ChildTool {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('Success','KnownFailure','UnexpectedFailure')]
[string]$Scenario
)
$exit = switch ($Scenario) {
'Success' { 0 }
'KnownFailure' { 4 }
'UnexpectedFailure' { 9 }
}
$output = $pwsh = (Get-Command pwsh -CommandType Application).Source
& $pwsh -NoLogo -NoProfile -Command "'payload'; exit $exit"
$code = $LASTEXITCODE
if ($code -eq 0) {
return [pscustomobject]@{ Status='Succeeded'; ExitCode=$code; Output=$output }
}
if ($code -eq 4) {
return [pscustomobject]@{ Status='KnownFailure'; ExitCode=$code; Output=$output }
}
throw "Child tool failed unexpectedly with exit code $code"
}The wrapper owns the policy. Callers see a stable PowerShell contract instead of duplicating exit-code logic everywhere.
7. Capture stderr because it is evidence, but do not use its existence as the only failure test
Some native programs write diagnostics to stderr even for successful operations, and others write failure text to stdout. The authoritative signal is the program’s documented exit status policy.
$stderr = Join-Path ([System.IO.Path]::GetTempPath()) 'child-stderr.log'
$output = $pwsh = (Get-Command pwsh -CommandType Application).Source
& $pwsh -NoLogo -NoProfile -Command "[Console]::Error.WriteLine('diagnostic'); 'payload'; exit 0" 2> $stderr
$code = $LASTEXITCODE
[pscustomobject]@{ ExitCode=$code; Output=$output; Stderr=(Get-Content -LiteralPath $stderr -Raw) }
Remove-Item -LiteralPath $stderr -ForceCapture both channels when they matter, then evaluate exit status separately.
8. CI runners care about the top-level process exit status
A CI step generally decides success from the process exit code of the shell it launched. A PowerShell script can notice a failing native command, print a warning, and still exit 0 unless the script deliberately propagates failure.
# Top-level script pattern
& some-native-tool --arguments
$code = $LASTEXITCODE
if ($code -ne 0) {
Write-Error "some-native-tool failed with exit code $code"
exit $code
}
# Continue only when policy says the native operation succeeded.Inside reusable functions, prefer throwing or returning a documented result according to the API contract. At the outermost script/CI entry point, translate that result into a process exit code appropriate for the runner.
9. PowerShell scripts and pwsh invocation have their own exit-code rules
When a script is launched through pwsh -File, the hosting process reports a status based on explicit exit, unhandled exceptions, or successful completion. This is another reason to design a clear top-level entry point instead of assuming $LASTEXITCODE behaves like a function return value.
$pwsh = (Get-Command pwsh -CommandType Application).Source
& $pwsh -NoLogo -NoProfile -Command "exit 23"
"child process exit: $LASTEXITCODE"A function should not call exit to report an ordinary function-level error: exit terminates the hosting script/process context.
10. Lab: build a native-tool wrapper with three explicit paths
function Invoke-NativeLab {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('ok','known','unexpected')]
[string]$Mode
)
$wanted = @{ ok=0; known=3; unexpected=11 }[$Mode]
$stdout = $pwsh = (Get-Command pwsh -CommandType Application).Source
& $pwsh -NoLogo -NoProfile -Command "'mode=$Mode'; exit $wanted"
$code = $LASTEXITCODE
switch ($code) {
0 { [pscustomobject]@{ Status='Succeeded'; ExitCode=$code; Stdout=$stdout }; break }
3 { [pscustomobject]@{ Status='KnownFailure'; ExitCode=$code; Stdout=$stdout }; break }
default { throw "Unexpected native failure: exit=$code stdout=$stdout" }
}
}
Invoke-NativeLab -Mode ok
Invoke-NativeLab -Mode known
try { Invoke-NativeLab -Mode unexpected } catch { $_.Exception.Message }- Confirm that output exists in all three scenarios, proving output is not a success test.
- Capture
$?immediately after the child process and compare it with the saved exit code. - Enable
$PSNativeCommandUseErrorActionPreferenceinside a temporary script block and compare behavior. - Explain how a top-level CI script should translate an unexpected wrapper failure into a nonzero process exit status.
11. Common mistakes and the underlying model
| Mistake | Why it fails | Better pattern |
|---|---|---|
| Treat any output as success | Native tools can output and still exit nonzero | Check documented exit status |
| Assume native nonzero exits enter catch by default | They normally do not create PowerShell ErrorRecords | Check $LASTEXITCODE or deliberately enable native error integration |
| Use one nonzero policy for every tool | Tools define different exit semantics | Wrap each tool with its documented policy |
| Run another command before saving status | $? can change and evidence becomes ambiguous | Capture status immediately |
12. Knowledge check
Question 1. For a native executable, what sets $? false?
Question 2. What variable contains the exact exit code of the last native process?
$LASTEXITCODE.Question 3. Does a nonzero native exit code create a PowerShell ErrorRecord by default?
Question 4. What preference can make nonzero native exits participate in ErrorAction handling?
$PSNativeCommandUseErrorActionPreference.Question 5. Why must wrappers understand a tool’s documented exit-code policy?
13. Summary
Native programs live at a process boundary. Save $LASTEXITCODE immediately, remember that $? is a transient success signal, and never infer success merely because output exists. By default, nonzero native exits do not create PowerShell ErrorRecords or enter catch. PowerShell 7.4+ can integrate them through $PSNativeCommandUseErrorActionPreference, but tool-specific exit semantics still govern policy. Wrap native tools so PowerShell callers receive a stable contract, then propagate failure appropriately at the top-level CI/process boundary.
14. Further reading
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this address.