Type to search 97 articles.

    Practical engineering guidance

    Writing PowerShell that other administrators can safely run

    A script that works is not the same as a script someone else can run at two in the morning. The difference is a small number of habits, and they cost almost nothing to adopt.

    Series: PowerShell toolkit

    • PowerShell
    • Automation
    • Operations

    Most administrative PowerShell is written once, by the person who needed it, and then inherited. The inheritor runs it during an incident, at speed, without having read it.

    Whether that goes well is decided by habits the author adopted or did not. None of them are sophisticated, and together they are the difference between a useful tool and a liability.

    The problem: what goes wrong with inherited scripts

    • It runs against the wrong scope because the target was hard-coded and the name looked right.
    • It makes changes with no way to preview them.
    • It fails halfway and leaves the environment in a state nobody documented.
    • It reports success because nobody checked whether the operation actually worked.
    • It needs credentials, and the credentials are in the file.
    • It ran fine last year and now fails against a changed environment, with an error nobody can interpret.

    Each has a specific, cheap remedy.

    The habits

    1. Parameters, never hard-coded values

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$OrganizationalUnit,
    
        [ValidateRange(1, 365)]
        [int]$InactiveDays = 90,
    
        [ValidateScript({ Test-Path -Path (Split-Path -Path $_ -Parent) })]
        [string]$ReportPath = "$env:TEMP\report.csv"
    )

    [CmdletBinding()] alone gives you -Verbose, -ErrorAction and the rest of the common parameters at no cost. Validation attributes catch a bad argument at the point of invocation rather than halfway through the work, which is exactly when you want to catch it.

    2. Support -WhatIf for anything that changes

    This is the single most valuable habit in the list.

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param([Parameter(Mandatory)][string]$Identity)
    
    if ($PSCmdlet.ShouldProcess($Identity, 'Disable account')) {
        Set-ADUser -Identity $Identity -Enabled $false
    }

    Declaring SupportsShouldProcess and wrapping every mutating operation in ShouldProcess gives the caller -WhatIf and -Confirm for free. Someone inheriting the script can see exactly what it intends to do before it does any of it.

    ConfirmImpact = 'High' makes the script prompt by default for genuinely destructive operations, which the caller can suppress with -Confirm:$false once they are sure.

    A script that changes things without -WhatIf is a script nobody can safely run for the first time.

    3. Separate discovery from action

    Do not let one script find the targets and act on them in the same run. Write two steps: one that produces a list, and one that consumes an approved list.

    # Step one produces the candidates.
    Get-ADUser -Filter 'Enabled -eq $true' -Properties LastLogonDate |
        Where-Object { $_.LastLogonDate -lt (Get-Date).AddDays(-$InactiveDays) } |
        Export-Csv -Path $ReportPath -NoTypeInformation -Encoding UTF8
    
    # Step two acts only on what a human approved.
    Import-Csv -Path $ApprovedPath | ForEach-Object { ... }

    The gap between the two files is a human review, and it is what prevents a filter error becoming an estate-wide change. This is the same discipline as in retiring stale Active Directory accounts, and it applies to any bulk operation.

    4. Handle errors deliberately

    PowerShell’s default is to continue after a non-terminating error, which means a loop happily processes four hundred objects while failing on every one.

    $failures = [System.Collections.Generic.List[object]]::new()
    
    foreach ($item in $items) {
        try {
            Set-Something -Identity $item.Name -ErrorAction Stop
        }
        catch {
            $failures.Add([pscustomobject]@{
                Item   = $item.Name
                Reason = $_.Exception.Message
            })
            Write-Warning "Failed: $($item.Name) - $($_.Exception.Message)"
        }
    }
    
    if ($failures.Count -gt 0) {
        Write-Warning "$($failures.Count) of $($items.Count) failed."
        $failures | Export-Csv -Path $FailurePath -NoTypeInformation -Encoding UTF8
    }

    -ErrorAction Stop inside a try is what makes catch fire for a non-terminating error. Without it, the catch block is decorative — a mistake I have seen in a great deal of otherwise careful code.

    Collecting failures rather than stopping means one bad object does not abandon the run, and the operator gets a list of exactly what needs attention.

    5. Output objects, not text

    # Usable by the next command.
    [pscustomobject]@{
        Name      = $user.SamAccountName
        Enabled   = $user.Enabled
        LastLogon = $user.LastLogonDate
        Action    = 'Disabled'
    }

    Not Write-Host "Disabled $($user.SamAccountName)". An object can be filtered, sorted, exported and piped. Text on the console can only be read, and Write-Host output cannot even be captured.

    Use Write-Verbose for progress narration, Write-Warning for problems that did not stop the run, and the output stream for results.

    6. Never put credentials in the file

    No passwords, no secrets, no tokens, no connection strings containing either. Use a managed identity where the script runs in Azure, a certificate for app-only authentication, or prompt for a credential where the script is interactive. The reasoning is in Microsoft Graph PowerShell: authentication and scopes.

    A script with a credential in it cannot be put in version control, shared with a colleague, or attached to a ticket — which means it will be, and then it cannot be un-shared.

    7. Write comment-based help

    <#
    .SYNOPSIS
        Reports Active Directory accounts inactive beyond a threshold.
    
    .DESCRIPTION
        Read-only. Produces a CSV for review; makes no changes.
        Requires the ActiveDirectory module and read access to the target OU.
    
    .PARAMETER OrganizationalUnit
        Distinguished name of the OU to search.
    
    .EXAMPLE
        .\Get-InactiveAccounts.ps1 -OrganizationalUnit 'OU=Staff,DC=contoso,DC=com' -InactiveDays 120
    
    .NOTES
        LastLogonDate is replicated but imprecise; it is unsuitable for windows under 30 days.
    #>

    Get-Help then works on your script exactly as it does on a cmdlet. The .NOTES section is where the inheritor finds the caveat that would otherwise have to be rediscovered.

    State plainly whether the script changes anything. That one sentence is what the person at two in the morning most needs.

    8. Declare requirements

    #requires -Version 5.1
    #requires -Modules ActiveDirectory
    #requires -RunAsAdministrator

    A clear failure at the first line beats a confusing one at line ninety.

    Validation: how to know the script is ready

    Before handing a script to anyone else, check:

    • It runs with -WhatIf and describes what it would do, accurately.
    • It fails cleanly with a bad parameter value, naming the problem.
    • It handles an object that does not exist without abandoning the run.
    • Get-Help returns something useful.
    • It contains no credentials.
    • Someone else can run it from the help alone, without asking you a question.

    That last test is the real one, and it is worth doing literally — hand it to a colleague and watch.

    Extension

    Once a script is shaped this way, it becomes a building block. Wrap it in a function, put related functions in a module, keep the module in version control, and the next person inherits a tool rather than a file.

    Version control matters more than it appears to. A script in a repository has history, review and a record of why a change was made. A script on a share has none of those, and the copy somebody edited locally is the one that will be run.

    Verification and limits

    The language features used — CmdletBinding, SupportsShouldProcess and ShouldProcess, parameter validation attributes, #requires statements, comment-based help and error handling with -ErrorAction Stop — are standard PowerShell and were checked against current Microsoft documentation on 20 September 2026. The habits and their ordering are my own practice.

    The examples are illustrative fragments rather than complete tested scripts, and they were not executed for this article. -WhatIf only works if ShouldProcess is implemented correctly and wraps every mutating call — a script that declares SupportsShouldProcess and then changes something outside a ShouldProcess block gives a false assurance, which is worse than none. Test that specifically.

    References