How to Run PowerShell from C# (CSharp) and Receive the Results as Objects
· Updated: · Go Komura · C#, PowerShell, Windows, .NET, Automation, Legacy Asset Reuse
Revision history (1 updates, last updated Sep 1, 2026)
A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.
- Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614659)
- First published
Cite this article(DOI: 10.5281/zenodo.21614658)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). How to Run PowerShell from C# (CSharp) and Receive the Results as Objects. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614658 https://comcomponent.com/en/blog/2026/06/08/001-csharp-run-powershell-receive-objects/
- DOI (latest version)
- 10.5281/zenodo.21614658
- DOI (this version)
- 10.5281/zenodo.22220516
Situations where you want to run PowerShell from C# come up all the time in business applications and internal tools. For example:
- Retrieving the list of Windows services
- Inspecting processes and event logs
- Calling existing PowerShell scripts from a C# application
- Running PowerShell commands from a small GUI tool for administrators
- Gradually absorbing existing PowerShell automation assets into a .NET application
If you just need to run something, launching powershell.exe or pwsh.exe as an external process and reading standard output as a string does work. But that approach loses the very thing that makes PowerShell good: the object pipeline.
PowerShell results are not really just text. The output of Get-Process is process objects; the output of Get-Service is service objects. If the C# side can receive that structure intact, string parsing becomes unnecessary and the whole process becomes considerably safer.
This article walks through the basics of running PowerShell from C# and receiving the results as PSObject.
The code in this article is published on GitHub as a complete buildable, runnable sample set (a library with the execution wrapper and conversion logic, a console demo that demonstrates each section of the article, and unit tests verifying PSObject handling and error processing).
csharp-run-powershell-receive-objects - komurasoft-blog-samples (GitHub)
1. Use the PowerShell SDK, Not an External Process
There are broadly two ways to invoke PowerShell from C#.
| Approach | Characteristics | Suited for |
|---|---|---|
Launch powershell.exe / pwsh.exe via ProcessStartInfo |
Read stdout and stderr as strings | Simple execution of existing batches, jobs that just leave logs |
Use System.Management.Automation.PowerShell |
Receive results as PSObject |
Processing results in C#, admin tools, business applications |
This article covers the latter. With System.Management.Automation.PowerShell, you can assemble and run a PowerShell pipeline from C# code. The key point is that the return value is not a string — it is fundamentally a Collection<PSObject>.
In other words, the mental model is:
Run a PowerShell command
↓
Receive the results as a collection of PSObject
↓
Extract values via BaseObject or Properties
↓
Convert to C# DTOs / records / classes as needed
The point is to treat PowerShell output as objects from the start, rather than decomposing it as strings.
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (26 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
2. Environment Assumptions
This article uses a .NET 8 console application as the example. The PowerShell SDK targets different versions of .NET depending on its own version, so choose one matching your project’s target framework.
As of June 2026, thinking about it like this is a good starting point.
| C# application target | Example PowerShell SDK | Notes |
|---|---|---|
| .NET 8 | Microsoft.PowerShell.SDK 7.4 series |
Easy to use in .NET 8 apps |
| .NET 10 | Microsoft.PowerShell.SDK 7.6 series |
Candidate when using a newer PowerShell SDK |
| .NET Framework | Microsoft.PowerShell.5.1.ReferenceAssemblies |
For Windows PowerShell 5.1; for new development, confirm your requirements |
Here, as a .NET 8 example, we use Microsoft.PowerShell.SDK 7.4.16.
dotnet new console -n PowerShellObjectSample
cd PowerShellObjectSample
dotnet add package Microsoft.PowerShell.SDK --version 7.4.16
The .csproj then looks something like this:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.PowerShell.SDK" Version="7.4.16" />
</ItemGroup>
</Project>
Pinning the version is a good idea. The PowerShell SDK is convenient, but it is affected by the application’s runtime environment, the target .NET, and the compatibility of the PowerShell modules involved. In a business application, it is safer to state the version you actually tested than to take whatever latest version happened to work on a development machine.
What is different when you target .NET Framework
Sometimes the code you maintain is a .NET Framework Windows Forms or WPF application, and you want to call PowerShell from there. The code examples in this article assume current .NET, but the way you write the C# side is almost identical. The differences are these.
| Aspect | Current .NET + Microsoft.PowerShell.SDK |
.NET Framework + Microsoft.PowerShell.5.1.ReferenceAssemblies |
|---|---|---|
| PowerShell that actually runs | PowerShell 7 series, included in the package | Windows PowerShell 5.1, shipped with Windows |
| Role of the NuGet package | Contains the implementation | Reference assemblies only; the runtime assemblies come from the OS |
| Available syntax and cmdlets | What PowerShell 7 supports | What 5.1 supports; ForEach-Object -Parallel, ??, and the ternary operator are not available |
| Where modules are searched for | PowerShell 7’s $env:PSModulePath |
Windows PowerShell’s $env:PSModulePath |
| Asynchronous execution | InvokeAsync is available |
BeginInvoke / EndInvoke, or wrap Invoke() in Task.Run |
| How you write the C# side | PowerShell.Create(), AddCommand, AddParameter, Invoke(), Collection<PSObject> |
The same |
| Distribution size | Large, because the whole SDK ships with the app | Small, because it uses what the OS provides |
The .csproj then looks something like this:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.PowerShell.5.1.ReferenceAssemblies" Version="1.0.0" />
</ItemGroup>
</Project>
In practice, the difference that matters most is that what actually runs is Windows PowerShell 5.1. When you absorb an existing PowerShell script, a script written for PowerShell 7 can be a syntax error under 5.1. Conversely, sometimes you choose this route precisely because you need an old module that only runs under Windows PowerShell 5.1.
Note that the code in sections 3 through 12 of this article does not use InvokeAsync, so it applies to .NET Framework unchanged. Only the asynchronous part is revisited in section 13.
3. Minimal Code: Run PowerShell and Receive PSObject
First, let’s retrieve the current C# application’s own process via PowerShell.
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Management.Automation;
int currentProcessId = Environment.ProcessId;
using PowerShell ps = PowerShell.Create();
Collection<PSObject> results = ps
.AddCommand("Get-Process")
.AddParameter("Id", currentProcessId)
.Invoke();
foreach (PSObject item in results)
{
Console.WriteLine($"PSObject type: {item.GetType().FullName}");
Console.WriteLine($"BaseObject type: {item.BaseObject.GetType().FullName}");
if (item.BaseObject is Process process)
{
Console.WriteLine($"Id: {process.Id}");
Console.WriteLine($"Name: {process.ProcessName}");
Console.WriteLine($"Memory: {process.WorkingSet64:N0} bytes");
}
}
There are three things to take away here: PowerShell.Create() creates the PowerShell execution object; AddCommand("Get-Process") and AddParameter("Id", currentProcessId) assemble the command and its parameters; and the return value of Invoke() is a Collection<PSObject>.
PSObject is a wrapper around the values PowerShell outputs. To see the original .NET object inside, look at BaseObject. In this example, the content of the Get-Process result can be extracted as a System.Diagnostics.Process.
4. Choosing Between BaseObject and Properties
When handling PowerShell results in C#, the first thing you will hesitate over is these two:
item.BaseObject
item.Properties["Name"]?.Value
Here is a guideline for which to use:
| Extraction method | When to use it |
|---|---|
BaseObject |
When you want to use the original .NET object PowerShell returned, as is |
Properties["..."] |
When you want to extract columns created with Select-Object or [pscustomobject] |
When you run a command like Get-Process directly, BaseObject may contain the original .NET object. On the other hand, when the PowerShell side shapes columns with Select-Object, the results usually come back as PowerShell custom objects. In that case, retrieving values by column name from Properties is the natural approach.
5. Reading Select-Object Results in C#
In practice, you rarely need every property PowerShell returns. To pass only the necessary columns to the C# side, use Select-Object in the PowerShell pipeline.
using System.Collections.ObjectModel;
using System.Globalization;
using System.Management.Automation;
using PowerShell ps = PowerShell.Create();
Collection<PSObject> rows = ps
.AddCommand("Get-Process")
.AddCommand("Sort-Object")
.AddParameter("Property", "CPU")
.AddParameter("Descending", true)
.AddCommand("Select-Object")
.AddParameter("First", 10)
.AddParameter("Property", new[] { "Name", "Id", "CPU", "WorkingSet" })
.Invoke();
foreach (PSObject row in rows)
{
string name = Convert.ToString(row.Properties["Name"]?.Value, CultureInfo.InvariantCulture) ?? "";
int id = Convert.ToInt32(row.Properties["Id"]?.Value, CultureInfo.InvariantCulture);
double? cpu = row.Properties["CPU"]?.Value is null
? null
: Convert.ToDouble(row.Properties["CPU"]!.Value, CultureInfo.InvariantCulture);
long workingSet = Convert.ToInt64(row.Properties["WorkingSet"]?.Value, CultureInfo.InvariantCulture);
Console.WriteLine($"{id}: {name}, CPU={cpu}, WorkingSet={workingSet:N0}");
}
This code corresponds to the following pipeline in PowerShell:
Get-Process |
Sort-Object -Property CPU -Descending |
Select-Object -First 10 -Property Name, Id, CPU, WorkingSet
From the C# side, calling AddCommand repeatedly builds the PowerShell pipeline:
.AddCommand("Get-Process")
.AddCommand("Sort-Object")
.AddCommand("Select-Object")
Written this way, the output of each command is passed to the next.
After narrowing columns with Select-Object, you extract values by column name, as in row.Properties["Name"]?.Value.
6. Converting to a C# record
If you pass PSObject around the whole application, downstream code becomes too dependent on PowerShell. For display or business logic, converting to C#-side types makes things easier to work with.
For example, convert process information into this record:
public sealed record ProcessSummary(
string Name,
int Id,
double? Cpu,
long WorkingSet);
Splitting out the conversion logic like this keeps things tidy:
using System.Globalization;
using System.Management.Automation;
static ProcessSummary ToProcessSummary(PSObject row)
{
string name = GetString(row, "Name");
int id = GetInt32(row, "Id");
double? cpu = GetNullableDouble(row, "CPU");
long workingSet = GetInt64(row, "WorkingSet");
return new ProcessSummary(name, id, cpu, workingSet);
}
static string GetString(PSObject row, string propertyName)
{
return Convert.ToString(row.Properties[propertyName]?.Value, CultureInfo.InvariantCulture) ?? "";
}
static int GetInt32(PSObject row, string propertyName)
{
return Convert.ToInt32(row.Properties[propertyName]?.Value, CultureInfo.InvariantCulture);
}
static long GetInt64(PSObject row, string propertyName)
{
return Convert.ToInt64(row.Properties[propertyName]?.Value, CultureInfo.InvariantCulture);
}
static double? GetNullableDouble(PSObject row, string propertyName)
{
object? value = row.Properties[propertyName]?.Value;
return value is null ? null : Convert.ToDouble(value, CultureInfo.InvariantCulture);
}
The caller then looks like this:
List<ProcessSummary> processes = rows
.Select(ToProcessSummary)
.ToList();
foreach (ProcessSummary process in processes)
{
Console.WriteLine($"{process.Id}: {process.Name}");
}
Handle PSObject at the boundary with PowerShell, and convert to ordinary C# types like ProcessSummary inside the application.
With this separation in place, changing the PowerShell command later keeps the blast radius small.
7. Returning PSCustomObject Makes the C# Side Easier
When you want the PowerShell side to return several values together, [pscustomobject] is convenient.
using System.Collections.ObjectModel;
using System.Management.Automation;
string script = @"
[pscustomobject]@{
MachineName = [System.Environment]::MachineName
PowerShellVersion = $PSVersionTable.PSVersion.ToString()
CurrentDirectory = (Get-Location).Path
}
";
using PowerShell ps = PowerShell.Create();
Collection<PSObject> rows = ps
.AddScript(script, useLocalScope: true)
.Invoke();
foreach (PSObject row in rows)
{
Console.WriteLine($"MachineName: {row.Properties["MachineName"]?.Value}");
Console.WriteLine($"PowerShell: {row.Properties["PowerShellVersion"]?.Value}");
Console.WriteLine($"Directory: {row.Properties["CurrentDirectory"]?.Value}");
}
If a PowerShell script returns a [pscustomobject] at the end, the C# side can extract values by name from Properties. This is considerably safer than returning a complicated string and splitting it on the C# side.
An example to avoid is output like this:
"$MachineName,$PowerShellVersion,$CurrentDirectory"
This approach looks easy, but it breaks the moment a value contains a comma or a newline.
Have PowerShell return objects, and read them as properties in C#. With this shape, adding columns later is easy to accommodate.
8. Never Embed User Input Directly into AddScript
Even when using the PowerShell SDK, assembling scripts as strings is dangerous. For example, code like this is best avoided:
// An example to avoid
string userInputPath = GetPathFromUser();
string script = $"Get-ChildItem -Path '{userInputPath}'";
using PowerShell ps = PowerShell.Create();
ps.AddScript(script).Invoke();
Written this way, there is room for user input to be interpreted as PowerShell code. When passing values to a PowerShell command, use AddCommand and AddParameter wherever you can.
string userInputPath = GetPathFromUser();
using PowerShell ps = PowerShell.Create();
Collection<PSObject> files = ps
.AddCommand("Get-ChildItem")
.AddParameter("Path", userInputPath)
.AddParameter("File", true)
.Invoke();
A value passed through AddParameter is treated as a parameter value, not concatenated into a PowerShell code string.
In practice, this is a safe way to divide the two:
| Style | When to use |
|---|---|
AddCommand / AddParameter |
When you want to assemble commands safely from the C# side |
AddScript |
When running fixed short scripts or loading existing scripts |
AddScript with string concatenation |
Avoid as a rule; if used, validate and escape inputs with great care |
Embedding PowerShell in C# gives your application powerful capabilities. That is convenient, but there is one line you must not cross: never turn user input directly into a script.
9. Format-Table Is for the Final Screen Display — Don’t Use It Before Passing to C#
If you want to receive PowerShell results as objects in C#, you essentially do not use Format-Table or Format-List.
For example, PowerShell like this is handy for a human reading the screen:
Get-Service | Format-Table Name, Status
But if you run Format-Table before the C# side receives the results, what you get is no longer service objects — it is display formatting information. When you want to work with the results in C#, use Select-Object.
Get-Service | Select-Object Name, Status
Written from C#, that becomes:
using PowerShell ps = PowerShell.Create();
Collection<PSObject> services = ps
.AddCommand("Get-Service")
.AddCommand("Select-Object")
.AddParameter("Property", new[] { "Name", "Status" })
.Invoke();
The idea is simple.
Just making it readable on screen → Format-Table / Format-List
Feeding downstream processing in C# → Select-Object / PSCustomObject
This holds for PowerShell on its own as well, but it matters especially when C# is involved.
10. Receiving Errors
In PowerShell, output and errors are separate streams. If you look only at the return value of Invoke(), you can miss errors. The basic shape is this:
using System.Management.Automation;
using PowerShell ps = PowerShell.Create();
Collection<PSObject> output = ps
.AddCommand("Get-Item")
.AddParameter("Path", @"C:\no-such-file.txt")
.Invoke();
if (ps.HadErrors)
{
foreach (ErrorRecord error in ps.Streams.Error)
{
Console.WriteLine($"Error: {error.Exception.Message}");
Console.WriteLine($"Category: {error.CategoryInfo.Category}");
Console.WriteLine($"Target: {error.TargetObject}");
}
}
PowerShell cmdlets have errors that stop processing and errors that let it continue. If you want to treat them as exceptions on the C# side, one option is to pass Stop for ErrorAction.
using System.Management.Automation;
try
{
using PowerShell ps = PowerShell.Create();
Collection<PSObject> output = ps
.AddCommand("Get-Item")
.AddParameter("Path", @"C:\no-such-file.txt")
.AddParameter("ErrorAction", "Stop")
.Invoke();
}
catch (RuntimeException ex)
{
Console.WriteLine($"PowerShell failed: {ex.Message}");
}
Which one is better depends on the nature of the application. An admin tool that wants to show the list even when part of it fails is better served by collecting the error stream and displaying it, while a job that should stop entirely on failure is clearer with ErrorAction Stop and exception handling.
11. Build a Small Execution Wrapper
In an application that calls PowerShell repeatedly, writing the same error handling every time gets messy. A simple wrapper helps.
using System.Management.Automation;
public sealed record PowerShellRunResult(
IReadOnlyList<PSObject> Output,
IReadOnlyList<ErrorRecord> Errors);
public static class PowerShellRunner
{
public static PowerShellRunResult Run(Action<PowerShell> build)
{
using PowerShell ps = PowerShell.Create();
build(ps);
List<PSObject> output;
try
{
output = ps.Invoke().ToList();
}
catch (RuntimeException ex)
{
throw new InvalidOperationException($"PowerShell execution failed: {ex.Message}", ex);
}
return new PowerShellRunResult(
Output: output,
Errors: ps.Streams.Error.ToList());
}
}
The caller can then focus purely on assembling the command.
PowerShellRunResult result = PowerShellRunner.Run(ps => ps
.AddCommand("Get-Service")
.AddCommand("Where-Object")
.AddParameter("Property", "Status")
.AddParameter("EQ", "Running")
.AddCommand("Select-Object")
.AddParameter("First", 10)
.AddParameter("Property", new[] { "Name", "DisplayName", "Status" }));
foreach (PSObject row in result.Output)
{
Console.WriteLine($"{row.Properties["Name"]?.Value}: {row.Properties["Status"]?.Value}");
}
foreach (ErrorRecord error in result.Errors)
{
Console.Error.WriteLine(error.Exception.Message);
}
That said, assembling PowerShell-specific condition syntax from C#, as with Where-Object in this example, can get a little hard to read. Simple commands and parameters are fine with AddCommand / AddParameter, but complex filtering or aggregation is sometimes more readable as a fixed PowerShell script. Even then, the policy of never concatenating external input directly into a script string does not change.
12. Shape Complex Work into Objects on the PowerShell Side
When you combine C# and PowerShell, deciding which side owns what makes the design easier.
Here is the split I recommend:
| Owner | Responsibilities |
|---|---|
| PowerShell | Operations close to Windows and its modules, existing scripts, running admin commands |
| C# | UI, input validation, type conversion, business logic, persistence, API integration |
On the PowerShell side, shape the final output into a [pscustomobject].
Get-Service |
Where-Object Status -eq 'Running' |
Select-Object Name, DisplayName, Status
Or build the [pscustomobject] explicitly:
$services = Get-Service | Where-Object Status -eq 'Running'
[pscustomobject]@{
Count = $services.Count
Names = $services.Name
}
On the C# side, read the properties of the returned PSObject and convert them into your own application types.
With this shape, PowerShell implementation details do not leak too far into the C# side.
13. Practical Caveats
When running PowerShell from C#, code that merely runs is not enough. In practice, checking the following points early keeps you safe.
The executing user’s privileges
PowerShell runs with the privileges of the user running the C# application. Commands that require administrator privileges will fail when run as a normal user. Service operations, event logs, certificates, the registry, Hyper-V, and Microsoft 365 administration modules all require thinking through the privilege boundaries.
32-bit / 64-bit differences
On Windows, what a 32-bit process and a 64-bit process can see in the registry and in available modules can differ. If you are building a Windows administration tool, assuming x64 execution as the baseline will reduce trouble.
Whether the modules exist in the runtime environment
Adding the PowerShell SDK to a C# application does not automatically bring along every PowerShell module. For example, if you use a product-specific management module or an internal module, you must confirm that the module exists in the runtime environment and which path it will be loaded from.
In GUI apps, don’t block the UI thread
When running PowerShell from WinForms or WPF, executing heavy work directly on the UI thread freezes the window. In that case, design it to run as background work and update the UI on completion.
The PowerShell SDK has an asynchronous execution API, and using it is the most straightforward route. PowerShell.InvokeAsync returns Task<PSDataCollection<PSObject>>, so you can await it directly.
First, factor the part that calls PowerShell out into a method that never touches the UI.
using System.Management.Automation;
// The two methods below are meant to live inside a window or form class.
//
// Always take a cancellationToken. If the PowerShell instance stays hidden in a
// local variable of this method, the caller has no way to call Stop().
// When a command hangs, or the user closes the window, the awaiting handler
// keeps waiting with the button disabled
private static async Task<IReadOnlyList<PSObject>> GetRunningServicesAsync(
CancellationToken cancellationToken)
{
// Not a using. We have to wait for the stop to complete before disposing (see below)
PowerShell ps = PowerShell.Create();
Task? stopping = null;
try
{
// `-EQ` is a switch that takes no value; the value to compare goes to `-Value`
// (the comparison syntax is `-Property <String> -EQ -Value <Object>`).
// Writing AddParameter("EQ", "Running") fails during parameter binding
ps.AddCommand("Get-Service")
.AddCommand("Where-Object")
.AddParameter("Property", "Status")
.AddParameter("EQ")
.AddParameter("Value", "Running")
.AddCommand("Select-Object")
.AddParameter("Property", new[] { "Name", "DisplayName", "Status" });
// Do not register before starting. If the token handed to Register is
// already canceled, Register runs the callback right there. BeginStop
// reaches a pipeline that has not started yet and does nothing, then
// InvokeAsync starts the command and the stop signal is already spent —
// which is exactly how you end up with a command that keeps running
// after the window is closed.
// Bail out first, then start, then attach the way to stop it
cancellationToken.ThrowIfCancellationRequested();
Task<PSDataCollection<PSObject>> running = ps.InvokeAsync();
// Stop the pipeline if cancellation is requested. Stop() does not return
// until the pipeline has stopped, and cancellation can come from the UI
// thread, so use the asynchronous version. Keep the stop itself as a Task
// and wait for it in the finally below
using (cancellationToken.Register(
state => Volatile.Write(ref stopping, StopAsync((PowerShell)state!)), ps))
{
PSDataCollection<PSObject> output = await running;
return output.ToList();
}
}
finally
{
// If a stop was requested, wait for it to complete before disposing.
// await running returning and the stop finishing are separate events, so
// disposing here without waiting lets a later EndStop touch an already
// disposed PowerShell. And that runs on a thread pool callback, where
// there is nowhere to catch the exception it throws (the process goes down)
Task? pending = Volatile.Read(ref stopping);
if (pending is not null)
{
try { await pending; }
catch { /* do not let a failure in the stop path mask the real result or exception */ }
}
ps.Dispose();
}
}
// Wrap BeginStop / EndStop in a Task. Calling EndStop directly inside the
// callback lets its exception escape onto the thread pool
private static Task StopAsync(PowerShell ps) =>
Task.Factory.FromAsync(ps.BeginStop, ps.EndStop, null);
The caller is an event handler on the window or form. async void is acceptable here; event handlers are one of the few places where async void is allowed.
// Meant to live in a WPF window class.
// For WinForms, read RoutedEventArgs as EventArgs,
// IsEnabled as Enabled, and ItemsSource as DataSource.
// For canceling a run in progress. Used both by the cancel button and on window close
private CancellationTokenSource? _running;
private async void RunButton_Click(object sender, RoutedEventArgs e)
{
RunButton.IsEnabled = false;
using var cts = new CancellationTokenSource();
_running = cts;
try
{
IReadOnlyList<PSObject> services = await GetRunningServicesAsync(cts.Token);
ResultList.ItemsSource = services
.Select(row => row.Properties["Name"]?.Value?.ToString() ?? "")
.ToList();
}
catch (PipelineStoppedException)
{
// The normal path for the cancel button or a window close. Show nothing
}
catch (RuntimeException ex)
{
MessageBox.Show($"PowerShell failed: {ex.Message}");
}
finally
{
_running = null;
RunButton.IsEnabled = true;
}
}
private void CancelButton_Click(object sender, RoutedEventArgs e) => _running?.Cancel();
protected override void OnClosed(EventArgs e)
{
_running?.Cancel(); // Never leave a pipeline running after the window is closed
base.OnClosed(e);
}
Four things are worth holding on to.
- Where you come back from
await, you are on the UI thread in both WinForms and WPF. The synchronization context puts you back there, so you do not needInvokeorDispatcher.Invoke. - Disable the button while the work runs. If you do not, the user can start the same work twice.
- Catch exceptions as
RuntimeException. If you have not setErrorActiontoStop, check the error stream as well (section 10). - Always provide a way to stop. If the
PowerShellinstance stays hidden in a local variable of the method, the caller cannot callStop(). When a command hangs or the user closes the window, you get a screen that waits forever with the button disabled.
When you stop it, the InvokeAsync you are awaiting throws PipelineStoppedException. As shown above, catch it as a normal path rather than a failure. PowerShell offers the synchronous Stop(), the asynchronous BeginStop / EndStop, and StopAsync (see the references at the end). Because the stop can come from the UI thread, choose an asynchronous version that does not make it wait.
And if you stopped it asynchronously, wait for that stop to complete before disposing. await running returning and the stop started by BeginStop finishing happen separately. If you leave it as using PowerShell ps and fall out of the block, EndStop runs after Dispose and touches an already disposed instance. Worse, that happens on a thread pool callback, with no try anywhere to catch the exception it throws — the app dies for no apparent reason just as it is shutting down. That is why the code above drops using in favor of try / finally and holds the stop as a Task before awaiting it. Wrapping it with Task.Factory.FromAsync puts the EndStop exception into that Task too, so it never escapes onto the thread pool.
If you are writing for Windows PowerShell 5.1 and InvokeAsync is not available, split it into BeginInvoke / EndInvoke. You can also wrap the synchronous Invoke() in Task.Run, but that is only for when you are giving up cancellation. Task.Run merely frees the UI thread; nobody can reach the pipeline that is running. Even when the user presses Cancel, the hung command keeps running in the background.
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
private static async Task<IReadOnlyList<PSObject>> GetRunningServicesLegacyAsync(
CancellationToken cancellationToken)
{
PowerShell ps = PowerShell.Create();
Task? stopping = null;
try
{
ps.AddCommand("Get-Service")
.AddCommand("Select-Object")
.AddParameter("Property", new[] { "Name", "Status" });
// The order and the cleanup are exactly the same as the InvokeAsync example above.
// Bail out first, start it, then attach the way to stop it.
// The synchronous Invoke() cannot express "start it, then register",
// so it has to be split into BeginInvoke / EndInvoke
cancellationToken.ThrowIfCancellationRequested();
// Wrap BeginInvoke / EndInvoke in a Task. Unlike Task.Run, which keeps one
// thread pool thread blocked, completion is signaled by PowerShell itself
Task<PSDataCollection<PSObject>> running =
Task.Factory.FromAsync(ps.BeginInvoke(), ps.EndInvoke);
using (cancellationToken.Register(
state => Volatile.Write(ref stopping, StopAsync((PowerShell)state!)), ps))
{
// A stop surfaces as PipelineStoppedException
PSDataCollection<PSObject> output = await running;
return output.ToList();
}
}
finally
{
Task? pending = Volatile.Read(ref stopping);
if (pending is not null)
{
try { await pending; } catch { }
}
ps.Dispose();
}
}
Do not wrap it in Task.Run and then Register. If you write “bail out → register → Invoke()” inside Task.Run, a cancellation that arrives between the registration and the start does nothing. BeginStop reaches a pipeline that has not started yet, then Invoke() starts the command, and the stop signal is already spent — the very trap the InvokeAsync example above avoids, reproduced in full.
Whichever shape you use, the policy is the same, and it has three parts: never run a long Invoke() on the UI thread, never touch the UI directly from the method that calls PowerShell, and open the caller’s way to stop it after the pipeline has started.
Concurrency and the cost of the first run
In an admin tool, the request to run work in parallel because processing one machine at a time is too slow comes up almost immediately. Here are the points where that usually gets stuck.
First, the first Invoke() is slow. Runspace initialization and module discovery happen on that first run. It is not a defect that the very first call after startup takes time while every call after it is fast. If you are measuring, exclude the first call from the comparison. In an app driven from a UI, one option is to run a lightweight command once at startup as a warm-up.
Second, do not share one PowerShell instance across multiple threads. Calling Invoke or InvokeAsync again on an instance that is already running throws InvalidOperationException on the grounds that the command has already been started. If you want to run work concurrently, call PowerShell.Create() per operation.
That said, calling PowerShell.Create() per operation creates a Runspace each time. Once the numbers grow, set up a RunspacePool and reuse runspaces from it.
using System.Management.Automation;
using System.Management.Automation.Runspaces;
static async Task<IReadOnlyList<PSObject>> GetServiceAsync(
RunspacePool pool,
string serviceName)
{
using PowerShell ps = PowerShell.Create();
ps.RunspacePool = pool;
ps.AddCommand("Get-Service")
.AddParameter("Name", serviceName)
.AddCommand("Select-Object")
.AddParameter("Property", new[] { "Name", "Status" });
PSDataCollection<PSObject> output = await ps.InvokeAsync();
return output.ToList();
}
On the calling side, open the pool and then fire the work off concurrently.
using System.Management.Automation;
using System.Management.Automation.Runspaces;
string[] serviceNames = { "Spooler", "W32Time", "EventLog" };
using RunspacePool pool = RunspaceFactory.CreateRunspacePool(1, 4);
pool.Open();
IReadOnlyList<PSObject>[] results = await Task.WhenAll(
serviceNames.Select(name => GetServiceAsync(pool, name)));
foreach (IReadOnlyList<PSObject> rows in results)
{
foreach (PSObject row in rows)
{
Console.WriteLine($"{row.Properties["Name"]?.Value}: {row.Properties["Status"]?.Value}");
}
}
Get-Service is used here for illustration; in reality, picture a command that takes real time for each target. The key points are to set ps.RunspacePool rather than ps.Runspace, and to create and dispose the PowerShell instance itself per operation.
Finally, raising the pool size does not necessarily make things faster. The real ceiling is set by the load on the target servers, authentication, the network, and how well the target modules tolerate concurrent execution. Start with a small value, measure, and only then raise it.
Distribution size
Microsoft.PowerShell.SDK is convenient, but it adds dependencies to your application. Acceptable for a small utility, but depending on your distribution format and update mechanism, the size can become a concern. Verify early with your actual distribution method — ClickOnce, MSIX, single-file exe, or internal deployment tooling.
14. The Benefits of Receiving Objects Instead of Strings
Finally, why insist so much on PSObject? Running PowerShell as an external process and reading standard output is easy:
PowerShell output
↓
String
↓
Split / regex / Substring
↓
C# values
But this method depends on the display format. It breaks easily depending on column widths, locale, line endings, whitespace, error messages, and delimiters appearing inside values.
With the PowerShell SDK, the flow becomes:
PowerShell output
↓
PSObject
↓
Properties / BaseObject
↓
C# types
Here you extract values based on the data structure, not the display format. For business applications and admin tools, the latter is easier to maintain.
15. Conclusion
If you want to run PowerShell from C# and work with the results, it is worth considering the PowerShell SDK rather than just launching powershell.exe and reading standard output.
The basic flow:
Add Microsoft.PowerShell.SDK
↓
Create the execution object with PowerShell.Create()
↓
Assemble the work with AddCommand / AddParameter / AddScript
↓
Run with Invoke()
↓
Receive a Collection<PSObject>
↓
Extract values via BaseObject or Properties
↓
Convert to C# DTOs / records / classes
The three most important points in practice:
- For downstream processing in C#, use
Select-Objector[pscustomobject], notFormat-Table - Never embed user input directly into an
AddScriptstring; pass it viaAddParameterwherever possible - Handle
PSObjectat the boundary, and convert to C# types inside the application
PowerShell is strong at Windows administration and reusing existing assets; C# is strong at building applications, UIs, and type-safe business logic. Connect the two well, and you can gradually build your existing PowerShell scripts into a .NET application without throwing them away.
References
- The complete sample code for this article (library, demo, unit tests) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/csharp-run-powershell-receive-objects
- Microsoft Learn: Windows PowerShell Host Quickstart
https://learn.microsoft.com/en-us/powershell/scripting/developer/hosting/windows-powershell-host-quickstart - Microsoft Learn: Adding and invoking commands
https://learn.microsoft.com/en-us/powershell/scripting/developer/hosting/adding-and-invoking-commands - Microsoft Learn: PowerShell Class
https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell - Microsoft Learn: PSObject Class
https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.psobject - Microsoft Learn: PowerShell.InvokeAsync Method
https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell.invokeasync - Microsoft Learn: PowerShell.BeginStop Method (stops the running command asynchronously; the returned
IAsyncResultis consumed byEndStop)
https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell.beginstop - Microsoft Learn: PowerShell.Stop Method (the synchronous version; it does not return until the pipeline has stopped)
https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell.stop - Microsoft Learn: Creating multiple runspaces
https://learn.microsoft.com/en-us/powershell/scripting/developer/hosting/creating-multiple-runspaces - NuGet Gallery: Microsoft.PowerShell.SDK
https://www.nuget.org/packages/Microsoft.PowerShell.SDK/ - NuGet Gallery: Microsoft.PowerShell.5.1.ReferenceAssemblies
https://www.nuget.org/packages/Microsoft.PowerShell.5.1.ReferenceAssemblies/
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Calling COM and .NET from PowerShell — Widening What Your Scripts Can Reach
A practical guide to calling .NET classes from PowerShell, embedding C# and Win32 APIs with Add-Type, driving COM, cleaning up leftover E...
Using WMI/CIM from C# and PowerShell — A Practical Guide to Hardware Info Retrieval, Process Monitoring, and Remote Queries
WMI/CIM is the standard way to get a PC's serial number, monitor free disk space, and detect process launches. This article covers how to...
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...
The Differences Between Windows PowerShell 5.1 and PowerShell 7 — A Practical Guide to Migrating In-House Scripts
The relationship between Windows PowerShell 5.1 and PowerShell 7 (side-by-side coexistence and pwsh.exe), Microsoft's official position t...
Automating Excel and CSV Work with PowerShell — Practical Recipes for Aggregation, Reconciliation, and Report Output
Practical recipes for automating CSV aggregation, reconciliation, and Excel report output with PowerShell. Covers the default encodings o...
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.
- Which approach should I use to run PowerShell from C#?
- There are broadly two: launching powershell.exe/pwsh.exe as an external process with ProcessStartInfo, or using System.Management.Automation.PowerShell (the PowerShell SDK). If all you do is read standard output as a string, the first one works. But for an admin tool or a business application that processes the results in C#, the second one fits better because it hands you the results as a collection of PSObject. String parsing disappears, and you can write safe code that does not depend on the display format.
- How do I decide between BaseObject and Properties on a PSObject?
- Use BaseObject when you want the original .NET object PowerShell returned, as is. For example, the result of running Get-Process directly can be extracted as a System.Diagnostics.Process. Results whose columns were shaped with Select-Object or [pscustomobject], on the other hand, usually come back as PowerShell custom objects, so it is more natural to name the column and read it with Properties["ColumnName"]?.Value.
- What should I watch out for when passing user input from C# to PowerShell?
- Avoid embedding user input into an AddScript script through string concatenation. It is dangerous because the input has room to be interpreted as PowerShell code. When you need to pass a value, use AddCommand and AddParameter: the value is then treated as a parameter value rather than as part of a code string. Keeping AddScript to fixed short scripts and to loading existing scripts is the safe policy.
- Why should I avoid Format-Table when receiving PowerShell results in C#?
- Because passing results through Format-Table or Format-List turns them into display formatting information instead of the original objects, and the C# side can no longer extract values as properties. When the results feed downstream processing in C#, narrow the columns with Select-Object or shape the PowerShell side to return a [pscustomobject]. The rule of thumb is: Format-* if you are only looking at it on screen, Select-Object if you are handing it to C#.