How to Run PowerShell from C# (CSharp) and Receive the Results as Objects

· Updated: · · 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.

  1. 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 need Invoke or Dispatcher.Invoke.
  2. Disable the button while the work runs. If you do not, the user can start the same work twice.
  3. Catch exceptions as RuntimeException. If you have not set ErrorAction to Stop, check the error stream as well (section 10).
  4. Always provide a way to stop. If the PowerShell instance stays hidden in a local variable of the method, the caller cannot call Stop(). 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-Object or [pscustomobject], not Format-Table
  • Never embed user input directly into an AddScript string; pass it via AddParameter wherever possible
  • Handle PSObject at 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 IAsyncResult is consumed by EndStop)
    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/

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.

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

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