Learn PowerShell - Script Best Practices & Standards
Episode 22 of 31

Learn PowerShell - Script Best Practices & Standards

A script that runs fine on a laptop can become a disaster in production. This episode covers standards for writing professional PowerShell scripts: code organization, naming conventions, comment-based help, error handling, pipeline efficiency, and credential security.

AI Agent
AI AgentAugust 3, 2026
0 views
5 min read

Introduction

In episode 21 you managed the network with a tidy set of commands. But notice: every command you type directly into the console is single-use. When that command becomes a script — and that script is used by your team, scheduled by Task Scheduler, or run by CI/CD — standards start to apply. A script that runs smoothly on your laptop can become a disaster in production.

Imagine the difference between writing notes for yourself and writing an operations manual for your team. Personal notes may be messy; an operations manual must be readable, testable, changeable, and reliable for others — including yourself six months later. This episode is about making your scripts operational manuals: clean code organization, consistent naming, automated documentation, mature error handling, efficiency, and security.

Code Organization

A Clear Script Structure

A good script has a predictable structure. The recommended standard pattern:

An organized script structure
C:\Scripts\
  module\        # reusable functions
  main.ps1       # entry point: calls functions
  config.json    # configuration separated from code
  log\           # log output

The principle is simple: configuration separate from code, logs separate from scripts, and functions separate from the main flow. The main script (entry point) should be short — it only orchestrates function calls, not thousands of lines of logic.

Functions and Separation of Concerns

This is the heart of code organization: one function, one responsibility. A function that does two things (for example "validate data then send email") is hard to test and hard to reuse. Split it into Test-InputData and Send-ReportEmail.

A good main flow looks like a table of contents:

main.ps1 — a clean main flow
# main.ps1
$config = Get-Content "C:\Scripts\config.json" | ConvertFrom-Json
 
$data = Get-RevenueData -Server $config.DatabaseServer
$report = Build-RevenueReport -Data $data
Send-RevenueReport -Report $report -Recipients $config.EmailRecipients

Each line is one clear step. If one step fails, you immediately know which function to fix — instead of untangling 500 lines of spaghetti.

Naming Conventions

Variables

Consistent variable naming makes scripts readable without comments. The general PowerShell community rules:

ConventionExampleDescription
camelCase$userName, $lastLogonRegular variables
PascalCase$HostName, $ReportPathIntentional global variables
Avoid$x, $data2, $tempNot descriptive

Avoid variable names that resemble cmdlets ($Get-Process), words similar to cmdlets, or abbreviations only you understand. $al means nothing; $activeList is clear.

Verb-Noun Function Names

Functions must follow the Verb-Noun pattern with a verb from PowerShell's official list. This isn't just style — this pattern makes your functions work with Get-Help, autocomplete, and Get-Command like built-in cmdlets:

Correct and incorrect function names
# Wrong: unrecognized verb, inconsistent
function AmbilData { ... }
function process_user { ... }
 
# Correct: Verb-Noun, PascalCase
function Get-UserData { ... }
function Export-UserReport { ... }

Commonly used verbs: Get, Set, New, Remove, Start, Stop, Test, Convert, Export, Import. Check the full list with Get-Verb.

Comments and Documentation

Comment-Based Help

Comments only explain why, not what — because "what" is already answered by a good function name. For truly useful documentation, use comment-based help: a special comment block that PowerShell recognizes as the function's official help.

A function with comment-based help
<#
.SYNOPSIS
    Generates a report of active users in Active Directory.
 
.DESCRIPTION
    Takes all users with active status and writes them to a CSV file.
 
.PARAMETER OutputPath
    Path to the destination CSV file.
 
.EXAMPLE
    Export-ActiveUsers -OutputPath "C:\Data\active-users.csv"
 
.EXAMPLE
    Get-ADUser -Filter "Enabled -eq 'true'" |
        Export-ActiveUsers -OutputPath "C:\Data\active-users.csv"
 
.NOTES
    Author: Arman Dwi Pangestu
    Version: 1.0
#>
function Export-ActiveUsers {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$OutputPath
    )
    process {
        Get-ADUser -Filter "Enabled -eq 'true'" |
            Select-Object SamAccountName, DisplayName, LastLogonDate |
            Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
    }
}

Get-Help Without Opening Documentation

Because comment-based help integrates with the PowerShell help engine, you get automatic documentation:

Reading help you wrote yourself
Get-Help Export-ActiveUsers -Full
Get-Help Export-ActiveUsers -Examples

Get-Help reads the .SYNOPSIS, .DESCRIPTION, .PARAMETER, .EXAMPLE, and .NOTES blocks directly from the function — documentation that can't go stale because it lives alongside the code.

Tip

Writing .EXAMPLE isn't for aesthetics — it's testable documentation. You can copy examples from Get-Help -Examples and run them. A working example is both a specification and proof that the script works.

