Skip to content
Shell & TerminalDeep Dive Published Updated 8 min readViews unavailable

PowerShell Native Command Errors: Exit Codes, Streams, and Catchable Failures

Separate PowerShell ErrorRecords from native exit codes, capture status reliably, and choose a version-aware failure policy for scripts and CI.

A PowerShell script can print an error-looking message, continue running, and still exit successfully. The inverse can also happen: a native program writes a harmless warning to standard error but returns exit code zero. Reliable automation therefore needs to distinguish PowerShell’s error records, its six output streams, native standard output and standard error, and a process exit code.

These are separate interfaces. $ErrorActionPreference does not automatically mean “fail when any external program returns a nonzero status.” A try/catch block does not catch every Write-Error, and a native stderr line is not equivalent to a thrown .NET exception. Once those distinctions are explicit, a script can define what success means and report failures without losing diagnostic context.

Two failure models coexist

PowerShell cmdlets and advanced functions report errors through the PowerShell error stream. A non-terminating error is written while the command may continue processing. It does not enter catch by default. -ErrorAction Stop or a suitable $ErrorActionPreference can promote non-terminating errors into terminating ones, allowing try/catch to handle them.

External programs usually report completion using an integer exit code. PowerShell stores the most recent native program’s exit code in $LASTEXITCODE. A nonzero value sets $? to $false, but by default does not create an ErrorRecord in $Error, nor does it make a catch block run. $? is volatile: almost any subsequent operation can change it, so do not defer checking it after other commands.

& git.exe status --short
$gitExitCode = $LASTEXITCODE

if ($gitExitCode -ne 0) {
    throw "git status failed with exit code $gitExitCode"
}

Capture $LASTEXITCODE immediately after the process. Do not insert a second native command, pipeline, or helper call first and then assume the variable still describes the command you meant to check. If output is being redirected or piped, test how the exact PowerShell version handles it; stream redirection changes what is visible, not the target’s exit-code contract.

Choose the right mechanism for PowerShell code

For a function that can continue processing a pipeline and report an item-specific failure, use Write-Error or $PSCmdlet.WriteError() in an advanced function. For an advanced function that cannot complete its operation but should let the caller choose the response, create an ErrorRecord and call $PSCmdlet.ThrowTerminatingError(). Use throw when the script itself cannot safely proceed.

function Test-ConfigFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Path
    )

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        $record = [System.Management.Automation.ErrorRecord]::new(
            [System.IO.FileNotFoundException]::new("Not found: $Path"),
            'ConfigFileNotFound',
            [System.Management.Automation.ErrorCategory]::ObjectNotFound,
            $Path
        )
        $PSCmdlet.ThrowTerminatingError($record)
    }

    Get-Item -LiteralPath $Path
}

The caller can decide whether that terminating error should abort a larger operation:

try {
    Test-ConfigFile -Path './settings.json' -ErrorAction Stop
}
catch {
    Write-Error -ErrorRecord $_
    exit 1
}

The exact behavior of -ErrorAction and $ErrorActionPreference has important nuances, especially with statement-terminating errors from advanced functions. Do not use a global preference as a substitute for documenting each function’s error contract. Test the public function from a caller scope and verify both the emitted error record and whether following statements execute.

Opting into native-command error integration

PowerShell 7.3 introduced $PSNativeCommandUseErrorActionPreference as an experimental preference. It became stable in PowerShell 7.4. When enabled, a native command with a nonzero exit code emits a non-terminating error that respects $ErrorActionPreference; setting the preference to Stop can then make that failure catchable.

$PSNativeCommandUseErrorActionPreference = $true
$ErrorActionPreference = 'Stop'

try {
    native-tool.exe --check
    'The command returned success.'
}
catch {
    Write-Error "Native command failed: $($_.Exception.Message)"
    exit 1
}

This is useful when a script intentionally wants native nonzero exit statuses to participate in PowerShell error handling, but it is not universally correct. Some established programs assign meaning to nonzero statuses that are not failures. robocopy is a commonly documented example: its exit-code range carries information about copied files as well as failure conditions. Enabling automatic conversion globally can change the meaning of such a program’s contract.

For a tool with a documented status range, disable the preference narrowly and interpret the code explicitly. Preserve the original native status before running any other native executable, and write down which statuses count as success for that specific tool. Avoid a generic rule such as “all codes above zero fail” unless the program actually specifies that rule.

Do not confuse stderr with failure

Native programs often use stderr for progress, warnings, or usage text while returning zero. The reverse is also possible: a program can fail without producing useful stderr. Starting in PowerShell 7.2, redirected native stderr is not added to $Error, and $ErrorActionPreference does not control redirected native output in the same way it does a PowerShell error record. PowerShell 7.4 added the native exit-code preference described above; it still does not make every stderr byte an authoritative failure signal.

