Chapter 10Lesson 04~150 minutes

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" }
}
Wrapper rule: Document the exit-code policy for the specific native tool you wrap. Preserve the original code in logs or error details.

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
        }
    }
}
Tool-specific semantics still matter: Do not enable this preference blindly around tools whose documented nonzero codes can mean success-with-information. Scope the change and apply the tool’s actual exit-code contract.

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 -Force

Capture 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 $PSNativeCommandUseErrorActionPreference inside 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

MistakeWhy it failsBetter pattern
Treat any output as successNative tools can output and still exit nonzeroCheck documented exit status
Assume native nonzero exits enter catch by defaultThey normally do not create PowerShell ErrorRecordsCheck $LASTEXITCODE or deliberately enable native error integration
Use one nonzero policy for every toolTools define different exit semanticsWrap each tool with its documented policy
Run another command before saving status$? can change and evidence becomes ambiguousCapture 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?

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?

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

Next lesson

Diagnose failures systematically with breakpoints, tracing, transcripts, and structured operational logging

Continue to Lesson 5, where the chapter builds on this failure model with diagnose failures systematically with breakpoints, tracing, transcripts, and structured operational logging.

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.