How to Run PowerShell from C# (CSharp) and Receive the Results as Objects
· Go Komura · C#, PowerShell, Windows, .NET, Automation, Legacy Asset Reuse
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.
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, this is a useful way to think about it:
| 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 we use Microsoft.PowerShell.SDK 7.4.16 as the .NET 8 example.
dotnet new console -n PowerShellObjectSample
cd PowerShellObjectSample
dotnet add package Microsoft.PowerShell.SDK --version 7.4.16
The .csproj ends up looking 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>
We recommend pinning the version. The PowerShell SDK is convenient, but it is affected by the application’s runtime environment, the target .NET, and PowerShell module compatibility. For business applications, explicitly recording the version you verified is safer than just shipping “the latest version that happened to work on the dev machine.”
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 should be avoided:
// Example to avoid
string userInputPath = GetPathFromUser();
string script = $"Get-ChildItem -Path '{userInputPath}'";
using PowerShell ps = PowerShell.Create();
ps.AddScript(script).Invoke();
Written this way, user input can be interpreted as PowerShell code. When passing values to PowerShell commands, use AddCommand and AddParameter wherever possible.
string userInputPath = GetPathFromUser();
using PowerShell ps = PowerShell.Create();
Collection<PSObject> files = ps
.AddCommand("Get-ChildItem")
.AddParameter("Path", userInputPath)
.AddParameter("File", true)
.Invoke();
Values passed via AddParameter are treated as parameter values, not concatenated into a PowerShell code string.
In practice, this is a sensible division:
| 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 the application powerful operational capabilities. That convenience comes with one line you must not cross: never turn user input directly into script.
9. Format-Table Is for the Final Screen Display — Don’t Use It Before Passing to C#
When you want to receive PowerShell results as objects in C#, you basically do not use Format-Table or Format-List.
For example, this PowerShell is convenient for a human looking at a screen:
Get-Service | Format-Table Name, Status
But if you apply Format-Table before handing results to C#, what you receive is not service objects — it is formatting metadata for display. If you want to work with the data in C#, use Select-Object.
Get-Service | Select-Object Name, Status
Written from C#, it looks like this:
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 is true when using PowerShell on its own too, but it becomes especially important when integrating with C#.
10. Receiving Errors
In PowerShell, output and errors are separate streams. If you only look at the return value of Invoke(), you can miss errors. The basic form is:
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 produce both terminating errors and non-terminating errors. If you want to handle them as exceptions on the C# side, one approach is to specify 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 is better depends on the nature of the application. For an admin tool where you want the listing even if parts fail, collecting the error stream and showing it on screen fits better. If a failure should stop the whole operation, treating it as an exception via ErrorAction Stop is clearer.
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 commands.
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# — like the Where-Object in this example — can get a little hard to read. Simple commands and parameters are fine with AddCommand / AddParameter, but for complex filters and aggregations, a fixed PowerShell script is sometimes more readable. Even then, the policy of never concatenating external input directly into the script string does not change.
12. Shape Complex Output into Objects on the PowerShell Side
When combining C# and PowerShell, design becomes easier if you decide which side owns what.
We recommend this division:
| 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 a [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 application’s own 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 runs is not enough. In practice, checking these 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, run it as background work and update the UI on completion. The PowerShell SDK also has asynchronous execution APIs, but it starts with one rule: never run a long Invoke() on the UI thread.
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 far 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 - 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.
Windows App Outsourcing and Contract Development: What to Sort Out Before You Ask
Before commissioning Windows app outsourcing or contract development, here is how to sort out existing software modification, device inte...
Handling Windows Impersonation Tokens Correctly — Borrowing Privileges per Thread and Reverting Safely
A practical guide to Windows impersonation tokens — access tokens, primary tokens, thread tokens, impersonation levels, RevertToSelf, and...
Testing PowerShell with Pester — A Practical Approach to Making Operations Scripts Harder to Break
A practical walkthrough of testing PowerShell scripts with Pester v5 — safely covering date handling, file operations, deletion logic, mo...
Practical PowerShell Command Recipes — Growing the Small Tools You Use Every Day
A practical roundup of PowerShell commands for everyday work, covering where to use Measure-Object, Group-Object, Select-String, Compare-...
Applied PowerShell Scripting — Safely Automating Log Investigation, Archiving, and Reporting
Practical steps for safely automating log investigation, CSV reporting, archiving old logs, keeping audit trails, and Task Scheduler exec...
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.
Author Profile
Profile page for the article author.
Go Komura
Representative of KomuraSoft LLC
Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.
Public links