PS C:\> Get-Standard · v1.1.1
↓ Download AGENTS.md ↓ Download CLAUDE.md
Claude Code reads CLAUDE.md, not AGENTS.md. The CLAUDE.md here is a one-line shim that imports this Standard, so you keep one source of truth. How adoption works →
AGENTS.md: PowerShell Engineer Standard
Version: 1.1.1 · Maintainer: Jim Tyler, Microsoft MVP, PowerShell Engineer (powershellengineer.com) Applies to: all PowerShell (.ps1, .psm1, .psd1) authored in this repository. Licence: MIT. Copy it, fork it, adapt it to your house rules. Attribution appreciated, not required.
Not affiliated with or endorsed by Microsoft. PowerShell is a trademark of Microsoft Corporation.
Write for the engineer who inherits this code and has to run it against production at 2am. When a rule below conflicts with an explicit instruction from the user, follow the user and say which rule you set aside and why.
0. Non-negotiables
Every function you emit must satisfy all of these. If a task cannot be done without breaking one, name the rule and say why rather than shipping something that looks finished.
A vague request is not that case. Underspecified is the normal state of a request: choose a sensible default, state it in one line, and write the function. Ask a question only when the task cannot be attempted at all without an answer. Uncertainty about whether a command or parameter exists is handled by the # VERIFY: marker in 0.1, not by declining to write the code. Refusing to answer a question you could have answered under a stated assumption is its own defect, and it is the more common one.
- Advanced function:
[CmdletBinding()]+param()block - Approved verb (
Get-Verb), singular noun,Prefixapplied in module context [OutputType()]declared- Emits objects (
[pscustomobject]or a class), never formatted strings - Validation expressed as parameter attributes, not
ifblocks - Comment-based help with a runnable
.EXAMPLE SupportsShouldProcesson anything that changes state- Terminating errors are catchable; non-terminating errors don't masquerade as success
- No secret reaches an output stream, a native-command argument, or source control
- PSScriptAnalyzer clean against
PSScriptAnalyzerSettings.psd1 - Comments and help written in the voice set out in section 19, not in marketing register
- No invented cmdlets, parameters, modules, or properties (section 0.1)
- Full cmdlet names and named parameters. No aliases, no positional args
0.1 Do not invent API surface
The most common defect in generated PowerShell is not style. It is a parameter that does not exist. Get-ADUser -IncludeDisabled reads perfectly, passes review, and fails the moment it runs. Code that is wrong in this way is worse than code that is ugly, because nothing about it looks wrong.
- If you are not certain a cmdlet, parameter, module, property, enum value, exception type,
or .NET method signature exists, do not use it.
- Prefer a construct you are sure of over an elegant one you are not.
Where-Object { -not $_.Enabled } that you know works beats a -Filter string you half remember.
- Never invent a module name. If you do not know which module provides a command, say so
rather than guessing at something plausible.
- Never fabricate example output. Either derive it from the code you just wrote or label it
as illustrative.
- Do not reproduce documentation URLs from memory. Name the command and let the reader run
Get-Help against the version they actually have.
Version-specific surface is where this fails most often. Parameters added in later module releases, renames between AzureRM and Az, Graph SDK v1 against v2, Exchange Online v2 against v3. Being right about one version is being wrong about another.
When you are unsure and the task still needs it, do both of these:
- Mark it in the code where the reader will see it:
# VERIFY: confirm -IncludeDisabled exists in your module version
- Give the command that settles it:
Get-Command Get-ADUser -Syntax
(Get-Command Get-ADUser).Parameters.Keys
Get-Help Get-ADUser -Parameter IncludeDisabled
$result | Get-MemberThe same applies to .NET surface, which is invented just as often and checked far less. A constructor overload that does not exist, a static method that was never there, an enum value that sounds right: all of these produce code that reads fine and throws on the first run.
[System.IO.Path] | Get-Member -Static
[System.Net.Http.HttpClient].GetConstructors() | ForEach-Object { $_.ToString() }
[System.DayOfWeek].GetEnumNames()
[System.Text.Encoding] | Get-Member -Static -Name UTF8Tab completion after [System. is the fastest check of all and costs nothing. Two community modules do the same job more legibly: Get-TypeConstructor and Get-TypeMember from PSScriptTools output copy-pasteable syntax, and Find-PSMDType from PSModuleDevelopment finds types by interface or pattern.
Stating uncertainty costs the reader five seconds. An invented parameter costs them a debugging session, and costs you their trust in everything else in the file.
1. Canonical shape
This is the reference implementation. Match its structure.
function Remove-PSEStaleAccount {
<#
.SYNOPSIS
Disables Active Directory accounts inactive beyond a threshold.
.DESCRIPTION
Finds enabled user accounts whose LastLogonDate precedes the cutoff and
disables them. Requires delegated write access to the target OU. Emits one
result object per account evaluated, including skipped accounts, so the
caller can audit the full run rather than only the changes.
.PARAMETER Identity
The account to evaluate. Accepts pipeline input from Get-ADUser so you can
filter with the AD provider's server-side query before piping in.
.PARAMETER InactiveDays
Days of inactivity before an account is considered stale. Set this to match
your organization's account lifecycle policy; the default is deliberately
conservative.
.INPUTS
Microsoft.ActiveDirectory.Management.ADUser
.OUTPUTS
PSEngineer.AccountResult
.EXAMPLE
Get-ADUser -Filter 'Enabled -eq $true' -SearchBase $ou -Properties LastLogonDate |
Remove-PSEStaleAccount -InactiveDays 90 -WhatIf
Previews the full run without modifying anything.
.EXAMPLE
Remove-PSEStaleAccount -Identity jdoe -InactiveDays 30 -Verbose
Evaluates a single account with progress detail on the verbose stream.
.EXAMPLE
$stale = Get-Content .\accounts.txt | Remove-PSEStaleAccount -InactiveDays 180
$stale | Where-Object Action -eq 'Disabled' | Export-Csv .\disabled.csv -NoTypeInformation
Captures results as objects for downstream reporting.
.NOTES
Requires: ActiveDirectory module, delegated write access to the target OU.
.LINK
Get-ADUser
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
[OutputType('PSEngineer.AccountResult')]
param(
[Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[ValidateNotNullOrEmpty()]
[string[]]$Identity,
[Parameter()]
[ValidateRange(1, 3650)]
[int]$InactiveDays = 90
)
begin {
$cutoff = (Get-Date).AddDays(-$InactiveDays)
Write-Verbose "Cutoff date: $cutoff"
}
process {
foreach ($id in $Identity) {
try {
$getParams = @{
Identity = $id
Properties = 'LastLogonDate', 'Enabled'
ErrorAction = 'Stop'
}
$user = Get-ADUser @getParams
}
catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] {
Write-Error -Message "Account not found: $id" -Category ObjectNotFound -TargetObject $id
continue
}
$isStale = $user.LastLogonDate -and $user.LastLogonDate -lt $cutoff
$action = 'Skipped'
if ($isStale -and $PSCmdlet.ShouldProcess($id, 'Disable account')) {
try {
Disable-ADAccount -Identity $user -ErrorAction Stop
$action = 'Disabled'
}
catch {
$PSCmdlet.WriteError($_)
$action = 'Failed'
}
}
[pscustomobject]@{
PSTypeName = 'PSEngineer.AccountResult'
Identity = $id
LastLogonDate = $user.LastLogonDate
Action = $action
}
}
}
end {
Write-Verbose 'Evaluation complete.'
}
}2. Naming
- Verbs from
Get-Verbonly.Get/Set/New/Remove/Test/Invokecover most needs. - Nouns singular, PascalCase, prefixed:
PSEfor this project (Get-PSEModulePlan). - Parameters match ecosystem convention:
Path,Identity,Name,Force,PassThru. - No abbreviations in public surface.
$svcis fine locally;-Svcis not a parameter name.
3. Parameters and validation
Push constraints into attributes so Get-Help and tab-completion expose them for free:
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[ValidateSet('Dev', 'Test', 'Prod')]
[string]$Environment
[ValidateRange(1, 100)]
[int]$BatchSize = 25
[ValidateScript({
if (-not (Test-Path -LiteralPath $_)) { throw "Path not found: $_" }
$true
})]
[string]$ConfigPath- Prefer
[ValidateSet]over string comparison; it drives completion. [ValidateScript]mustthrowa message that names the offending value. The default
failure text tells the user nothing.
- Use parameter sets with
DefaultParameterSetNameinstead of mutually exclusive switches. - Never default a parameter to a mutable shared object.
Completion
[ValidateSet] handles closed sets that are known at authoring time. When the valid values depend on the environment, supply a completer instead of leaving the user to guess:
[Parameter(Mandatory)]
[ArgumentCompleter({
param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
(Get-PSEProfile).Name |
Where-Object { $_ -like "$wordToComplete*" } |
ForEach-Object { [System.Management.Automation.CompletionResult]::new($_) }
})]
[string]$ProfileName- Completers run on every Tab press. Keep them under a few hundred milliseconds and cache
anything that crosses the network. A completer that queries a directory on each keystroke makes the whole shell feel broken.
- Return
CompletionResultobjects rather than bare strings when you want tooltips or a
display name that differs from the inserted value.
- Filter on
$wordToCompleteyourself. Nothing does it for you. - When the same completer serves several parameters, implement
IArgumentCompleteras a
class and reference it with [ArgumentCompleter([PSEProfileCompleter])].
Register-ArgumentCompleteris for adding completion to commands you do not own. Do not
use it as a substitute for declaring the completer on your own parameters.
Dynamic parameters
Default position: do not. A DynamicParam block is a large amount of fragile boilerplate that hides the parameter from Get-Help and from tab completion until its condition is met, which makes the command harder to discover and harder to document. Reach for the static alternatives first:
| You want | Use instead |
|---|---|
| Values that vary by environment | [ArgumentCompleter] on a normal parameter |
| Mutually exclusive parameter groups | Parameter sets |
| A parameter that only applies sometimes | An optional parameter and a validation check |
| A closed list known at authoring time | [ValidateSet] |
Dynamic parameters earn their place in two cases only: a provider-aware command whose parameters genuinely differ by PSDrive, and a parameter whose very existence depends on runtime state that cannot be known when the function is parsed.
If you write one:
- Comment why a static parameter would not work. Without that note the next maintainer will
assume it was cargo cult and be right most of the time.
- Document it in
.PARAMETERanyway, even though help will not generate it automatically. - Test the condition being both true and false. Dynamic parameters are where untested branches
hide.
New-PSDynamicParameterin PSScriptTools generates the block correctly, which is worth more
than writing it by hand, and worth much more than letting a model improvise it.
4. Splatting
Splat any call with three or more parameters, or any call whose line would exceed ~100 characters. Build the hashtable immediately above the call.
# Yes
$requestParams = @{
Uri = "$BaseUri/v1.0/users"
Method = 'Get'
Headers = $authHeader
ContentType = 'application/json'
ErrorAction = 'Stop'
}
Invoke-RestMethod @requestParams
# No. Backticks are a maintenance hazard
Invoke-RestMethod -Uri "$BaseUri/v1.0/users" -Method Get `
-Headers $authHeader -ContentType 'application/json' -ErrorAction StopConditional parameters are added to the hashtable, never assembled as string fragments:
if ($Credential) { $params['Credential'] = $Credential }Never use backtick line continuation. Splat instead.
Converting an existing long command line into a splat is mechanical, so do not do it by hand. Convert-CommandToHashtable in PSScriptTools takes the command text and emits the hashtable plus the call.
5. Output and the pipeline
Write-Hostis banned for data. Use it only for deliberate, human-only decoration, and
prefer Write-Information even then.
- One object per item, emitted in
process. Don't accumulate and return an array unless
ordering or summary requires it.
- Attach
PSTypeNameso formatting andGet-Memberbehave. - A
PSTypeNameon its own only tags the object. Any type name you invent and emit from more
than one function gets a Format.ps1xml defining its default view, otherwise you have paid the cost of typing the output and taken none of the benefit. Register it through FormatsToProcess in the manifest.
- Use
Types.ps1xml(viaTypesToProcess) forScriptProperty,AliasProperty, and
DefaultDisplayPropertySet. DefaultDisplayPropertySet is what lets an object carry twenty properties while showing the four that matter, which removes most of the temptation to reach for Format-Table in the first place.
Update-FormatDataandUpdate-TypeDataare for the development loop. Shipped modules
declare both files in the manifest.
- Never emit
Format-*output from a function. It cannot be used downstream. - Support pipeline input via
ValueFromPipelineByPropertyNamewherever a natural upstream
cmdlet exists. This is the most commonly skipped requirement in generated code; treat it as mandatory unless the function genuinely takes no item-shaped input.
6. Error handling
| Situation | Do this |
|---|---|
| One item of many fails | Write-Error + continue, keep processing |
| Precondition invalid, cannot proceed | $PSCmdlet.ThrowTerminatingError($errorRecord) |
| Calling a cmdlet you must catch | Pass -ErrorAction Stop on that call |
| Re-surfacing a caught error | $PSCmdlet.WriteError($_), which preserves the record |
- Catch specific exception types before a general
catch. - Never set
$ErrorActionPreferenceglobally in a module; scope it per call. - Never use
trap. - Never swallow:
catch { }is a defect. If you truly intend to ignore, say so in a comment. - Include
-TargetObjectso the caller can identify which input failed. finallyfor cleanup that must run: connections, temp files, impersonation.
Strict mode
Set-StrictMode -Version Latestat the top of every script, and in the root.psm1of
every module. It turns typo'd variable names, missing properties, and out-of-range array indexes into errors instead of silent $null.
- Pin to
-Version 3.0instead when the code must behave identically across engine versions.
Latest changes meaning as the engine changes, which is the point of it and also the risk.
- Strict mode is a safety net for authoring mistakes, not a substitute for parameter
validation. It does nothing about a caller passing a valid-but-wrong value.
- Do not enable it inside a single function to paper over a scope you do not trust. Set it
once at the top and fix what it surfaces.
7. Safety: ShouldProcess
Any function that creates, modifies, or deletes state declares SupportsShouldProcess.
ConfirmImpact = 'High'for destructive or irreversible operations.- Gate the mutation only. Evaluation, reads, and result objects still run under
-WhatIf,
so a -WhatIf run doubles as an audit.
$PSCmdlet.ShouldContinue()only for additional interactive confirmation beyond
ShouldProcess, and always paired with a -Force switch to bypass it.
- A
-Forceswitch must also set$ConfirmPreferenceappropriately, not skipShouldProcess.
8. Help quality bar
Decorative help is worse than no help, because it implies documentation exists.
.SYNOPSIS: one imperative line. Do not restate the function name..DESCRIPTION: side effects, prerequisites, required permissions, and what it does not do..PARAMETER: explain why you'd set it. The type is already visible, so don't repeat it..INPUTS/.OUTPUTS: real type names. Composability depends on these being accurate..EXAMPLE×3 minimum: simple → realistic → edge case. Each must run unmodified..NOTES: permissions, module dependencies, version requirements.- Modules ship
en-US/about_PSEngineer.help.txt. Most modules skip it, which is why most
modules read as a folder of scripts rather than a product.
Test: everything in Get-Help <function> -Examples must be copy-pasteable.
External help
Comment-based help is the baseline. A module intended for other people ships external help as well, because MAML is what supports updatable help, localized help, and help that survives the function being compiled or obfuscated.
- Author in markdown and generate MAML.
Microsoft.PowerShell.PlatyPSis the supported tool;
the original platyPS (0.14.2) is retired and should not be used for new work.
- Generated markdown lives in
docs/, is committed, and is regenerated rather than hand-edited
after a parameter changes.
- PlatyPS creates templates. It does not write documentation. The descriptions and examples are
still yours to write, to the standard in section 19.
- Verify with
Get-HelpPreviewor by importing the built module and runningGet-Help -Full.
Markdown that renders is not the same as help that renders.
- MAML conversion has known rough edges in the current release, so check the output rather than
assuming the pipeline is clean.
9. Testing with Pester v5
Pester v5 only. v4 syntax fails in v5 because of discovery/run phase scoping. Do not mix them.
- Variables used by
Itblocks are set inBeforeAll, not at describe scope. Describeper function;Contextper scenario or parameter set.- Test behavior and contracts, not implementation details.
- Mock with
-ModuleNameto intercept inside the module scope. InModuleScopefor private functions.- Required coverage: happy path, each validation failure, error branch,
-WhatIfmakes no change. - Tag slow or environment-dependent tests so CI can select.
10. Module structure
PSEngineer/
├── src/
│ ├── Public/ # one exported function per file, named for the function
│ ├── Private/ # helpers, not exported
│ ├── Classes/ # loaded before functions
│ └── en-US/ # about_ help, localized strings
├── tests/
├── build/
├── PSEngineer.psd1
├── PSEngineer.Format.ps1xml # default views for emitted PSTypeNames
├── PSEngineer.Types.ps1xml # DefaultDisplayPropertySet, ScriptProperties
└── PSEngineer.psm1FunctionsToExportlists names explicitly. Never'*', because wildcards force full-module
parse on discovery and measurably slow every Import-Module and tab-completion.
- Same for
CmdletsToExport,VariablesToExport,AliasesToExport: explicit or@(). - Build concatenates
src/into a single.psm1for load performance; author in separate files. - Use a build tool rather than a bespoke script.
ModuleBuilderhandles the source-to-.psm1
merge and manifest updates; InvokeBuild or psake handle the wider task graph.
- This layout is a convention, not a law. Several mature modules put
functions/,formats/,
types/, and en-US/ at the root instead of under src/. Either is fine. What matters is that it is consistent within a repository and that the build knows where things are.
- SemVer in the manifest. Maintain
CHANGELOG.md. - Declare
RequiredModules,PowerShellVersion, andCompatiblePSEditions.
Compiled components
Most modules should not have one. Reach for C# when there is a reason PowerShell cannot cover: a hot inner loop where the parse overhead dominates, a type system the language cannot express, P/Invoke, or an SDK that ships only as a library.
- Source lives in
library/<ModuleName>/. Build output is copied tobin/, and the source is
committed. A DLL with no repository behind it is not something an administrator should be asked to trust.
- Load through
RequiredAssembliesin the manifest rather thanAdd-Typeinside the.psm1.
RequiredAssemblies resolves before the module loads and fails with a clear error instead of a confusing one halfway through import.
Add-Type -TypeDefinitioncompiling C# at import time does not belong in a shipped module.
It costs seconds on every import and assumes a compiler is present.
- Assembly version conflicts are the real cost. In Windows PowerShell everything shares one
load context, so a second module wanting a different version of the same assembly loses silently and fails somewhere unrelated. PowerShell 7 improves this but does not remove it. Depend on as few assemblies as possible and pin the versions you do take.
- Once a binary component exists, state the runtime, platform, and edition requirements in the
manifest and the README. The module is no longer trivially portable and pretending otherwise wastes someone's afternoon.
Scaffolding
The layout above is available as a PSModuleDevelopment template, so anyone already working through Invoke-PSMDTemplate can adopt this structure without changing tools. Generating the same structure by hand is fine. Generating a different one and calling it this one is not.
Dependencies
Every entry in RequiredModules is an install step, a version conflict, and a support question. Target zero. The whole of onboarding should be:
Install-Module PSEngineer -Scope CurrentUser # PowerShellGet
Install-PSResource PSEngineer -Scope CurrentUser # PSResourceGet
Import-Module PSEngineerDocument both. Microsoft.PowerShell.PSResourceGet is the current module management stack and ships with PowerShell 7.4 and later, but Install-Module is what most people still have in their fingers and what every older runbook says.
No build step, no manual assembly copying, no elevation, no post-install script, no network call at import time.
- Prefer the .NET base class library over a module that wraps it.
- When a dependency serves one or two functions, make it optional rather than required. Test
for it at call time and fail with the exact command that fixes it: throw "Requires the Az.Accounts module. Install with: Install-Module Az.Accounts -Scope CurrentUser"
- Pin exact versions for anything genuinely required (section 12.3).
- Do not vendor another module inside yours. Its licensing, updates, and version conflicts
become yours the moment you do.
- Keep
PowerShellVersionandCompatiblePSEditionshonest so the Gallery filters correctly
for people who cannot meet your minimum.
- A platform-specific dependency means a companion module, not a conditional import.
Ecosystem frameworks
Two mature community projects overlap with parts of this document, and pretending they do not exist serves nobody.
- PSFramework provides logging, configuration, and tab completion infrastructure that is
more capable than what section 15.2 describes building by hand.
- PSModuleDevelopment provides templating, script splitting, encoding repair, and type
discovery.
- PSScriptTools provides scripting helpers that implement several rules in this document
directly, including splat conversion, type and constructor discovery, dynamic parameter generation, repeatable performance testing, and session provenance.
The position this document takes: for a module that strangers will install from the Gallery, the zero-dependency rule above wins and you implement what you need. For internal tooling where you control the environment and the install, PSFramework is a sound choice and its logging is better than a hand-rolled equivalent.
Nothing else changes either way. A module built on PSFramework still owes approved verbs, ShouldProcess, object output, real help, and tests.
11. Performance
- Never
+=on arrays in a loop. In 5.1 through 7.4 it reallocates the whole array every
iteration. PowerShell 7.5 optimized it substantially, but code in this repository cannot assume 7.5, so use [System.Collections.Generic.List[T]]::new() or emit straight to the pipeline.
- Filter left, format right. Push predicates into
-Filter(server-side) rather than
Where-Object (client-side) for AD, Exchange, and file system queries.
$null = $expror[void]$exprinstead of| Out-Null.foreachstatement beatsForEach-Objectfor large in-memory collections.- Hashtable lookups instead of nested
Where-Objectscans when joining datasets. - Don't micro-optimize readable code without a measurement. Cite
Measure-Commandresults
when you do optimize.
- A single
Measure-Commandrun is noise. Caching, JIT, and whatever else the machine is doing
all move the number. Run the comparison repeatedly and report the median. Test-Expression in PSScriptTools does this, including random intervals between runs and trimmed averages.
Parallelism
Sequential until measurement says otherwise. Each runspace is its own session that must import modules and resolve commands itself, and that startup cost swamps the work in most scripts people reach for parallelism to fix. Since 7.1, ForEach-Object -Parallel reuses runspaces from a pool sized by -ThrottleLimit (opt out with -UseNewRunspace, added in 7.3), which amortizes the cost across iterations but does not remove it.
ForEach-Object -Parallel(7.0+) pays off when the work is I/O bound, such as REST calls or
remote queries, and there are enough items to amortize runspace startup. It rarely helps CPU-bound PowerShell.
- Set
-ThrottleLimitdeliberately. The default of 5 is a starting point, not a tuned value,
and the right number depends on what is being waited on.
- Variables from the caller's scope need
$using:. Collections written from parallel blocks
need a thread-safe type such as [System.Collections.Concurrent.ConcurrentBag[object]]. A plain List[T] will corrupt or throw under contention.
Start-ThreadJobsuits a handful of long-running background tasks better than
ForEach-Object -Parallel suits the same job.
- Manage runspace pools directly only when neither of the above fits, and write a comment
saying which constraint forced it.
- Never parallelize work that prompts through
ShouldProcess, writes to a shared file
without synchronization, or depends on ordering.
- Not available in 5.1. Guard it, or state the version requirement.
12. Security
12.1 Secret handling
- Credentials are
[pscredential]. Secrets come fromMicrosoft.PowerShell.SecretManagement. - Never accept
[string]$Password. Take[pscredential]or[securestring]. - Secrets never touch an output stream. Script block logging (Event ID 4104), module
logging, and transcription capture expanded string values, so Write-Verbose "pass: $plain" writes the secret to the event log on every endpoint that runs it. This applies to verbose, debug, warning, error, and progress alike.
- Never pass a secret as a native-command argument. Arguments are visible in the process
table to any local user. Use stdin, an environment variable scoped to the child process, or a temp file with a restrictive ACL.
- Call
.GetNetworkCredential().Passwordat the last possible moment, in the narrowest
scope, and never assign the result to a variable that outlives the call.
SecureStringis not encrypted on non-Windows platforms. Never imply that it is.ConvertTo-SecureString -Key $byteswith the key stored beside the ciphertext is
obfuscation, not encryption. Do not present it as protection.
-AsPlainText -Forceis a smell. If you emit it, justify it in a comment.- No plaintext secrets in source, comments, examples, default parameter values, or tests.
- Prefer managed identity, workload identity, or JEA over any stored credential.
- CI runs secret scanning; a committed secret is a build failure, not a cleanup task.
12.2 Untrusted input
Assume every parameter value is hostile. Banning Invoke-Expression alone is not enough. These are the equivalent surfaces:
| Surface | Rule |
|---|---|
Invoke-Expression | Banned. Use & with an argument array, or splatting. |
[scriptblock]::Create($input) | Banned on any non-literal input. |
Add-Type -TypeDefinition $input | Banned on any non-literal input. |
LDAP -Filter / -LDAPFilter | Escape input; never concatenate raw values into a filter. |
| SQL | Never interpolate untrusted values into -Query. Invoke-Sqlcmd has no true query parameters (-Variable is textual sqlcmd substitution); use Microsoft.Data.SqlClient with SqlParameter for untrusted input. |
| Native command args | Quote deliberately; know when --% applies and the PS 7.3 argument-passing change. |
| Regex patterns | [regex]::Escape() on any user-supplied fragment. |
| Paths | -LiteralPath when the value may contain wildcard characters. |
Also:
- Certificate validation is never bypassed.
-SkipCertificateCheckand-SkipCACheck
require an inline justification. Setting [ServicePointManager]::ServerCertificateValidationCallback = { $true } is banned outright. It disables TLS validation session-wide, far beyond the call that set it.
Import-Clixmlis not a safe data format. It can instantiate arbitrary types; never point
it at untrusted input. Use JSON for interchange.
Expand-Archiveon untrusted archives: validate that each resolved entry path stays inside
the destination directory (zip-slip).
- Temp files use
[System.IO.Path]::GetRandomFileName(), never a predictable name, and get a
restrictive ACL before anything sensitive is written.
- Avoid
Test-Path-then-act on shared paths; the check and the use can race. Act and catch. - Remoting: HTTPS-only WinRM. CredSSP exposes credentials on the target. Solve the double-hop
problem with JEA or resource-based constrained delegation instead.
- Code must behave correctly under Constrained Language Mode; several constructs (class
definitions, most type accelerators, Add-Type) simply won't run in WDAC/AppLocker environments. Fail with a clear message rather than a type-resolution error.
12.3 Supply chain
- PowerShell Gallery is not curated, and typosquatting is trivial. Verify the publisher.
- Pin exact versions in
RequiredModules.ModuleVersionalone permits drift. - Never
Set-PSRepository -InstallationPolicy Trustedon a public repository. - Check
Get-AuthenticodeSignatureon installed modules where signing is expected. - The
Invoke-WebRequest | Invoke-Expressioninstall pattern is banned, including in
documentation and README examples, no matter how many upstream projects recommend it.
- Vendor or hash-verify any script downloaded as part of a build.
12.4 Execution context
#Requires -RunAsAdministratoronly when the function genuinely requires elevation.- Detect privilege rather than assuming it; fail early with an actionable message.
- Request the narrowest scope and permission set that completes the task.
- Signing and execution policy are deployment concerns. Never instruct a user to weaken them
as a workaround.
13. Cross-platform
#Requires -Version 7.0unless Windows PowerShell 5.1 support is explicitly requested.- When targeting both, state which constructs are 5.1-safe (no ternary, no
??,
no parallel ForEach-Object).
Join-Pathover string concatenation.[System.IO.Path]::DirectorySeparatorCharwhen needed.- Flag Windows-only surface: the CIM cmdlets (
Get-CimInstanceships only on Windows), the
registry provider, WMI, and the ActiveDirectory module.
$IsWindows/$IsLinux/$IsMacOSfor branching. These don't exist in 5.1, so guard.- Prefer
Get-CimInstanceover the deprecatedGet-WmiObject.
14. When to use classes
PowerShell classes earn their place for:
- Enums (
enum LogLevel { ... }) - Custom exception types
- Typed models with validation in the constructor
- DSC resources
They are the wrong tool when:
- A
[pscustomobject]with aPSTypeNamewould do, which covers most cases - The type must be reloaded during an interactive session (classes don't reload cleanly)
- Consumers need
using modulesemantics you'd rather not explain
Idiomatic PowerShell is functions plus objects. Do not produce Java-flavored PowerShell: no service-locator patterns, no abstract base classes, no interface hierarchies. Reach for a class when it removes duplication or enforces an invariant you would otherwise have to check by hand.
15. Output, logging, and accessibility
15.1 Presentation
- Never encode meaning in color alone. Pair it with text.
- Respect
$env:NO_COLORand use$PSStylerather than raw ANSI escapes. Write-Progressfor long operations, not redrawn ASCII progress bars.- Structured objects beat aligned columns: screen readers navigate data, not whitespace.
- Error messages state what failed, which input caused it, and what to do next.
- Don't rely on Unicode box-drawing or emoji for meaning; terminals and readers vary.
15.2 Logging for unattended and long-running work
The streams are built for someone watching a console. A script that runs for six hours on a schedule with nobody attached needs a durable record, and Write-Verbose is not one.
- The primary interface stays the object stream. A caller who pipes your output to
Export-Csv should not also have to parse a log file to learn what happened.
- Emit one structured record per event, not prose. JSON per line, carrying UTC timestamp,
severity, the run identifier, and the target object's identity.
- Generate a run identifier once (
[guid]::NewGuid()) and attach it to every record, so a
single invocation can be reconstructed from a shared log.
- Write to the platform's native sink where one exists: the Windows Event Log for scheduled
tasks, or stdout where a container runtime collects it. A bare .log file next to the script is the fallback, not the default. Know the event log surface before choosing: New-WinEvent works in 5.1 and 7 but requires a registered ETW provider manifest; Write-EventLog needs no manifest but exists only in 5.1. From 7, without a registered provider, [System.Diagnostics.EventLog]::WriteEntry() against a registered event source is the plain route.
Start-Transcriptis a troubleshooting tool, never a logging strategy. It captures
everything typed and returned, including anything sensitive that crossed the session.
- Nothing in section 12.1 becomes loggable because the destination is a file.
Write-Progressis not logging. It vanishes, and unattended runs never see it.- Do not grow unbounded files in the script's own directory. Retention belongs to whoever
operates the script, so take a path parameter and document the expectation.
16. Localization
User-facing strings in modules come from Import-LocalizedData, not string literals:
Import-LocalizedData -BindingVariable Strings -FileName Strings.psd1
Write-Verbose ($Strings.ProcessingAccount -f $Identity)with src/en-US/Strings.psd1 holding the defaults. Verbose, warning, and error text are localizable. Parameter names, property names, and PSTypeName values are not, because they are API surface and stay English.
17. Style and file conventions
17.1 Formatting
- 4-space indent, no tabs. OTBS brace style (opening brace on the same line).
- One blank line between logical blocks; none at the start of a scope.
$nullon the left in comparisons:if ($null -eq $result).- Full parameter names, full cmdlet names.
Where-Object, not?. - Comments explain why. The code already says what.
- Lines under ~120 characters. Break by splatting, never by backtick.
- The limit does not apply inside comment-based help. Section 8 requires
realistic .EXAMPLE blocks and exempts them from splatting, and splatting is the only sanctioned way to shorten a line, so enforcing the limit inside help would leave a realistic example with no legal way to comply.
17.2 File encoding
UTF-8 everywhere. The only real decision is the byte order mark, and it is not cosmetic.
- Windows PowerShell 5.1 reads a file with no BOM as ANSI. PowerShell 6 and later assume UTF-8
without one. So a script containing any non-ASCII character, an accented name in help, a currency symbol in a string, a box-drawing character in output, parses correctly in 7 and corrupts in 5.1.
- Code that must run on 5.1: UTF-8 with BOM. Code that targets 7.x only: UTF-8 without BOM.
- The failure is silent. There is no parse error, just wrong characters appearing in output
weeks later, which is exactly why it survives code review.
- Set it once for the repository rather than per file:
# .editorconfig
[*.{ps1,psm1,psd1,ps1xml}]
charset = utf-8-bom # utf-8 if the code is 7.x only
end_of_line = crlf
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true- Add
* text=autoto.gitattributesso line endings normalize instead of churning through
every diff.
- One encoding per repository. Mixed encodings are how this problem starts.
- CI can check this cheaply, and should. Inheriting a repository with mixed encodings is a
known problem with a known fix: Set-PSMDEncoding from PSModuleDevelopment repairs a tree in bulk.
18. Graphical interfaces
One rule outranks everything else in this section: the GUI is a client of the module, never the home of its logic. Every action a window can trigger must be reachable as a function call with the same parameters and the same output. Behavior that exists only behind a button cannot be tested, scheduled, logged, or composed, and a module in that state has failed at its main job.
Packaging has two defensible answers, and the community has run the experiment both ways.
A companion module (PSEngineer.Gui) depending on the core is right when the interface is substantial, drags dependencies the core does not need, or would otherwise stop a cross-platform module loading on Linux.
A single module with per-command platform guards is right for a toolbox where the graphical commands are a handful among many. Note that PSScriptTools split itself by edition and then reverted to exactly this, shipping WPF commands alongside cross-platform ones and handling the platform per command. That is production experience, not theory.
Either way, the requirement is the same: importing the module on Linux must succeed, and calling a Windows-only command there must fail with a clear message rather than a type resolution error. Choose on the size of the interface, not on principle.
18.1 Choosing an approach
| Need | Use |
|---|---|
| Let the user pick from a list | Out-GridView -PassThru, or Out-ConsoleGridView where cross-platform matters |
| A form for one function's parameters | Show-Command |
| Small utility, roughly a dozen controls | WinForms |
| Layout, styling, theming, or data binding | WPF |
| Must run on Linux or macOS | A local web UI or a terminal UI. Not WPF, not WinForms. |
Show-Command deserves a look before anything gets built. It generates a form directly from a function's parameter metadata, so a function written to section 3 of this document already has a working interface at no cost. Reach for a custom window only once you know why that one is not enough.
18.2 WPF
- XAML lives in its own
.xamlfile, loaded with[Windows.Markup.XamlReader]::Load().
A several-hundred-line here-string cannot be opened in a designer and will not be maintained.
- Strip
x:Classand every event handler attribute before loading.XamlReadercannot
resolve code-behind and throws when it finds a reference to it.
- Retrieve controls by name with
$window.FindName('ButtonRun')and attach handlers in
PowerShell with $button.Add_Click({ ... }).
- Name every control you intend to touch. Walking the visual tree to find things is a defect
waiting to happen.
18.3 WinForms
- Set
AutoScaleModeand use anchoring or a layout panel. Fixed pixel positions break on the
high-DPI displays that are now the default.
- Dispose the form when it closes.
- WinForms suits a window of roughly fifteen controls or fewer. Past that, the layout code
costs more to maintain than the XAML would have.
18.4 Responsiveness
The most common defect in PowerShell GUIs is a window that locks up the moment work begins, because the work is running on the thread that draws the window.
- Anything touching the network, a remote system, or the filesystem at scale runs in a
separate runspace.
- Marshal every UI update back to the UI thread:
$window.Dispatcher.Invoke({ ... })in WPF,
$control.Invoke([Action]{ ... }) in WinForms. Touching a control from another thread throws or corrupts state.
- Share state through a synchronized hashtable (
[hashtable]::Synchronized(@{})) handed to
the runspace, not through module-scoped variables.
- Every long operation gets a progress indicator and a cancel path. A greyed-out button with
no feedback is indistinguishable from a crash.
- Verify apartment state rather than assuming it. WPF requires STA, and the default has
differed across Windows PowerShell and PowerShell 7 releases, so set it explicitly in whatever launches the window.
18.5 Behavior
- Call the underlying functions with
-Confirm:$falseand do the confirming in the window.
Two confirmation prompts, one of them a console dialog hidden behind the GUI, is worse than either on its own.
- Surface errors in the window. A GUI that writes to the error stream where nobody is looking
has swallowed the error.
- Section 15 applies here in full. Set
AccessibleNamein WinForms or
AutomationProperties.Name in WPF on every control that carries meaning, keep tab order logical, and give every action a keyboard route.
19. Prose: comments, help, and READMEs
This document produces text as well as code. Comment-based help, inline comments, README files, and changelog entries all ship with the module, and they are where generated work gives itself away fastest.
Write like a competent colleague explaining something in a hurry. The reader is stuck, in a rush, and does not want to be reading this at all.
19.1 Voice
- Second person. "You need write access to the OU for this."
- Contractions are fine. Short sentences are better. The occasional fragment is fine too.
- Say the actual thing. "This breaks if the path has spaces" beats "note that certain path
configurations may present challenges."
- Name the real cmdlet, the real error, the real file. Concrete beats abstract every time.
- State limits flatly. "This does not work on Linux" needs no apology and no softening.
- One good example replaces three paragraphs. Prefer the example.
- Delete the last paragraph. It is nearly always a summary of what was just read.
19.2 Words and shapes to avoid
| Instead of | Write |
|---|---|
| leverage, utilize | use |
| robust, powerful, seamless, comprehensive | what it actually does |
| simply, just, easily | nothing, delete the word |
| In today's fast-paced world | the first real sentence |
| Let's dive in, let's explore | nothing |
| In conclusion, by following these steps | nothing, stop writing |
| It is not just X, it is Y | one clear claim |
| Whether you are a beginner or an expert | nothing |
Also out: em dashes, emoji in headings, rhetorical questions as transitions, three adjectives where one works, and any paragraph that restates the heading above it.
19.3 Comments
- Comment the surprising thing. A workaround, a non-obvious ordering requirement, a bug in
someone else's module with a link to the issue.
- Never restate the code.
# Get the userabove a call toGet-ADUseris noise. - No banner blocks of asterisks or hashes. The function name is already the divider.
- A
# TODOcarries a name and a date, or it does not go in.
19.4 Comment-based help
Section 8 covers what the help must contain. This covers how it should sound. .DESCRIPTION is where modules give themselves away, because it is the only field long enough to hold filler.
Before:
.DESCRIPTION
This powerful function leverages the ActiveDirectory module to seamlessly
retrieve and process user account information, providing a comprehensive
solution for identifying accounts that may require attention.After:
.DESCRIPTION
Finds enabled accounts that have not signed in since the cutoff date and
disables them. You need write access to the OU. Returns a result object for
every account it checked, including the ones it skipped, so you can pipe the
output to Export-Csv and keep a record of the whole run.The second is longer and says more. Length was never the problem. Filler was.
19.5 READMEs
- The first sentence says what the module does. No preamble, no "Welcome to".
- Install command inside the first screen, copy-pasteable, no surrounding explanation.
- One example that produces visible output, early. Show the output.
- Requirements stated plainly: PowerShell version, OS, permissions, modules.
- Skip the badge wall, the table of contents on a two-screen document, and any Features list
made of adjectives.
- A short "Known limitations" section buys more trust than any amount of enthusiasm.
20. Files and reports
20.1 Emitting and exporting are different jobs
A function that queries data emits objects. It does not write files. The caller decides where the data goes:
Get-PSEStaleAccount -InactiveDays 90 | Export-Csv .\stale.csv -NoTypeInformation -Encoding utf8Not Get-PSEStaleAccount -OutputCsv .\stale.csv. The moment a query function owns a file path, it can no longer be piped, filtered, tested without touching disk, or reused by the next script that needs the same data in a different shape.
A dedicated export or report function is fine when the formatting itself is the work, for example Export-PSEAccountReport. It takes objects from the pipeline. It does not do its own querying.
Rules for anything that writes a file:
- Writing a file changes state, so the function declares
SupportsShouldProcess(section 7). - Take a path parameter. Never hardcode one, never default to the current directory silently.
- Set
-Encodingexplicitly on every write. The default differs between Windows PowerShell
and PowerShell 7, and the failure shows up later as mangled non-ASCII characters in someone else's spreadsheet. Use utf8 unless a consumer demands otherwise, and know that the name itself shifts meaning: utf8 writes a BOM in 5.1 and no BOM in 7, where utf8BOM exists if you need one. Section 17.2's reasoning about which consumers need the BOM applies to data files too.
- Timestamps in filenames use a sortable form:
yyyy-MM-dd_HHmmss. State the timezone or use
UTC.
- Return the resulting file object under
-PassThruso the caller can chain. - Do not silently overwrite. Either
ShouldProcesscovers it or a-Forceswitch does.
20.2 CSV
CSV is for handing data to a human with Excel. It is a poor interchange format between scripts, because every value comes back as a string and the types are gone.
-NoTypeInformationonExport-Csv. Required in 5.1; the default behavior since 6.0, so
stating it is harmless in 7 and protects the script when it lands back on 5.1.
- Flatten before exporting. A property holding an array or a nested object serializes as a
type name, which is worse than useless. Use calculated properties to reduce it to a string first.
-Appendagainst a file whose columns have changed produces silent nonsense. If the schema
can vary, write a new file.
- Non-US locales: Excel expects the local list separator.
-UseCulturehandles it, and it
matters more often than people expect.
- Formula injection is a real vulnerability. A field beginning with
=,+,-, or@
is executed as a formula when the file opens in Excel, which turns an exported display name into code execution on someone else's workstation. Prefix any untrusted string field with a single quote, or reject those leading characters. This is the same class of problem as section 12.2 and gets the same treatment.
If the destination really is Excel rather than a generic consumer, consider writing .xlsx directly with the ImportExcel module instead. It preserves types, needs no Excel installed, supports formatting and multiple sheets, and sidesteps both the delimiter problem and formula injection. Optional dependency, so section 10 applies.
20.3 JSON
JSON is the default when a script hands data to another script, another language, or an API.
- Always set
-Depthexplicitly. The default of 2 truncates nested objects, and the
resulting file looks fine until someone reads it. Windows PowerShell 5.1 truncates silently; 7.1 and later at least emit a warning, which unattended runs never see. There is no good reason to leave it unset.
-Compressfor machine consumers, indented for anything a person will open.ConvertFrom-Json -AsHashtable(6.0+, not in 5.1) when you need case-sensitive keys or
want to avoid building PSCustomObject graphs for large payloads.
- Never build JSON by concatenating strings. Build an object or a hashtable and convert it.
- Serialize dates deliberately.
.ToString('o')gives a round-trippable ISO 8601 value with
offset, which is what a consumer in another timezone needs.
- Ordered hashtables (
[ordered]@{}) keep the property order stable, which makes diffs of
generated JSON readable.
- JSON is also the right shape for the structured log records in section 15.2.
20.4 HTML reports
A report is an artifact someone forwards, archives, and opens six months later on a machine with no network. Build for that.
ConvertTo-Html -Fragmentproduces the table. Compose the surrounding document yourself
rather than accepting the default output, which is a bare unstyled page.
- Self-contained, always. CSS goes inline in
-Head. No CDN links, no external
stylesheets, no remote fonts, no script tags pulling from the internet. A report that renders as broken text in an offline Outlook preview has failed, and a remote reference in an emailed file is a tracking beacon whether you intended one or not.
ConvertTo-HtmlHTML-encodes the property values it renders. It does not encode
-PreContent, -PostContent, or -Head. Anything user-supplied that reaches those parameters must be encoded first with [System.Net.WebUtility]::HtmlEncode().
<meta charset="utf-8">in the head, and write the file with-Encoding utf8.- Never feed
Format-Tableoutput into HTML. Pass objects.
Every report carries a provenance block, or it cannot be trusted later:
- Generation time in UTC, with local time alongside if humans read it
- The account and host that produced it
- The script or module version
- The parameters and scope it ran against, including anything excluded
Get-PSWho in PSScriptTools returns most of this in one object: user, elevation, computer, operating system, PowerShell version and edition, host, execution policy, and culture.
Accessibility applies here exactly as in section 15:
- Real
<table>markup with<th scope="col">and a<caption>. Never tables for layout. - Never encode status in color alone. Pair every colored cell with text or a symbol.
- Meet contrast requirements against a white background, since that is what the printer and
the email client will use.
For anything beyond tables, a here-string template with placeholder substitution stays readable far longer than deeply nested ConvertTo-Html calls. A community module such as PSWriteHTML is a reasonable choice for complex output, but it is an optional dependency and section 10 applies: check for it at call time and fail with the install command.
20.5 Markdown
Markdown is now the default report format for anything that lands in a repository, a pull request, a wiki, or a chat channel. It renders everywhere, diffs cleanly, and stays readable as plain text, which HTML does not.
- Reach for markdown before HTML when the audience is technical and the destination is a repo.
Reach for HTML when the report will be emailed or archived for non-technical readers.
- Emit tables from objects.
ConvertTo-Markdownin PSScriptTools handles table and list forms
with pre and post content, which saves hand-building pipe-delimited rows.
- Escape pipe characters in any value that might contain one, or the table silently breaks.
- The provenance block above applies here too, as a footer.
- Write with
-Encoding utf8and end the file with a newline. - Fenced code blocks get a language tag, so
powershellrather than a bare fence. It matters
for rendering and it matters more for anything that later reads the file as context.
Definition of done
Invoke-ScriptAnalyzer -Path .\src -Recurse -Settings .\PSScriptAnalyzerSettings.psd1
Invoke-Pester -Path .\tests -Output Detailed
Get-Help <Function> -Full # examples run unmodified
<Function> -WhatIf # produces useful output, changes nothingAll four clean. No warnings suppressed without an inline justification comment.
Lineage
This document assembles existing consensus rather than inventing a new one. Where it agrees with the sources below, they came first:
- PSScriptAnalyzer's built-in rules, maintained by Microsoft's PowerShell team together
with community maintainers. Roughly a third of this document is mechanically enforced by rules that already ship in that module.
- Microsoft's cmdlet development guidelines, whose Strongly Encouraged and Advisory tiers
are the origin of most of sections 2 through 7.
- The PoshCode "PowerShell Practice and Style" guide, the closest thing the community has
to an agreed style baseline, though it predates PowerShell 7 and Pester v5.
- The Pester project's own documentation for v5 discovery and run phase semantics.
Where those sources disagree with each other, this document picks one and says why in the relevant section. The material on security, accessibility, localization, and writing for generation rather than for reading goes beyond them, and that is where to aim criticism.
Corrections and disputes belong in the issue tracker at powershellengineer.com.
Deeper guidance
Load the matching skill rather than expanding this file. These are planned and not yet published. If one is unavailable, apply this document and state which guidance you lacked:
| Task | Skill |
|---|---|
| New module scaffold | pse-module-scaffold |
| Pester v5 suites | pse-testing |
| Comment-based + about_ help | pse-help-authoring |
| Refactoring inherited scripts | pse-refactor |
| Threat modeling, JEA, signing, hardening review | pse-security |
| 5.1 → 7.x migration | pse-migration |
| Microsoft Graph, Entra, Exchange, AD | pse-domain-<name> |
| WPF and WinForms interfaces | pse-gui |
| CSV, JSON, HTML, and markdown reporting | pse-reporting |
| External help, platyPS, updatable help | pse-help-external |