Predictable Native Command Errors in PowerShell: Stop on Non‑Zero Exit Codes
When you automate with PowerShell, you inevitably call native tools: git, docker, kubectl, openssl, npm, clang, and countless others. By default, PowerShell does not throw terminating errors when a native command exits with a non-zero code. That means your script can keep running after a failure, producing confusing logs or corrupting state. The fix is simple and robust: make non-zero exit codes behave like errors so your scripts stop where they should, every time, on every OS.
Why native commands can surprise you
Cmdlets participate in PowerShell's error pipeline and honor $ErrorActionPreference. Native commands (EXEs on Windows, ELF/Mach-O binaries on Linux/macOS) do not. Historically, PowerShell would only set $LASTEXITCODE and continue unless you explicitly checked it after each call. This creates several pitfalls:
- Silent failures: A failing native command is easy to miss and the script continues with bad assumptions.
- Inconsistent behavior: Cmdlets stop on errors with
'Stop', but native tools don't, mixing two failure models. - Flaky CI/CD: Pipelines may pass sporadically or fail late, far from the root cause.
- Harder debugging: You need to sprinkle checks for
$LASTEXITCODEeverywhere, and you still risk missing one.
The modern PowerShell way is to tell the engine to treat non-zero exits from native commands as actual errors, and then opt to stop on errors globally. You get predictable control flow, cleaner logs, and safer pipelines.
Make non-zero exits act like errors
Two lines establish predictable behavior:
$PSNativeCommandUseErrorActionPreference = $truemakes native commands honor$ErrorActionPreferencefor non-zero exit codes.$ErrorActionPreference = 'Stop'makes non-terminating errors turn into terminating errors (exceptions), causingtry/catchto work the way you expect.
Use both at the top of your script or module. Then add try/catch around native calls and log $LASTEXITCODE with useful context so you can fix failures quickly. Here is a cross-platform test to verify behavior consistently across Windows, Linux, and macOS:
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
if ($IsWindows) {
$bin = 'cmd.exe'; $args = @('/c','exit','2')
} else {
$bin = '/bin/sh'; $args = @('-c','exit 2')
}
try {
& $bin @args
Write-Host 'Native OK'
} catch {
Write-Warning ("Caught native failure. Exit={0}" -f $LASTEXITCODE)
}
Expected result: it catches the failure and prints the warning with Exit=2. If you see "Native OK", your environment is not configured as expected—ensure both variables are set before the call, and confirm your PowerShell version supports $PSNativeCommandUseErrorActionPreference (PowerShell 7+).
Alternative universal test
If you want a single-line cross-platform test using PowerShell itself:
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
try {
& pwsh -NoProfile -Command 'exit 2'
Write-Host 'Unexpected: succeeded'
} catch {
Write-Warning "Caught native failure from pwsh. Exit=$LASTEXITCODE"
}
This uses the PowerShell binary as the native process we expect to fail with exit code 2. It works the same way on all runners and OSes that have pwsh in PATH.
Patterns for robust, cross-platform native integrations
1) Set preferences once, early, and locally
Set the preferences at the top of your script/module so child calls inherit the behavior. If you are writing a function that wraps native tools, set these inside the function to avoid relying on the caller’s global state:
function Invoke-NativeChecked {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$FilePath,
[string[]]$ArgumentList
)
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
try {
$output = & $FilePath @ArgumentList 2>&1
[pscustomobject]@{
Output = $output
ExitCode = $LASTEXITCODE
Command = "$FilePath $($ArgumentList -join ' ')"
}
} catch {
$context = [pscustomobject]@{
FilePath = $FilePath
Arguments = $ArgumentList -join ' '
ExitCode = $LASTEXITCODE
Pwd = (Get-Location).Path
PSVersion = $PSVersionTable.PSVersion.ToString()
OS = ($PSVersionTable.OS ?? $env:OS)
Error = $_.Exception.Message
}
Write-Error ("Native command failed: {0} (exit {1})`nContext: {2}" -f $FilePath, $LASTEXITCODE, ($context | ConvertTo-Json -Compress))
throw
}
}
# Example usage
$result = Invoke-NativeChecked -FilePath 'git' -ArgumentList @('status','--porcelain')
$result.Output | ForEach-Object { Write-Host $_ }
Key points:
- Stop on the first failure. You get fast, clear failures instead of cascading errors.
- Capture output for logging or decision-making while still failing on non-zero exits.
- Include context (args, PWD, PS/OS version) to make root-cause analysis quick.
2) Fail fast in CI/CD and scripts
Use these preferences in GitHub Actions, Azure Pipelines, GitLab, or Jenkins to stop a job immediately when a tool fails:
- name: Enforce native failures stop the job
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
& git --version
& pwsh -NoProfile -Command 'exit 2'
Write-Host 'This line is never reached'
Because non-zero exits are treated as errors with 'Stop', your job fails deterministically and points to the exact failing step.
3) Wrap critical native calls with try/catch and log $LASTEXITCODE
Even when PowerShell throws, it’s still useful to surface the native exit code and relevant context in logs. Add a small helper for consistent logging:
function Invoke-NativeOrDie {
param([Parameter(Mandatory)][string]$Exe, [string[]]$Args)
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
try {
& $Exe @Args
} catch {
$msg = "{0} {1} failed with exit {2}" -f $Exe, ($Args -join ' '), $LASTEXITCODE
Write-Error $msg
throw
}
}
Invoke-NativeOrDie 'docker' @('ps')
This keeps logs readable without duplicating error-handling boilerplate everywhere.
4) Cross-platform testing for predictable behavior
Automate a smoke test across OSes so you never regress behavior when refactoring:
param([switch]$Fail)
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
if ($Fail) {
if ($IsWindows) {
& cmd.exe /c exit 2
} else {
& /bin/sh -c 'exit 2'
}
} else {
if ($IsWindows) {
& cmd.exe /c exit 0
} else {
& /bin/sh -c 'exit 0'
}
}
Write-Host 'Reached end successfully'
Run with pwsh ./script.ps1 -Fail and verify it stops with a caught error and logs the exit code. Run without -Fail and ensure the success message prints.
5) Practical tips and gotchas
- Set preferences inside functions that invoke native tools to protect against callers overriding global state.
- Only catch what you can handle. If you rethrow, include the native exit code and command line for forensic clarity.
- Don’t rely on
$?for native commands; use exceptions plus$LASTEXITCODEfor details. - If a tool writes failures only to stderr but returns 0, you must parse output or use tool flags that enforce non-zero exits (e.g.,
--failwithcurl). - For long-lived processes, prefer
Start-Process -PassThru -Waitand inspectExitCode, then throw if non-zero. - Make sure your team’s shell is consistent. Use
pwshin CI with the preferences set at the start of every step.
What you get: fewer surprises, clearer failures, safer pipelines, consistent behavior.
Build safer native integrations in PowerShell and level up your scripts. For deeper patterns, advanced scenarios, and production-ready recipes, check out the PowerShell Advanced CookBook.