Getting data out of PowerShell is easy. Getting it into a form somebody will actually read is the part that takes thought. A 4,000-row CSV attached to an email gets ignored; a short HTML table showing only what’s wrong gets acted on.
This guide covers the three output formats worth knowing, when each is the right choice, and how to send a report on a schedule without it ending up in junk.
Quick Facts
- Always use
Export-Csv -NoTypeInformation— without it you get a junk header line that breaks Excel imports. ConvertTo-Htmlproduces a full styled page in one line, and takes CSS directly.- Use JSON when another system reads the output, CSV when a human opens it in Excel, HTML when it goes in an email.
Select-Objectbefore exporting. Exporting raw objects gives you dozens of columns nobody wants.- A report that’s empty when everything is fine gets read. A daily 4,000-row dump gets filtered to a folder and forgotten.
Pick your format first
| CSV | HTML | JSON | |
| Read by | Humans, in Excel | Humans, in email | Other systems |
| Nested data | No | No | Yes |
| Formatting | None | Full CSS | None |
| Round-trips back into PowerShell | Loosely — everything becomes a string | No | Yes, with types intact |
| Best for | Data someone will sort and filter | Status emails and dashboards | Handing off to an API or script |
CSV — the one everybody gets slightly wrong
The classic mistake is exporting objects straight out of a cmdlet. You get every property the object has, most of which are meaningless in a spreadsheet.
# Selects only what matters, and names the columns properly
Get-Service |
Where-Object Status -eq 'Stopped' |
Select-Object @{Name='Service';Expression={$_.DisplayName}},
@{Name='StartMode';Expression={$_.StartType}},
Name |
Export-Csv -Path C:\Reports\StoppedServices.csv -NoTypeInformation -Encoding UTF8
Three things worth copying from that. -NoTypeInformation strips the #TYPE line that otherwise confuses Excel. -Encoding UTF8 stops accented characters turning into mojibake. And the calculated properties give columns sensible names rather than raw property names.
Timestamp your filenames
$stamp = Get-Date -Format 'yyyy-MM-dd'
$path = Join-Path 'C:\Reports' "StoppedServices_$stamp.csv"
Sortable date format, and Join-Path rather than string concatenation. You’ll thank yourself when there are two hundred of these in a folder.
HTML — for reports people read in email
ConvertTo-Html is underrated. It turns any collection of objects into a complete web page, and it accepts CSS directly, so a readable report is genuinely one command.
$css = @"
<style>
body { font-family: Segoe UI, Arial, sans-serif; font-size: 14px; }
table { border-collapse: collapse; width: 100%; }
th { background: #16213a; color: #fff; text-align: left; padding: 8px; }
td { border-bottom: 1px solid #ddd; padding: 8px; }
tr:nth-child(even) { background: #f6f8fa; }
</style>
"@
Get-Service |
Where-Object Status -eq 'Stopped' |
Select-Object DisplayName, Name, StartType |
ConvertTo-Html -Head $css -PreContent "<h2>Stopped services - $(Get-Date -Format 'dd MMM yyyy')</h2>" |
Out-File C:\Reports\services.html -Encoding UTF8
-PreContent and -PostContent let you add a heading and a footer. -Fragment gives you just the table with no page wrapper, which is what you want when embedding it in an email body.
JSON — for handing data to something else
Use JSON when the consumer is a system rather than a person. It preserves structure, handles nesting, and reads back into PowerShell with types intact.
Get-Service |
Select-Object Name, DisplayName, Status |
ConvertTo-Json -Depth 3 |
Out-File C:\Reports\services.json -Encoding UTF8
# Reading it back
$data = Get-Content C:\Reports\services.json -Raw | ConvertFrom-Json
The -Depth parameter matters. The default is 2, and anything nested deeper is silently flattened to a type name rather than its contents — a quiet failure that’s easy to miss.
Making it a scheduled report
Quick Steps
- Save the script somewhere the service account can read — a UNC path, not a mapped drive.
- Create a scheduled task running as an account with the rights it needs, with “Run whether user is logged on or not” ticked.
- Set the action to
pwsh.exewith arguments-NoProfile -ExecutionPolicy Bypass -File "\\server\share\report.ps1". - Use
-NoProfile— profiles can change behaviour and slow startup. - Log both success and failure to a file so you know it ran, not just when it broke.
On sending mail: Send-MailMessage still works but is officially obsolete and shouldn’t be used for anything new. For Microsoft 365, use Microsoft Graph. For an internal SMTP relay, a small .NET MailMessage wrapper is the usual replacement.
Make the report worth reading
- Report exceptions, not inventory. Send what’s wrong, not everything.
- Send nothing when there’s nothing to say, or send a one-line all-clear. Either beats a daily wall of text.
- Put the count in the subject line — “3 servers below 10% free space” tells the reader whether to open it.
- Sort by severity, so the worst thing is the first row.
Glossary
| Term | What it means |
| Calculated property | A hashtable with Name and Expression keys used in Select-Object to rename or transform a column. |
| Here-string | A multi-line string between @" and "@, used here to hold CSS. |
| -NoTypeInformation | Suppresses the #TYPE header line in CSV exports. |
| -Depth | How many levels of nesting ConvertTo-Json serialises. Default 2. |
| -Fragment | Outputs only the HTML table, without a full page wrapper. |
Frequently asked questions
Why does my CSV open with everything in one column?
Regional settings. In locales where the list separator is a semicolon, Excel won’t split comma-delimited files automatically. Either use -Delimiter ';' or use Excel’s Data → From Text import, which lets you choose.
Why is my exported CSV full of System.Object[] entries?
Because a property contains a collection, and CSV has no way to represent one. Flatten it first with something like @{Name='Groups';Expression={$_.Groups -join '; '}}.
Should I still use Send-MailMessage?
Not for anything new. Microsoft marked it obsolete because it doesn’t guarantee secure connections. Existing scripts using it will keep working, but new work should use Microsoft Graph or a .NET SMTP client.
How do I stop the report emailing when there’s nothing to report?
Check the count before sending: if ($results.Count -eq 0) { return }. Some teams prefer a daily all-clear so silence isn’t ambiguous — either is fine, as long as it’s deliberate.
Gear We Recommend
Testing scripts is easier with a dedicated admin machine set up right. Here’s the kit we use.
Browse our Admin Machine picks on AmazonAs an Amazon Associate, TechyGeeksHome earns from qualifying purchases
Disclosure: this post may contain affiliate links. If you buy through one of them, we may earn a small commission at no extra cost to you. We only recommend products we’ve tested or genuinely rate.
Discover more from TechyGeeksHome
Subscribe to get the latest posts sent to your email.