Chapter 02Lesson 05~95 minutes

Native Executables, Exit Codes, Arguments, and Cross-Shell Boundaries

Learn the PowerShell-to-native process boundary: executable discovery, argument passing, text streams, exit codes, modern PowerShell 7.x native preferences, and safe cross-platform wrappers.

BeginnerNative commandsExit codes

Learning objectives

By the end of this lesson

  • Differentiate a PowerShell cmdlet/function invocation from launching a native executable process.
  • Use $LASTEXITCODE and $? correctly after native commands and explain what each signal represents.
  • Describe how native stdout/stderr differ from PowerShell rich object output.
  • Inspect and reason about modern $PSNativeCommandArgumentPassing and $PSNativeCommandUseErrorActionPreference behavior.
  • Pass native arguments as structured values and build a small cross-platform lab using pwsh as the guaranteed native executable.

1. A native command crosses into another process

When you run a cmdlet such as Get-Process, PowerShell invokes a PowerShell command that participates directly in the engine’s parameter binding, streams, and object model. When you run a native executable such as pwsh, git, or curl, PowerShell starts another operating-system process.

Get-Command Get-Process | Select-Object Name, CommandType, Source
Get-Command pwsh -CommandType Application | Select-Object Name, CommandType, Path

That process boundary matters. PowerShell must convert argument values into the form the child process receives. The child communicates success primarily through a numeric process exit code and typically communicates data through stdout/stderr text streams. Those are not the same contracts as PowerShell rich objects and PowerShell error records.

2. Compare a cmdlet object with native text output

First inspect a PowerShell object:

$processObject = Get-Process -Id $PID
$processObject.GetType().FullName
$processObject | Get-Member | Select-Object -First 8 Name, MemberType

Now ask a child pwsh process to print its version. The executable writes text to its standard output; the parent PowerShell receives that output as string data:

$pwsh = (Get-Command pwsh -CommandType Application).Path
$nativeOutput = & $pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'

$nativeOutput
$nativeOutput.GetType().FullName

The exact type display can reflect collection/scalar behavior when multiple lines are returned, but the conceptual difference is stable: the child process did not send a live System.Diagnostics.Process object across the operating-system process boundary. It emitted text.

3. $LASTEXITCODE is the native process result signal

Native programs return an integer exit code to the parent process. By long-standing convention, zero commonly means success and nonzero commonly means some kind of failure—but every tool defines its own contract. PowerShell stores the most recent native exit code in $LASTEXITCODE.

& $pwsh -NoProfile -Command 'exit 0'
$LASTEXITCODE

& $pwsh -NoProfile -Command 'exit 7'
$LASTEXITCODE
0
7

Do not discard a nonzero code merely because the tool printed useful-looking output. In automation, the exit-code contract is often what CI runners and wrapper scripts use to decide success.

4. $? is a PowerShell success flag for the last operation

$? is a Boolean automatic variable that reports success/failure of the last operation. For native executables, current PowerShell sets $? to $true when $LASTEXITCODE is zero and $false when it is nonzero.

& $pwsh -NoProfile -Command 'exit 0'
[pscustomobject]@{ LastExitCode = $LASTEXITCODE; Success = $? }

& $pwsh -NoProfile -Command 'exit 9'
[pscustomobject]@{ LastExitCode = $LASTEXITCODE; Success = $? }

Be careful when inspecting these variables: every additional command you run can update session state. Capture values immediately after the native invocation when they matter.

Exit-code semantics belong to the tool

Zero/nonzero is common, but some tools use multiple nonzero codes for informational outcomes. A production wrapper should implement the specific tool’s documented policy rather than assuming every nonzero value means the same thing.

5. Keep native arguments as separate values

The safest mental model is executable + ordered argument values. Avoid concatenating all arguments into one shell-like string. PowerShell 7.3 introduced a newer native argument-passing implementation that better preserves quotes and empty-string arguments.

$PSNativeCommandArgumentPassing

$arguments = @(
    '-NoProfile'
    '-Command'
    'exit 0'
)

& $pwsh @arguments
$LASTEXITCODE

Array splatting passes the array elements as command arguments. That preserves logical boundaries in your PowerShell code and makes logging/testing easier. The exact conversion at the native boundary is controlled by the current native argument-passing mode.

6. Modern PowerShell 7.x native argument-passing modes

In current PowerShell 7.x, $PSNativeCommandArgumentPassing can be Legacy, Standard, or Windows. Microsoft documents the default as Windows on Windows and Standard on non-Windows platforms.

ModeMeaning at a beginner level
StandardUse the modern native argument-passing behavior. This is the default on non-Windows platforms.
WindowsUse the modern behavior generally, but automatically use legacy-style passing for specific Windows executables/file types such as cmd/batch-family scenarios documented by Microsoft. This is the default on Windows.
LegacyUse the historical argument behavior for compatibility with scripts/tools that depended on it.
[pscustomobject]@{
    PowerShellVersion = $PSVersionTable.PSVersion.ToString()
    PlatformMode      = $PSNativeCommandArgumentPassing
}

Do not change this preference globally just because one old command behaves strangely. First identify the target tool, its quoting requirements, and whether the script depends on legacy behavior. Scope compatibility work narrowly.

7. stdout/stderr are native streams, not rich PowerShell objects