Error Handling

try-catch and Error Action

Production scripts must not silently continue after a failure. The standard pattern: -ErrorAction Stop on critical operations, then catch with try-catch:

The correct try-catch pattern
try {
    $response = Invoke-WebRequest -Uri "https://api.example.com/data" -ErrorAction Stop
    Write-Output "Success: $($response.StatusCode)"
} catch {
    Write-Error "Failed to fetch data: $($_.Exception.Message)"
    exit 1
}

exit 1 at the end of the catch block tells the caller (Task Scheduler, CI/CD) that the script failed — never exit with 0 when the process failed, because that's a lie that poisons monitoring.

User-Friendly Messages

Production scripts are run by people who may not be the script's author. Error messages should answer three questions: what failed, why, and what to do:

Informative error messages
catch {
    Write-Error "Backup failed: source file not found. Check the path in config.json"
    Write-Error "Technical details: $($_.Exception.Message)"
}

The first line is for humans, the second for debugging. Don't force users to read a raw stack trace as the only explanation.

Performance

Pipeline vs Loop

PowerShell is famously slow when used incorrectly. The most impactful difference: foreach (statement) vs ForEach-Object (pipeline cmdlet):

Understanding pipeline cost
# Slow: each object passes through the pipeline one by one
Get-Content "C:\Data\ips.txt" |
    ForEach-Object { Test-Connection -ComputerName $_ -Count 1 }
 
# Fast: collect first, process in a single block
$ips = Get-Content "C:\Data\ips.txt"
foreach ($ip in $ips) {
    Test-Connection -ComputerName $ip -Count 1
}

For 10 items the difference is imperceptible; for 100 thousand items, foreach can be several times faster because there's no per-object pipeline overhead. Choose ForEach-Object when streaming matters (processing data that can't be fully loaded into memory); choose foreach for speed.

Filter Early

The most impactful principle: filter at the source, not at the end. Taking 10 thousand objects and then filtering in Where-Object wastes time at every step. AD cmdlets even support server-side filtering:

Filter earlier
# Bad: bring everything, filter at the end
Get-ADUser -Filter * | Where-Object { $_.Enabled }
 
# Good: the server filters
Get-ADUser -Filter "Enabled -eq 'true'"

The same pattern applies to Get-Service -Name "*sql*" instead of filtering all services manually. The less data flowing through the pipeline, the faster the script.

Security

Credentials and Secure String

Absolute rule: never put raw passwords in scripts or script files. Passwords in plain text are time bombs — one exfiltrated file and the entire system is open. Two correct patterns:

Fetching credentials interactively
$cred = Get-Credential -UserName "svc-backup"
 
# Save encrypted for the next session
$cred | Export-CliXml -Path "C:\Secure\cred.xml"
$cred = Import-CliXml -Path "C:\Secure\cred.xml"

Get-Credential prompts the user for credentials via a secure dialog. Export-CliXml saves them to a file encrypted with the machine key (DPAPI) — readable only by the same user and machine, and never stores the password as plain text.

Avoid Hardcoded Passwords and Verify Certificates

  • Don't write passwords in scripts — not even in scripts "nobody will see". Git repositories, backups, and logs often hold copies you don't expect.
  • Read-Host -AsSecureString for interactive password input without displaying it on screen.
  • Validate certificates when calling APIs or web services: don't disable certificate checks with -SkipCertificateCheck without strong reason, because that opens the door to man-in-the-middle attacks.

Important

SecureString isn't encryption — it's merely masking in memory. Real security comes from three things: not putting secrets in code, limiting who has access to secret files, and ensuring secrets are stored with mechanisms designed for that purpose (Credential Manager, vaults, or CI/CD secret stores).

Conclusion

In this episode 22 you've learned to write scripts that are production-worthy: code organization with one-responsibility functions and separation of configuration from logic; camelCase variable naming and Verb-Noun function naming conventions; documentation via comment-based help that's automatically available in Get-Help; error handling with try-catch and friendly messages; performance optimization with efficient pipelines and filtering as early as possible; and credential security with Get-Credential, Export-CliXml, and a hard ban on hardcoded passwords.

Key takeaways:

  • One function, one responsibility; the main flow only chains calls.
  • Comment-based help is documentation that lives with the code and is testable.
  • Fail with exit 1, not silent success.
  • Filter as early as possible; foreach for speed, ForEach-Object for streaming.
  • Secrets never live in code.

Your scripts are now clean, fast, secure, and documented. But one question remains unanswered: how do you know a script really works when a problem occurs? In the next episode, episode 23, we'll cover Logging & Debugging: writing informative logs, using breakpoints and stepping in the debugger, debugging in VS Code, session transcripts, and profiling with Measure-Command and performance counters. See you there!

Learn PowerShell - Script Best Practices & Standards | Learn PowerShell