Calling External EXEs Correctly from PowerShell — The Pitfalls of Argument Quoting, Exit Codes, and Mojibake

· · 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 is Windows.12
  • In Windows mode, only cmd.exe, cscript.exe, wscript.exe and the .bat .cmd .js .vbs .wsf extensions 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 @argArray turns each element into an independent argument. With ProcessStartInfo.ArgumentList you 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 ParameterBinding to 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 Windows mode, 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’s ArgumentList (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 with 2>&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.

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.”

References

  1. 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

  2. Microsoft Learn, about_Preference_Variables. On $PSNativeCommandArgumentPassing being a preference variable with a platform-dependent default, and on $OutputEncoding determining the encoding used when PowerShell sends strings to other applications.  2 3 4 5

  3. 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

  4. Microsoft Learn, about_Error_Handling. On how a native command’s nonzero exit code sets $? to $false and is stored in $LASTEXITCODE, while generating no ErrorRecord and not landing in try/catch.  2 3

  5. 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

  6. Microsoft Learn, Differences between Windows PowerShell 5.1 and PowerShell 7.x. On the change in PowerShell 7 whereby $? no longer becomes $false merely because a native command wrote to stderr, becoming $false only on a nonzero exit code.  2

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

  8. 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

  9. Microsoft Learn, Start-Process. On not waiting for the new process to complete by default, waiting with -Wait, obtaining the Process object with -PassThru and reading ExitCode, redirecting to files with -RedirectStandardOutput / -RedirectStandardError, elevating with -Verb RunAs, and running as a different user with -Credential

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

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.

Back to the Blog