Updated on
C# can run PowerShell in two ways that work very differently. We either start the PowerShell executable as a child process and read its text output, or we host the PowerShell engine inside our own process with the PowerShell class and get objects back.
ProcessStartInfo takes the first route and needs nothing installed but PowerShell itself. Microsoft.PowerShell.SDK takes the second: it costs a package reference and gives us typed results, an error stream we can inspect, and a runspace we can lock down to a named set of commands.
How Do We Run a PowerShell Script From C# With ProcessStartInfo?
ProcessStartInfo describes a process we want to start, and Process starts it. To run PowerShell this way, we start the PowerShell executable itself and pass our script path or our command as arguments.
Two arguments do most of the work. -File takes the path to a .ps1 script, and -Command takes PowerShell source to run directly.
To read the output, we set RedirectStandardOutput, which sends the child’s output into a stream we can read. It only works when UseShellExecute is false, a setting that is easy to miss.
The executable name matters too. powershell.exe is Windows PowerShell 5.1, which exists only on Windows. pwsh is PowerShell 7, which runs on Windows, macOS and Linux, and it is the one to name in new code.
Everything here crosses a process boundary. The script runs with its own lifetime, and what comes back is text, so a date arrives as characters we have to parse rather than as a DateTime.
This is the same plumbing we use to execute any other CLI application from C#.
Let’s start by creating a simple echo.ps1 file with this content:
'I am invoked using ProcessStartInfoClass!'
The quotes matter: a quoted string on its own line is written to the output, while the same words without quotes are read as a command named I, which fails.
Our method returns the exit code and both streams together, so we declare a small record for them:
public record PowerShellResult(int ExitCode, string Output, string Error);
Next, we will create a ProcessStart class with a method to execute our script:
public async Task<PowerShellResult> ExecuteScriptAsync(string pathToScript)
{
var processStartInfo = new ProcessStartInfo("pwsh")
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true
};
processStartInfo.ArgumentList.Add("-ExecutionPolicy");
processStartInfo.ArgumentList.Add("Bypass");
processStartInfo.ArgumentList.Add("-File");
processStartInfo.ArgumentList.Add(pathToScript);
using var process = new Process { StartInfo = processStartInfo };
process.Start();
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
await Task.WhenAll(outputTask, errorTask);
await process.WaitForExitAsync();
return new PowerShellResult(process.ExitCode, outputTask.Result, errorTask.Result);
}
ProcessStartInfo and Process live in the System.Diagnostics namespace, so the file needs using System.Diagnostics;.
When we create a new ProcessStartInfo instance, we give it the file name of the executable, pwsh, and add each command-line argument to ArgumentList. ArgumentList quotes any argument that needs it, so a script path that contains a space needs no escaping. Thus, we set the ExecutionPolicy Bypass, which lets us run our scripts with no restrictions unless a Group Policy sets the execution policy. The switch only applies on Windows, and other platforms ignore it.
If a machine only has Windows PowerShell, we replace pwsh with powershell.exe, and the arguments stay the same.
Then, we invoke the process using ProcessStartInfo class. We do that by creating a new Process instance and assigning the created processStartInfo object to the StartInfo property.
When we start a process using Process.Start, the process has its own standard output and standard error streams where it will write output and error messages. By default, the calling process is not catching these streams. That is why we set RedirectStandardOutput and RedirectStandardError properties to true. We instruct the Process object to redirect the standard output and standard error streams so that we can capture and read them programmatically. Redirection requires UseShellExecute to be false. That is already the default on .NET, and we set it anyway so the dependency is visible.
We read both streams at the same time with ReadToEndAsync() and wait for both before WaitForExitAsync(). Reading them one after the other with a synchronous ReadToEnd() can deadlock: Microsoft’s documentation for RedirectStandardOutput warns that synchronous reads create a dependency between the caller and the child process, and that “These dependencies can cause deadlock conditions.” A script that fills the error stream while we wait on the output stream would leave both processes waiting.
Lastly, we return the exit code with the output and error text and safely dispose of the process by including using statement.
With -File, a script that stops on an error, such as a throw, exits with 1, and an explicit exit 42 comes back as 42. A cmdlet that fails without stopping the script only writes to the error stream, and the exit code stays 0, so we check Error as well as ExitCode. It helps to know how a console application sets its exit code, because that number is all the calling process gets from the child besides its text.
Now, let’s modify our code and see how simple we can execute PowerShell commands directly instead of running a script. All we need to do is change the setup of the ProcessStartInfo class slightly:
public async Task<PowerShellResult> ExecuteCommandAsync(string command)
{
var processStartInfo = new ProcessStartInfo("pwsh")
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true
};
processStartInfo.ArgumentList.Add("-Command");
processStartInfo.ArgumentList.Add(command);
using var process = new Process { StartInfo = processStartInfo };
process.Start();
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
await Task.WhenAll(outputTask, errorTask);
await process.WaitForExitAsync();
return new PowerShellResult(process.ExitCode, outputTask.Result, errorTask.Result);
}
We configure it in a similar manner except now the ArgumentList holds -Command and the command variable that we will specify when invoking the function:
var processStart = new ProcessStart();
var commandResult = await processStart.ExecuteCommandAsync("echo 'I am invoked using echo command!'");
Console.WriteLine(commandResult.Output); // I am invoked using echo command!
ArgumentList keeps the command in a single argument, but the text is still PowerShell source that pwsh runs as written, so we never build it from user input.
To sum up, when we use the ProcessStartInfo class to execute a PowerShell script, we actually start a pwsh process on our local system by calling process.Start and passing it all the necessary arguments to execute our commands.
How Do We Run PowerShell In-Process With the PowerShell Class?
The PowerShell class runs PowerShell inside our own process. It lives in the System.Management.Automation namespace, which we get by referencing the Microsoft.PowerShell.SDK package, and it hosts the PowerShell engine as a library instead of starting an executable.
PowerShell.Create() gives us an instance. AddScript() takes PowerShell source, AddCommand() takes one command by name, AddArgument() and AddParameter() feed it values, and Invoke() runs the pipeline we have built.
Invoke() returns a collection of PSObject, so Get-Date hands us a real date to work with rather than a line of text to parse.
Failures are quiet by default. A cmdlet that fails without stopping does not throw: it fills ps.Streams.Error and sets ps.HadErrors, and code that checks neither carries on as though the command worked. A name AddCommand() cannot resolve, or script text that cannot parse, throws.
The instance holds a runspace, which is why every example here wraps it in using. Disposing it releases the session state the engine allocated.
Let’s start by referencing the Microsoft.PowerShell.SDK package, which gives us access to the PowerShell class in the System.Management.Automation namespace:
using Microsoft.PowerShell; using System.Management.Automation; using System.Management.Automation.Runspaces;
This time we’ll run the same echo.ps1 script in our own process, using the PowerShell class. We put this method and the next one in a class named PowerShellClass:
public bool ExecuteScript(string pathToScript)
{
var iss = InitialSessionState.CreateDefault();
iss.ExecutionPolicy = ExecutionPolicy.Bypass;
using var ps = PowerShell.Create(iss);
ps.AddCommand(pathToScript).Invoke();
return !ps.HadErrors;
}
We pass the path to our script to AddCommand(), which runs a script file the same way it runs a cmdlet. AddScript() is the wrong tool for a path, because it takes PowerShell source: an absolute path that contains a space is read as a command name that ends at the first space, so nothing runs and Streams.Error holds a CommandNotFoundException.
The -ExecutionPolicy switch from the first section has no equivalent on PowerShell.Create(). When no execution policy is set in any scope, a Windows client falls back to Restricted, and loading our script throws a PSSecurityException. So we create the runspace from an InitialSessionState whose ExecutionPolicy is Bypass. That value is applied as the process-scope policy, so it holds for every runspace our application opens afterwards, not just this one.
Let’s execute a simple command directly this time, we’ll use Get-Date to get the current date time:
public string ExecuteCommand(string command)
{
using var ps = PowerShell.Create();
ps.AddCommand(command);
var results = ps.Invoke();
if (ps.HadErrors)
{
throw new InvalidOperationException(ps.Streams.Error[0].ToString());
}
return results.FirstOrDefault()?.ToString() ?? string.Empty;
}
Notice that we instantiate the PowerShell class in the using statement to dispose of it properly after. It is the same pattern we use to manage IDisposable objects in C# in general. We create a PowerShell instance, add a command and invoke it.
To return the result as text, we take the first object with FirstOrDefault() and convert it to a string, so a command with no output gives us an empty string instead of an exception. If a cmdlet fails without stopping, HadErrors is true and we throw with the first error record instead of returning nothing:
var powerShellClass = new PowerShellClass();
Console.WriteLine(powerShellClass.ExecuteCommand("Get-Date")); // 10/4/2026 12:43:57 PM
As we can see, as long as we know the command in PowerShell, we can easily transform it and use it in C#. Let us try one more example where we start a process which is the notepad. notepad is a Windows example, and on Linux we would start a GUI program such as gedit instead:
using var ps = PowerShell.Create();
ps.AddCommand("Start-Process").AddArgument("notepad");
ps.Invoke();
Notice that the PowerShell class allows method chaining. We use the AddArgument() method after the AddCommand() method.
By default, the PowerShell class uses the default runspace to run the script and execute commands. The default runspace represents the default execution environment for our commands and scripts.
Let’s see how to use a custom runspace.
Custom Runspace
Alongside security reasons, we might use a custom runspace for performance gains. This is because we limit the runspace to a specified subset of commands that we actually need. That means that a runspace loads only the commands that we specify.
First, we need to create an InitialSessionState object. This class allows us to define a set of elements that should be present when a SessionState is created. The SessionStateVariableEntry class allows us to create new session state variables.
A session state variable is a variable that persists for the entire session, similar to a global variable. We use it to store the list of allowed commands in the session. The variable itself restricts nothing: the restriction comes from the commands we add to the session state next. SessionState refers to the current configuration of a PowerShell session or module.
Basically, the PowerShell session refers to the time from starting a host application that opens PowerShell runspace up until it closes the runspace. We will apply all our restrictions to this InitialSessionState object:
InitialSessionState iss = InitialSessionState.Create();
var entry = new SessionStateVariableEntry(
"AllowedCommands", new[] { "Get-Date" }, "List of allowed commands");
iss.Variables.Add(entry);
Here we list a single command, the Get-Date command.
Next, we add the Get-Date cmdlet to the InitialSessionState object. InitialSessionState.Create() starts empty, so this entry is the only cmdlet our runspace will know:
var getDateCmdlet = new SessionStateCmdletEntry("Get-Date",
typeof(GetDateCommand), "");
iss.Commands.Add(getDateCmdlet);
Now we can create and run our runspace to use the configured InitialSessionState. We put these steps in the constructor of a PSCustomRunspace class, which keeps the open runspace in a field and disposes it when we are done:
public class PSCustomRunspace : IDisposable
{
private readonly Runspace _rs;
public PSCustomRunspace()
{
InitialSessionState iss = InitialSessionState.Create();
var entry = new SessionStateVariableEntry(
"AllowedCommands", new[] { "Get-Date" }, "List of allowed commands");
iss.Variables.Add(entry);
var getDateCmdlet = new SessionStateCmdletEntry("Get-Date",
typeof(GetDateCommand), "");
iss.Commands.Add(getDateCmdlet);
_rs = RunspaceFactory.CreateRunspace(iss);
_rs.Open();
}
public void Dispose()
{
_rs.Dispose();
}
}
GetDateCommand lives in the Microsoft.PowerShell.Commands namespace, so the file also needs using Microsoft.PowerShell.Commands;.
All that remains now is to add a method to PSCustomRunspace that applies our custom runspace to the PowerShell instance, and to try it out:
public string ExecuteCommand(string command)
{
using var ps = PowerShell.Create();
ps.Runspace = _rs;
ps.AddCommand(command);
var results = ps.Invoke();
if (ps.HadErrors)
{
throw new InvalidOperationException(ps.Streams.Error[0].ToString());
}
return results.FirstOrDefault()?.ToString() ?? string.Empty;
}
When we call it with Get-Date, we get the date and time back:
using var customRunspace = new PSCustomRunspace();
Console.WriteLine(customRunspace.ExecuteCommand("Get-Date")); // 10/4/2026 12:43:58 PM
As expected, we got the time and date printed on the console.
Lastly, let’s try to run the Start-Process command instead, which is not in our custom runspace. To better understand what is happening, we will add a method to PSCustomRunspace that returns true or false depending on the execution result of our command:
public bool StartProcess(string processName)
{
try
{
using var ps = PowerShell.Create();
ps.Runspace = _rs;
ps.AddCommand("Start-Process").AddArgument(processName);
ps.Invoke();
return true;
}
catch (Exception)
{
return false;
}
}
Next, let’s print the result to the console:
var processStarted = customRunspace.StartProcess("notepad");
if (!processStarted)
{
Console.WriteLine("This is a custom runspace that can only run Get-Date command");
}
Our custom runspace does not recognize the command, so Invoke() throws a CommandNotFoundException and our method returns false. This shows we have successfully created a restricted runspace.
Catching the base Exception keeps the example short. In real code we would catch the specific type, and the guide to handling exceptions in C# shows how to place specific catch blocks before general ones.
Which Way of Running PowerShell From C# Should We Choose?
Both techniques run PowerShell, so the choice comes down to what we want back and what we can depend on.
Reach for ProcessStartInfo when PowerShell is an external tool. It needs no NuGet package, it uses whatever PowerShell the machine already has, and a script that hangs or crashes takes down a child process rather than ours.
Reach for the PowerShell class when PowerShell is part of the application. It returns objects instead of text, it reports failures through HadErrors and Streams.Error instead of through a parsed error string, and a custom runspace limits which commands the code can run at all.
Both have a real cost. The SDK is a large dependency carrying its own PowerShell engine version, while the process route pays a full process start on every call and hands back text we must parse.
The security question splits the same way. User input becomes a command line in the first case and a script in the second, and neither is safe by default.
Here are the two approaches side by side, question by question:
| Question | ProcessStartInfo + Process | PowerShell class (Microsoft.PowerShell.SDK) |
|---|---|---|
| Where does the code run? | In a separate PowerShell process | In our process, inside a runspace |
| What do we have to install? | Nothing: the machine's own PowerShell | The Microsoft.PowerShell.SDK package |
| Which PowerShell runs? | Whichever executable we name in FileName | The engine version the package carries |
| What comes back? | Text, from StandardOutput and StandardError | PSObject instances from Invoke() |
| How does a failure surface? | Text on the error stream; ExitCode can still be 0 | ps.HadErrors and ps.Streams.Error, or an exception from Invoke() |
| Can we restrict the commands? | Yes, by starting pwsh with -ConfigurationFile and a session configuration file | Yes, through InitialSessionState and a custom runspace |
| Execution policy | The -ExecutionPolicy switch, on Windows only | InitialSessionState.ExecutionPolicy, which sets it for the whole process |
| What happens on a hang or a crash? | It stays in the child process | It is our process |
| Reach for it when | PowerShell is one more command-line tool we call | PowerShell is part of the application |
The two techniques differ in one structural way, and everything else in the table follows from it:

Conclusion
In this article, we reviewed two ways to execute a PowerShell script in C#. We first covered using the ProcessStartInfo class. Next, we focused on the PowerShell class from the Microsoft.PowerShell.SDK package and the difference between this class and the ProcessStartInfo class. Lastly, we took a step further by looking at a custom runspace where we can restrict which commands can be executed.
Tested with .NET 10.0.10 and Microsoft.PowerShell.SDK 7.6.6.

Great explanation – thanks. However, I cannot overcome the System.Management.Automation.PSSecurityException: error