Calling External EXEs Correctly from PowerShell — The Pitfalls of Argument Quoting, Exit Codes, and Mojibake
· Go Komura · PowerShell, Windows, Automation, Script, Process, Character Encoding, Operational Improvement, Batch Processing
“It works fine in my command prompt, but the moment I move it into a PowerShell script the external tool returns an error” — this is something you will almost certainly hit when converting batch files to PowerShell, or when automating an in-house EXE or an open-source CLI. Most of the time the cause isn’t the logic, it’s the route the arguments take to reach the program. A path containing spaces, an argument containing a double quote, a string containing % or ( ). Any one of those can be the trigger that turns the argument you thought you passed into something else.
What makes it worse is that this behaviour changed in PowerShell 7.3. A workaround written to work in 5.1 becomes a double escape in 7, and a script written for 7 breaks in 5.1. In Japanese-language environments, mojibake in the output piles on top of that.
This article covers how arguments actually travel when you call an external program (a native command) from PowerShell, starting from the specification: the correct use of --%, ProcessStartInfo for when you need certainty, handling exit codes and stderr, and dealing with mojibake — all from a practitioner’s perspective. The design of error handling itself is covered in “PowerShell Error Handling and Retry Design,” so this article stays focused on the boundary with external processes.
1. The Bottom Line First
- PowerShell parses the arguments of external programs itself, too. Everything after a command invocation is parsed in argument mode, and values containing spaces need to be enclosed in quotes. Characters such as
,(){}|&<>@#are metacharacters, so to pass them literally you escape them with a backtick.1 - PowerShell 7.3 changed how arguments are passed (a breaking change). Embedded quotes and empty-string arguments are now preserved, and you can select the behaviour with
$PSNativeCommandArgumentPassing. The default on Windows isWindows.12 - In
Windowsmode, only cmd.exe, cscript.exe, wscript.exe and the.bat.cmd.js.vbs.wsfextensions fall back to the old (Legacy) style of passing. This is an exception for compatibility with legacy batch assets.1 - The stop-parsing token
--%is a last resort that treats everything after it as a literal. However, environment variables in%VAR%form are still expanded, PowerShell variables can’t be used at all, and the effect ends at a newline or a pipe. Redirections can’t be written either.1 - If you want to pass the value of a variable reliably, an array is the first choice. Splatting with
& $exe @argArrayturns each element into an independent argument. WithProcessStartInfo.ArgumentListyou can leave the quoting to .NET, but that’s a .NET Core 2.1+ API and isn’t available in 5.1.13 - Never pass untrusted input to a batch file. On Windows, arguments to a batch file are handed to cmd.exe as a raw command-line string, which is why the official documentation warns that untrusted input should be passed by some other means.1
- Judge success or failure with
$LASTEXITCODE. By default a nonzero exit code is not a PowerShell error and does not land in try/catch.4 - Fix mojibake separately on the sending side and the receiving side. Decoding an external command’s output is handled by
[Console]::OutputEncoding; strings piped from PowerShell into an external command are handled by$OutputEncoding.2 - When in doubt, use
Trace-Command -Name ParameterBindingto see the arguments that were actually passed. From PowerShell 7.3 onwards, argument binding for native commands can be traced as well.15
2. Why Arguments Get Mangled — The Premise of Argument Mode
PowerShell breaks a command line into tokens and interprets each one in either expression mode or argument mode. Once a command invocation appears, everything after it is parsed in argument mode. In argument mode, input is basically treated as an expandable string: something starting with $ is a variable reference, a quote starts a string, ( ) start an expression, and so on — in other words, the symbols carry syntactic meaning.1
That means a string you could paste straight into a command prompt and have work may be interpreted as something else entirely in PowerShell. The classic example is icacls.1
# Works in cmd.exe, but in the PowerShell 2.0 era the parentheses were interpreted as an expression and errored
icacls X:\VMS /grant Dom\HVAdmin:(CI)(OI)F
# Escape the metacharacters with backticks (hard to read)
icacls X:\VMS /grant Dom\HVAdmin:`(CI`)`(OI`)F
# Declare "everything from here is a literal" with the stop-parsing token (PowerShell 3.0+)
icacls X:\VMS --% /grant Dom\HVAdmin:(CI)(OI)F
The other premise is the route parsed arguments take to reach the program. In Windows PowerShell 5.1, the parsed arguments are reassembled into a single space-separated string before being handed to the process. During that reassembly, quotes contained inside an argument get dropped, and empty-string arguments disappear — the classic 5.1-era accident.1
3. The PowerShell 7.3 Breaking Change — $PSNativeCommandArgumentPassing
PowerShell 7.3 changed how that assembly works. This is a spot where the official documentation explicitly says it is a breaking change from the Windows PowerShell 5.1 behaviour.1
The new behaviour can be switched with the $PSNativeCommandArgumentPassing preference variable, whose values are Legacy (the old behaviour), Standard, and Windows. The default on the Windows platform is Windows; on non-Windows it’s Standard.12
There is exactly one difference between Windows and Standard: in Windows mode, the following invocations automatically use the Legacy style.1
| Invocations that automatically get the Legacy style |
|---|
cmd.exe / cscript.exe / wscript.exe |
Files with the extensions .bat .cmd .js .vbs .wsf |
It’s an exception designed to prevent the accident where old batch files or WSH scripts “break the moment you upgrade PowerShell to 7 because argument handling changed.” Put the other way round: if you explicitly set $PSNativeCommandArgumentPassing to Standard or Legacy, that determination is no longer made.1
The new style improves two things.1
# (1) Quotes embedded in a string are preserved
$a = 'a" "b'
TestExe -echoargs $a 'c" "d' e" "f
# Arg 0 is <a" "b>
# Arg 1 is <c" "d>
# Arg 2 is <e f>
# (2) Empty-string arguments survive instead of disappearing
TestExe -echoargs '' a b ''
# Arg 0 is <>
# Arg 1 is <a>
# Arg 2 is <b>
# Arg 3 is <>
If you want to pass a quoted path string such as "C:\Program Files (x86)\Microsoft\" as-is, you can write it straightforwardly in Windows / Standard mode.1
# 7.3 and later (Windows / Standard mode)
TestExe -echoargs '"C:\Program Files (x86)\Microsoft\"'
# Getting the same result in Legacy mode (the 5.1 equivalent) requires double-escaping the quotes
TestExe -echoargs "\""C:\Program Files (x86)\Microsoft\\"""
One caution here. The backslash (\) is not PowerShell’s escape character. \" appears in the example above because the underlying .NET API (ProcessStartInfo.ArgumentList) treats the backslash as an escape character; escaping in PowerShell syntax is done with a backtick (`). The mixing of these two is the single biggest reason this area looks difficult.13
The practical judgement is simple. In an environment where 5.1 and 7 coexist, stop writing arguments containing quotes as literals. Lean on “pass an array” and “use ProcessStartInfo” from the next sections and you’ll be almost entirely insulated from version differences. For the policy on 5.1/7 coexistence itself, see “The Differences Between Windows PowerShell 5.1 and PowerShell 7.”
4. The Correct Uses and the Limits of –% (the Stop-Parsing Token)
--% is a token that makes PowerShell pass the characters after it through without interpreting them (PowerShell 3.0+). The official documentation explicitly states that it is intended only for use with native commands on the Windows platform.1
PS> cmd /c echo "a|b"
'b' is not recognized as an internal or external command,
operable program or batch file.
PS> cmd /c --% echo "a|b"
"a|b"
It’s powerful, but you need to know its constraints precisely.1
| Constraint | Details |
|---|---|
| Environment variables alone are expanded | %<name>% forms such as %USERPROFILE% are always expanded. Escaping with %% isn’t possible. Undefined names pass through unchanged |
| PowerShell variables can’t be used | $path and similar are not expanded; they’re passed as literal strings |
| Scope of the effect | Until the next newline or pipe (|). It can’t be extended with backtick line continuation, and can’t be terminated with ; |
| No redirection | Things like >file.txt are passed straight through to the target command as arguments |
In other words, --% is usable only when what you’re passing is entirely a fixed string and contains no %. In the typical automation scenario where a script assembles a value into a variable and passes it, that condition is essentially never met. A habit of “just add --%” falls apart the moment you pass a password or wildcard containing %.
5. Passing Variables Reliably — Array Splatting and ProcessStartInfo
When you need to pass arguments that include variable values, the first choice is to put one argument per array element and splat the array. Each array element is passed as an independent argument, so you don’t need to write quotes yourself even for paths containing spaces.
$exe = 'C:\Program Files\MyTool\convert.exe'
$args = @(
'--input', 'D:\Order Data\2026-07.csv' # Spaces and non-ASCII characters are no problem
'--output', 'D:\Output\result.json'
'--mode', 'strict'
)
& $exe @args # Array splatting. Each element becomes one argument
if ($LASTEXITCODE -ne 0) { throw "Conversion failed (ExitCode=$LASTEXITCODE)" }
The call operator & is also required when running an exe whose path contains spaces ('C:\Program Files\...' on its own is merely evaluated as a string literal and never executed).
When you need more certainty — full control over each individual argument, or the ability to specify the output encoding per process — use .NET’s ProcessStartInfo directly. Values added to ArgumentList are quoted appropriately by .NET, so they are affected neither by PowerShell’s parsing nor by command-line re-parsing.3
However, ArgumentList is a .NET Core 2.1+ API and does not exist on the ProcessStartInfo of Windows PowerShell 5.1, which runs on .NET Framework.3 On 5.1, either assemble the Arguments string yourself as shown later in this section, or use the array splatting described above.
# [PowerShell 7+] Leave argument assembly to .NET
$psi = [System.Diagnostics.ProcessStartInfo]::new()
$psi.FileName = 'C:\Program Files\MyTool\convert.exe'
foreach ($a in '--input', $inputPath, '--output', $outputPath) {
$psi.ArgumentList.Add($a) # 1 element = 1 argument. Don't write quotes yourself (7 only)
}
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
# Being able to state the output encoding explicitly is another advantage of ProcessStartInfo (Section 6)
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
$psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
$proc = [System.Diagnostics.Process]::Start($psi)
# Read standard output and standard error "at the same time". If you read one of them
# synchronously to completion and only then read the other, the other pipe's buffer
# fills up while you're waiting, the child process blocks on its write, and you
# deadlock right there
$stdoutTask = $proc.StandardOutput.ReadToEndAsync()
$stderrTask = $proc.StandardError.ReadToEndAsync()
$proc.WaitForExit()
$stdout = $stdoutTask.GetAwaiter().GetResult()
$stderr = $stderrTask.GetAwaiter().GetResult()
if ($proc.ExitCode -ne 0) {
throw "Conversion failed (ExitCode=$($proc.ExitCode)): $stderr"
}
The most common accident with output redirection is deadlock. A pipe’s buffer has an upper limit, and once it fills, the child process blocks on its write. So it isn’t just calling WaitForExit() first and reading afterwards that’s dangerous — reading one stream to completion synchronously with ReadToEnd() before reading the other is dangerous too (if the buffer of the stream you aren’t reading fills first, the child process stalls and ReadToEnd() never returns). Either start reading both asynchronously and then wait, as above, or design things so that only one stream is redirected. For handling child processes in general, see also “A Checklist for Safely Handling Child Processes in Windows Apps.”
If you use ProcessStartInfo on Windows PowerShell 5.1, you end up passing Arguments a single string that you have quoted yourself. Writing that assembly by hand is a source of accidents, so on 5.1 make array splatting (& $exe @args) your first choice.
# [5.1] With no ArgumentList, assemble a single string containing the quotes yourself
$quote = {
param([string] $s)
if ($s -eq '') { return '""' } # An empty string must become "" or the argument vanishes entirely
if ($s -notmatch '[\s"]') { return $s } # If there's no need to enclose it, leave it alone
# Follow the Windows command-line parsing rules:
# (1) Double the run of backslashes immediately before a ", and turn the " itself into \"
# (2) Double a trailing run of backslashes as well. Because it sits immediately before
# the closing quote, leaving it alone would make it parse as \", the quote would
# never close, and even the following arguments would be broken
# (e.g. 'C:\Program Files\input\' -> "C:\Program Files\input\\")
$e = $s -replace '(\\*)"', '$1$1\"'
$e = $e -replace '(\\+)$', '$1$1'
'"' + $e + '"'
}
$psi.Arguments = (@('--input', $inputPath, '--output', $outputPath) |
ForEach-Object { & $quote $_ }) -join ' '
The fiddliness of these rules is exactly why you should avoid hand-assembly on 5.1. That said, array splatting isn’t a cure-all on Windows PowerShell 5.1 either. The values you pass are ultimately reassembled into a command-line string in the old style, so empty-string arguments disappear and values containing quotes get transformed.1 So on 5.1, use the following division of labour.
| Argument you’re passing on 5.1 | Method |
|---|---|
| Ordinary values containing spaces or non-ASCII characters | Array splatting (& $exe @args) is enough |
| Empty strings and values containing quotes | ProcessStartInfo + the escaping above, or --% (fixed strings only) |
PowerShell 7 resolves both problems, so this division of labour becomes unnecessary.
Note that the official documentation warns against passing untrusted input to batch files, because arguments to a batch file are handed to cmd.exe as a raw command-line string.1 A design that concatenates user input or file names into a batch invocation is a breeding ground for command injection. Pass the values via a temporary file or environment variables, or migrate the batch file itself to PowerShell (see “Should That Batch File Be Migrated to PowerShell?”).
6. Fixing Mojibake — [Console]::OutputEncoding and $OutputEncoding
In a Japanese-language environment, mojibake is inevitable. The key point is that the setting used depends on the direction.
| Direction | Setting used | Symptom |
|---|---|---|
| PowerShell receives an external command’s output | [Console]::OutputEncoding |
Output from a UTF-8 tool comes back garbled, like “譁�喧縺�” |
| PowerShell pipes a string to an external command | $OutputEncoding |
The non-ASCII text you sent is garbled on the other side |
$OutputEncoding is the preference variable that determines the encoding PowerShell uses when sending strings to a native command.2 Decoding the byte sequence an external command emits back into a string, on the other hand, is [Console]::OutputEncoding’s job. When someone fixes one of them and says “it’s still garbled,” it’s almost always because these two have been conflated.
# The standard fix when calling an external tool that outputs UTF-8 from Windows PowerShell 5.1
$prevOut = [Console]::OutputEncoding
$prevPs = $OutputEncoding
try {
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) # UTF-8 without BOM
$OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$result = & $exe --list
}
finally {
# This affects the whole session, so always restore it
[Console]::OutputEncoding = $prevOut
$OutputEncoding = $prevPs
}
If you use ProcessStartInfo, you can specify StandardOutputEncoding / StandardErrorEncoding per process as described in the previous section, so you never have to touch the session’s settings. That’s the option with fewer side effects. Character encoding on Windows in general is covered in “Windows Text Encodings and Line Endings.”
7. Exit Codes and stderr — Preventing “It Succeeded but Was Treated as a Failure”
You judge an external program’s success or failure with $LASTEXITCODE. By default a nonzero exit code does not generate an ErrorRecord and does not land in try/catch.4 Those basics were covered in detail in “PowerShell Error Handling and Retry Design,” so here I’ll add just two points specific to external processes.
(1) Writing to stderr is not a “failure.” Many CLI tools write progress and logs to stderr. PowerShell routes a native command’s stderr output into the error stream, so the screen turns red and it looks like a failure — but if the exit code is 0, it succeeded. In Windows PowerShell 5.1, $? could become $false merely because something was written to stderr; in PowerShell 7 this was fixed so that it becomes $false only on a nonzero exit code.6
(2) The type after merging with 2>&1 differs by version. In Windows PowerShell 5.1 each stderr line is mixed in as an ErrorRecord, but from PowerShell 7.4 onwards a native command’s redirected output is treated as a byte stream, and after merging it becomes string data.78 In other words, code that “sorts by whether something is an ErrorRecord” no longer works on 7.4 and later. If you need both outputs, the reliable approach is to receive them separately rather than merging them.
# [Recommended] Receive stdout and stderr separately (unaffected by version differences)
$errFile = [System.IO.Path]::GetTempFileName()
try {
$stdout = & $exe --import $csvPath 2> $errFile
$stderr = Get-Content -Path $errFile -Raw
# Keep both in the log
$stdout | Add-Content -Path $logPath -Encoding utf8
if ($stderr) { $stderr | Add-Content -Path $logPath -Encoding utf8 }
if ($LASTEXITCODE -ne 0) {
throw "Import failed (ExitCode=$LASTEXITCODE): $stderr"
}
# Reaching here means success. Output on stderr is not treated as a failure
}
finally {
Remove-Item $errFile -ErrorAction SilentlyContinue
}
# [When you don't need to distinguish them] Merging is fine if you're just dumping everything to the log
(& $exe --import $csvPath 2>&1) | ForEach-Object { $_.ToString() } |
Add-Content -Path $logPath -Encoding utf8
8. Checking What Was Actually Passed
Adding escapes on guesswork makes things worse. The fastest route is to look at the arguments that were actually passed. From PowerShell 7.3 onwards, you can trace native command argument binding with Trace-Command.15
Trace-Command -Name ParameterBinding -PSHost -Expression {
& $exe --input 'D:\Order Data\2026-07.csv' --mode strict
}
# DEBUG: ... BIND cmd line arg [--input] to position [0]
# DEBUG: ... BIND cmd line arg [D:\Order Data\2026-07.csv] to position [1]
If the thing you’re calling is your own tool, adding a single verification mode that just echoes the args it received makes this kind of investigation instantaneous (it’s the same idea as TestExe -echoargs in PowerShell’s own test tooling).1 If you want to do the same thing on a current 5.1 environment, a small .ps1 that merely enumerates $args, or a small EXE that merely prints its arguments, is enough.
9. Practical Rules of Thumb (Decision Table)
| Situation | Options | Guideline |
|---|---|---|
Fixed-string arguments (containing no %) |
--% / normal invocation |
If escaping gets messy, --% is the shortest route. But variables can’t be used1 |
| Passing the value of a variable | Array splatting & $exe @args |
First choice. No need to write quotes yourself even with spaces, non-ASCII characters, or symbols |
| Wanting the same code to work on both 5.1 and 7 | Array splatting | The code is the same, but on 5.1 empty strings and arguments containing quotes break (see the note below)1 |
| Passing empty strings or arguments containing quotes on 5.1 | ProcessStartInfo + your own escaping | Reliable because it doesn’t go through 5.1’s command-line reconstruction (Section 5) |
| Wanting full control over each argument (7 only) | ProcessStartInfo + ArgumentList |
Lets you leave the quoting to .NET. A .NET Core 2.1+ API3 |
| Needing a separate window, a different user, or elevation | Start-Process | Get ExitCode with -Wait -PassThru. Redirect output to a file9 |
| Passing a value to a batch file (.bat) | Via environment variables or a temporary file | Don’t pass untrusted input as arguments (official warning)1 |
| Output comes back as mojibake | [Console]::OutputEncoding (receiving) / $OutputEncoding (sending) |
The setting depends on the direction. Per process, use StandardOutputEncoding2 |
| Judging success or failure | $LASTEXITCODE |
A nonzero code doesn’t land in catch by default. Output on stderr doesn’t mean failure46 |
| Wanting to distinguish stdout from stderr | Redirect and receive them separately | The type after merging with 2>&1 differs between 5.1 and 7.4+78 |
| No idea what was actually passed | Trace-Command -Name ParameterBinding |
Measure before you add escapes on guesswork5 |
10. Summary
- PowerShell parses external programs’ arguments in argument mode too. Symbols carry syntactic meaning, so a string that works in cmd.exe won’t necessarily pass through unchanged.
- PowerShell 7.3 changed how arguments are passed (a breaking change) so that embedded quotes and empty strings are preserved. The default on Windows is
Windowsmode, in which only cmd.exe, batch files and the like fall back to the Legacy style. --%is a last resort for fixed strings only. Use it with an understanding of its constraints:%VAR%is always expanded, PowerShell variables can’t be used, and the effect ends at a newline or a pipe.- To pass variables, array splatting is the first choice. On PowerShell 7 you can also use
ProcessStartInfo’sArgumentList(it doesn’t exist in 5.1). The fact that the backslash is not PowerShell’s escape character is a major source of confusion. - When redirecting both standard output and standard error, always read both at the same time. Reading one to completion synchronously will deadlock.
- Mojibake is handled differently depending on the direction. Receiving:
[Console]::OutputEncoding. Sending:$OutputEncoding. Per process:StandardOutputEncoding. - Judge success or failure with
$LASTEXITCODE. Output on stderr is not a failure. If you want to distinguish stdout from stderr, don’t merge them with2>&1— receive them separately (the type after merging differs between 5.1 and 7.4+).
Sample Code Download
The code covered in this article is packaged in a ready-to-run form. It includes the argument-quoting module and execution via ProcessStartInfo.
Download the sample code (zip)
The samples in this article have been actually run and verified on PowerShell 7.6 (22 Pester tests). Run Invoke-SampleTests.ps1 included in the zip and you can reproduce the same verification on your own machine.
# Syntax parsing + static analysis + Pester tests
./Invoke-SampleTests.ps1
Configuration values (paths, server names, tenant IDs and so on) are examples. Do not run them as-is in a production environment — adapt them to your own environment.
Related Articles
- PowerShell Error Handling and Retry Design — From the try/catch Trap to Exit Codes and Retry Best Practices
- The Differences Between Windows PowerShell 5.1 and PowerShell 7 — A Practical Guide to Migrating In-House Scripts
- Should That Batch File Be Migrated to PowerShell? — Taking Stock of cmd/bat Assets and Deciding on Migration
- A Checklist for Safely Handling Child Processes in Windows Apps
- Windows Text Encodings and Line Endings - The Basics of Mojibake and CRLF/LF
- PowerShell Parameter Design and Modularization — From “a Script That Works” to “a Script You Can Hand Over”
Related Consulting Areas
KomuraSoft LLC handles converting batch assets to PowerShell, designing automation that combines external tools and in-house EXEs, and investigating the causes of scripts that “work in some environments and not others.”
- Technical Consulting & Design Review
- Legacy Asset Migration & Reuse
- Bug Investigation & Root-Cause Analysis
- Contact
References
-
Microsoft Learn, about_Parsing. On the distinction between expression mode and argument mode, the metacharacters of argument mode, escaping with backticks, the fact that arguments passed to native commands are joined after parsing into a single space-separated string, the specification of the stop-parsing token
--%from PowerShell 3.0 onwards (only environment variables are expanded,%%escaping isn’t possible, the effect lasts until a newline or a pipe, and redirection isn’t possible), the breaking change in PowerShell 7.3 to how native command command lines are parsed, the values of$PSNativeCommandArgumentPassing(Legacy/Standard/Windows) and the default on Windows, the fact that in Windows mode cmd.exe, cscript.exe, wscript.exe and .bat/.cmd/.js/.vbs/.wsf use the Legacy style, the fact that the backslash is not PowerShell’s escape character, the warning against passing untrusted input to batch files, and the ability from 7.3 onwards to trace native command argument binding. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25 ↩26 -
Microsoft Learn, about_Preference_Variables. On
$PSNativeCommandArgumentPassingbeing a preference variable with a platform-dependent default, and on$OutputEncodingdetermining the encoding used when PowerShell sends strings to other applications. ↩ ↩2 ↩3 ↩4 ↩5 -
Microsoft Learn, ProcessStartInfo.ArgumentList Property. On being able to specify arguments individually as a collection with the runtime performing the necessary quoting and escaping, on the backslash being treated as an escape character, and on the API applying to .NET Core 2.1 and later (it does not exist in .NET Framework), which means it cannot be used from Windows PowerShell 5.1 running on .NET Framework. See also the note on Process.StandardOutput Property about the deadlock that can occur when both standard output and standard error are redirected and read synchronously, and how to avoid it (read one of them asynchronously). ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, about_Error_Handling. On how a native command’s nonzero exit code sets
$?to$falseand is stored in$LASTEXITCODE, while generating no ErrorRecord and not landing in try/catch. ↩ ↩2 ↩3 -
Microsoft Learn, Trace-Command. On tracing parameter binding with
-Name ParameterBinding, output to the host with-PSHost, and specifying the trace target with-Expression. ↩ ↩2 ↩3 -
Microsoft Learn, Differences between Windows PowerShell 5.1 and PowerShell 7.x. On the change in PowerShell 7 whereby
$?no longer becomes$falsemerely because a native command wrote to stderr, becoming$falseonly on a nonzero exit code. ↩ ↩2 -
Microsoft Learn, about_Redirection. On the numbering scheme of PowerShell’s output streams, merging the error stream into the success stream with
2>&1, and the handling of native commands’ stderr output. ↩ ↩2 -
Microsoft Learn, What’s New in PowerShell 7.4. On the redirection operators now preserving native command output as a byte stream, with PowerShell no longer interpreting the content or adding formatting (a breaking change), and the resulting fact that stderr merged with 2>&1 is treated as string data. ↩ ↩2
-
Microsoft Learn, Start-Process. On not waiting for the new process to complete by default, waiting with
-Wait, obtaining the Process object with-PassThruand readingExitCode, redirecting to files with-RedirectStandardOutput/-RedirectStandardError, elevating with-Verb RunAs, and running as a different user with-Credential. ↩
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Where to Look When a PowerShell Script Is Slow — Arrays, Pipelines and Matching
The classic causes of slow PowerShell scripts, laid out. Why += on an array is O(n^2), the difference between the pipeline and foreach, t...
Stop Using Write-Host — PowerShell Output Streams and Log Design
How to choose between PowerShell's six output streams, the problems with Write-Host and where it genuinely belongs, why function return v...
Parallel Processing in PowerShell — Choosing Between ForEach-Object -Parallel and Jobs
A practical rundown of the differences between ForEach-Object -Parallel, Start-ThreadJob and Start-Job and when to use each, $using: and ...
Handling Credentials Safely in PowerShell — Banishing Plaintext Passwords from Your Scripts
A practical walkthrough of moving plaintext passwords out of PowerShell scripts and into safe storage: what SecureString really is and wh...
Parameter Design and Modularization for PowerShell Scripts — From a Script That Works to a Script You Can Hand Over
A step-by-step procedure for raising a PowerShell script to a quality you can hand to someone else. Covers the param block and [CmdletBin...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Frequently Asked Questions
Common questions about the topic of this article.
- When I call an EXE from PowerShell, the double quotes in my arguments disappear. Why?
- Because PowerShell parses the arguments itself before passing them on to the external program. In Windows PowerShell 5.1 the parsed arguments are reassembled into a single space-separated string, so embedded quotes get lost and empty-string arguments vanish. PowerShell 7.3 changed this behaviour so that embedded quotes and empty-string arguments are preserved. What's easy to miss is that in 5.1 even array splatting (& $exe @args) goes through that same reassembly. If you need to reliably pass a value containing quotes, or an empty string, splatting is not the answer. For a fixed string you can use the stop-parsing token --%, but it can't be used when variables are involved. The reliable route is to add the quotes yourself, following the Windows command-line rules, and pass the result as the ProcessStartInfo Arguments string (ArgumentList is a .NET Core 2.1+ API and isn't available in 5.1). There's an implementation example in the body of the article.
- If I use --% (the stop-parsing token), can I pass any argument safely?
- No — it has too many constraints to be a cure-all. Everything after --% is treated as a literal, except that environment variable references like %USERPROFILE% are still expanded, so any string containing % may be unintentionally substituted (and escaping with %% doesn't work either). PowerShell variables can't be expanded at all, the effect ends at the next newline or pipe character, and you can't write redirections. The moment you need to pass the value of a variable, --% is out, so consider ProcessStartInfo or Start-Process instead.
- Is it safe to pass a string received from outside into a batch file (.bat)?
- Avoid passing untrusted input to a batch file. On Windows, arguments to a batch file are handed to cmd.exe as a raw command-line string, which is why the official documentation explicitly states that untrusted input should be passed by some other means. Concatenating a file name or user input directly leaves room for command injection. It's safer to pass values via a temporary file or environment variables, or to replace the batch file with a PowerShell script.
- The output of an external command comes back as mojibake for non-ASCII text. What do I need to fix?
- Match [Console]::OutputEncoding — which PowerShell uses to decode an external command's standard output — to the encoding the command actually emits. For a tool that writes UTF-8, set [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 before calling it. Conversely, when you pipe a string from PowerShell into an external command, $OutputEncoding is what's used, so the trick is to think about the sending side and the receiving side separately. These settings are per session, so if you change them temporarily inside a script, change them back.
- How do I decide between Start-Process and direct invocation (&)?
- For the normal cases — you want to receive the output through the pipeline, or you only care about the exit code — direct invocation (& or simply the command name) is the default. Start-Process is for when you want to control how the process is launched: starting it in a separate window, running it as a different user, elevating to administrator (-Verb RunAs), or redirecting standard output to a file. Note that Start-Process does not wait for completion by default, so if you need the exit code you have to combine -Wait and -PassThru and read the ExitCode property.
Author Profile
Profile page for the article author.
Go Komura
Representative of KomuraSoft LLC
Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.
Public links