Native programs conventionally write normal output to stdout and diagnostics/errors to stderr. PowerShell can capture and redirect these streams, but the originating contract remains a native text-stream contract.

# Child process writes one line to stdout and exits successfully.
$out = & $pwsh -NoProfile -Command "[Console]::Out.WriteLine('native stdout')"
$out
$out.GetType().FullName

# Capture both native stdout and stderr into the success pipeline for inspection.
$combined = & $pwsh -NoProfile -Command "[Console]::Out.WriteLine('out'); [Console]::Error.WriteLine('err')" 2>&1
$combined

Redirection can deliberately merge streams, but do not confuse “I captured text” with “the child succeeded.” Inspect the exit code separately. Chapter 10 develops PowerShell streams and redirection in much more detail.

8. $PSNativeCommandUseErrorActionPreference can integrate nonzero exits with PowerShell errors

Current PowerShell exposes $PSNativeCommandUseErrorActionPreference. When it is $true, native commands that return nonzero exit codes issue PowerShell errors according to $ErrorActionPreference. This can make native failures participate more naturally in PowerShell error workflows.

$PSNativeCommandUseErrorActionPreference

Do not assume enabling it globally is always correct. Some native tools assign nonzero codes to outcomes that are not failures. A wrapper can temporarily scope behavior or handle $LASTEXITCODE explicitly using the tool’s documented contract.

Version and policy boundary

This lesson targets current PowerShell 7.6.x behavior. Windows PowerShell 5.1 and older PowerShell 7 releases have different native argument/error capabilities. Treat compatibility as an explicit requirement, not an invisible default.

9. Lab: use pwsh itself as a guaranteed native executable

Because you are already running PowerShell, the pwsh executable is the most reliable cross-platform native tool for this lab. We will create a disposable child script that prints the arguments it received and exits with a caller-selected code.

$lab = Join-Path $HOME 'devops-academy/powershell/chapter02/lesson05'
New-Item -ItemType Directory -Path $lab -Force | Out-Null
$child = Join-Path $lab 'show-args.ps1'

@'
$index = 0
foreach ($arg in $args) {
    "ARG[$index]=<$arg>"
    $index++
}
exit 0
'@ | Set-Content -LiteralPath $child -Encoding utf8

$nativeArgs = @(
    '-NoProfile'
    '-File'
    $child
    'alpha'
    'two words'
    '$HOME stays data'
    'value=with=equals'
)

& $pwsh @nativeArgs
$exitCode = $LASTEXITCODE
$success = $?

[pscustomobject]@{ ExitCode = $exitCode; Success = $success }

Expected observation: each logical value should appear as one child-script argument, including two words and the literal dollar-sign text. Your paths differ, but the argument boundaries should remain visible. The child exits zero, so $LASTEXITCODE should be 0 and $? should be true when captured immediately.

Now exercise a known failure code without changing system state:

& $pwsh -NoProfile -Command 'exit 23'
$failedCode = $LASTEXITCODE
$failed = $?

[pscustomobject]@{ ExitCode = $failedCode; Success = $failed }
ExitCode Success
-------- -------
23       False

Verification checklist

10. Cleanup the disposable native-process lab

The only state created by the lab is the isolated lesson directory under your home-folder course workspace. Preview the exact target, then remove it if it matches the lab path you created:

Set-Location $HOME
Remove-Item -LiteralPath $lab -Recurse -Force -WhatIf

# Execute only after verifying the WhatIf target.
Remove-Item -LiteralPath $lab -Recurse -Force
Test-Path -LiteralPath $lab

The final result should be False. The course root can remain for later chapters.

11. Common native-boundary mistakes

Assuming printed output means success. Check the documented exit-code contract.

Treating native text like rich cmdlet objects. Parse or convert it deliberately, or prefer a PowerShell-native API/cmdlet when one provides structured data.

Building a single command string. Preserve the executable and each argument as separate values.

Copying Windows PowerShell 5.1 quoting workarounds into modern PowerShell without testing. Current PowerShell uses newer native argument behavior.

Assuming every nonzero exit code is the same error. Implement the target tool’s documented policy.

Changing global native preferences to fix one tool. Prefer narrow compatibility handling and document why it is needed.

12. Knowledge check

Question 1. What does $LASTEXITCODE preserve after a native executable?

Question 2. For a native command in current PowerShell, when is $? normally false?

Question 3. Why is an argument array preferable to one concatenated command string?

Question 4. What is the default $PSNativeCommandArgumentPassing mode on Windows versus non-Windows in modern PowerShell?

Question 5. Does native stdout automatically contain PowerShell rich objects?

13. Summary

Native executables are a separate process boundary. Discover the executable, keep arguments structured, understand the current argument-passing mode, treat stdout/stderr as native text streams, and capture the numeric exit code immediately. $? is a Boolean success signal; $LASTEXITCODE retains the tool-specific numeric result. This distinction is foundational for reliable CI, cloud CLIs, package managers, Git, container tools, and every other native utility PowerShell orchestrates.

14. Further reading

Next chapter

Follow objects through the PowerShell pipeline

Chapter 03 turns the object-pipeline idea from Chapter 01 into a working model: types, properties, Get-Member, parameter binding by value/property name, filtering, projection, and the rule that formatting belongs at the end.

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.