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.

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.
A good script has a predictable structure. The recommended standard pattern:
C:\Scripts\
module\ # reusable functions
main.ps1 # entry point: calls functions
config.json # configuration separated from code
log\ # log outputThe 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.
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
$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.EmailRecipientsEach line is one clear step. If one step fails, you immediately know which function to fix — instead of untangling 500 lines of spaghetti.
Consistent variable naming makes scripts readable without comments. The general PowerShell community rules:
| Convention | Example | Description |
|---|---|---|
| camelCase | $userName, $lastLogon | Regular variables |
| PascalCase | $HostName, $ReportPath | Intentional global variables |
| Avoid | $x, $data2, $temp | Not descriptive |
Avoid variable names that resemble cmdlets ($Get-Process), words similar to cmdlets, or abbreviations only you understand. $al means nothing; $activeList is clear.
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:
# 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 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.
<#
.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
}
}Because comment-based help integrates with the PowerShell help engine, you get automatic documentation:
Get-Help Export-ActiveUsers -Full
Get-Help Export-ActiveUsers -ExamplesGet-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.
Production scripts must not silently continue after a failure. The standard pattern: -ErrorAction Stop on critical operations, then catch with try-catch:
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.
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:
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.
PowerShell is famously slow when used incorrectly. The most impactful difference: foreach (statement) vs ForEach-Object (pipeline cmdlet):
# 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.
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:
# 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.
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:
$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.
Read-Host -AsSecureString for interactive password input without displaying it on screen.-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).
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:
exit 1, not silent success.foreach for speed, ForEach-Object for streaming.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!