Test output and status independently. In diagnostics, capture streams deliberately and report the numeric code:

$stdout = & native-tool.exe --inspect 2>./native-stderr.txt
$exitCode = $LASTEXITCODE

if ($exitCode -ne 0) {
    $stderrText = Get-Content -LiteralPath './native-stderr.txt' -Raw
    throw "native-tool exited $exitCode. stderr: $stderrText"
}

This example is best for commands whose output can safely be buffered in memory. For large or streaming output, redirect to files or consume output incrementally instead of accumulating an unbounded string. The exact redirection semantics differ depending on whether the native command’s stderr is redirected, merged, or piped, so test the behavior on the PowerShell versions you support.

Make script exit behavior explicit

In a command-line script, map internal outcomes to a documented process exit status at the boundary. A function should generally return objects or emit PowerShell errors rather than calling exit, because exit terminates the host process and can surprise a caller that dot-sourced or embedded the function. The top-level script can translate a caught failure into an exit code:

try {
    Invoke-DeploymentValidation
    exit 0
}
catch {
    Write-Error -ErrorRecord $_
    exit 1
}

For pipeline-oriented functions, decide whether one failed item should stop the whole pipeline or allow later inputs to continue. That is an interface decision, not just an implementation detail. Give errors a stable identifier, preserve the underlying exception, and include the offending input as the target object without printing credentials or full environment variables.

CI adds another boundary: the runner may interpret the final process code, a PowerShell task wrapper may capture streams, and the shell may be Windows PowerShell 5.1 or PowerShell 7. Verify the executable path and $PSVersionTable.PSVersion in the job log, then test both the success and failure path. A script is not validated merely because its happy path prints the expected line.

Build a failure contract around the process boundary

For each external tool, document four independent facts: which exit codes mean success, which output stream carries machine-readable data, whether stderr may contain ordinary warnings, and whether the command can leave partial side effects before returning failure. That small contract makes it possible to write an adapter function with predictable PowerShell behavior instead of scattering magic status checks throughout the script.

function Invoke-ValidatedNativeCommand {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $FilePath,

        [Parameter()]
        [string[]] $ArgumentList
    )

    & $FilePath @ArgumentList
    $nativeStatus = $LASTEXITCODE

    if ($nativeStatus -ne 0) {
        $record = [System.Management.Automation.ErrorRecord]::new(
            [System.InvalidOperationException]::new("$FilePath exited with status $nativeStatus"),
            'NativeCommandFailed',
            [System.Management.Automation.ErrorCategory]::OperationStopped,
            $FilePath
        )
        $PSCmdlet.ThrowTerminatingError($record)
    }
}

This adapter assumes that zero is the only success status and that output should continue to stream to the caller. It deliberately does not parse stderr or redirect output. Before reusing the pattern, check that the particular utility follows the same exit-code convention. If several statuses are valid, accept an explicit set or provide a tool-specific classifier rather than weakening the test to “anything under 10 probably worked.”

Also consider what happens when PowerShell itself cannot start the executable: command resolution and process-creation errors are PowerShell errors, while a program that starts and exits with a nonzero code is a native process result. They should be reported distinctly because they point to different remediations. In CI, include the executable path, engine version, and exit code in sanitized diagnostics, but avoid dumping environment variables or unredacted arguments that may contain credentials.

Scope preference changes tightly

$ErrorActionPreference is scoped PowerShell state. Setting it at the top of a profile, module, or long-lived interactive session can change how unrelated commands behave later. Prefer a per-command -ErrorAction Stop for a PowerShell cmdlet whose non-terminating errors must enter a local catch, or set the preference inside a narrow function or script block with a clearly defined boundary. Remember that -ErrorAction does not change every terminating-error behavior, and some cmdlets emit errors from child scopes in ways that should be tested.

When a function wraps native code, decide whether callers should receive raw stdout, a structured result object, a PowerShell error record, or an exception. Document whether a nonzero exit is returned as data or raised as an error. Do not both throw and emit a success-shaped result for the same failed invocation; callers need one reliable contract. A wrapper can include the exit code and sanitized stderr in an error record while still preserving stdout for commands where partial output is meaningful.

Test the wrapper under the exact host that will execute it, including whether the command is called from a pipeline and whether -ErrorAction Stop is supplied. Validate the process exit code observed by the outermost runner as well as the internal error record. Some CI systems log an error stream but still treat the task as successful unless the process exits nonzero; make that mapping explicit at the script boundary.

Related:

Sources:

Comments