PowerShell passes .NET objects through its pipeline, not text. When you run Get-Process, you get back actual Process objects with typed properties — Name, Id, CPU, WorkingSet. You don't parse output; you just access properties. This is the single most important thing to internalise.
PowerShell is the primary management interface for the entire Microsoft ecosystem: Windows Server, Active Directory, Azure, Microsoft 365, Exchange, Hyper-V, and more. If you work anywhere near Microsoft infrastructure, this is not optional.
| Feature | cmd.exe | bash | PowerShell |
|---|---|---|---|
| Pipeline output | Text strings | Text strings | .NET objects with typed properties |
| Cross-platform | Windows only | Linux/macOS | Windows, Linux, macOS (PS 7+) |
| Command naming | Inconsistent | Inconsistent | Verb-Noun — Get-Process, Set-Item, Remove-ADUser |
| AD / Windows admin | Limited | Via extra tools | Native, deeply integrated |
| Error handling | Exit codes only | Exit codes | Try/Catch, typed exceptions, -ErrorAction |
Every cmdlet follows Verb-Noun: Get-Service, Start-Service, Stop-Service, Restart-Service. If you know the noun, you can guess the verb. If you know the verb, you can find everything it applies to.
# Discover commands — your first stop when you don't know what exists Get-Command *service* # All cmdlets with "service" in the name Get-Command -Verb Get # Everything that retrieves something Get-Command -Module ActiveDirectory # All AD cmdlets # Built-in help — comprehensive, with examples Get-Help Get-Process -Examples # Just show usage examples Get-Help Get-Process -Full # Everything, including parameter details Update-Help # Download latest help files (run once)
The | operator passes the complete output of one command as input to the next. Because it's objects, every downstream command can access any property by name — no awk, no cut, no regex to extract fields.
# Filter, sort, select — all working on live object properties Get-Process | Where-Object { $_.CPU -gt 100 } | Sort-Object CPU -Descending # $_ is "the current object" — it carries all properties of whatever is in the pipe Get-Process | Select-Object Name, Id, CPU, WorkingSet | Format-Table # Get stopped services and start them — action directly on the piped objects Get-Service | Where-Object { $_.Status -eq "Stopped" } | Start-Service
| Cmdlet | What it does | Quick example |
|---|---|---|
| Where-Object | Keep only objects matching a condition | Where-Object { $_.Name -like "SQL*" } |
| Select-Object | Keep specific properties, or first/last N | Select-Object Name, CPU -First 10 |
| Sort-Object | Sort by a property | Sort-Object CPU -Descending |
| Group-Object | Group objects sharing a property value | Group-Object Status |
| Measure-Object | Count, sum, average, min, max | Measure-Object -Property CPU -Average |
| ForEach-Object | Run a script block once per object | ForEach-Object { Write-Host $_.Name } |
# PowerShell uses word operators, not symbols (except -not) -eq equal $x -eq 5 -ne not equal $x -ne 0 -gt greater than $x -gt 100 -lt less than $x -lt 50 -like wildcard match $name -like "SQL*" -match regex match $name -match "^DC-[0-9]+" -in value in array $role -in @("Admin", "Manager") -and logical AND ($x -gt 0) -and ($x -lt 100) -or logical OR ($x -eq 0) -or ($x -gt 50) -not logical NOT -not $enabled
Variables start with $ and are dynamically typed. PowerShell infers the type from the assigned value, but you can cast explicitly. The type determines what properties and methods are available on the object.
# Basic assignment — PS infers the type $name = "Alice Smith" # [string] $count = 42 # [int] $enabled = $true # [bool] $date = Get-Date # [datetime] # Explicit type cast [int]$port = "389" # string "389" becomes integer 389 [datetime]$when = "2024-01-15" # Check type at any time $name.GetType() # IsPublic: True, Name: String # String interpolation: variables expand in DOUBLE quotes, NOT single quotes $user = "asmith" Write-Host "Creating: $user" # Output: Creating: asmith Write-Host 'Creating: $user' # Output: Creating: $user (literal) # For expressions inside strings, wrap in $(...) Write-Host "Free: $([math]::Round($disk.FreeSpace/1GB, 1)) GB"
# Arrays — ordered list, zero-indexed $servers = @("DC-01", "DC-02", "FILE-01") $servers[0] # "DC-01" $servers[-1] # "FILE-01" (last item) $servers.Count # 3 $servers += "SQL-01" # Append (creates a new array internally) # Hashtables — key/value pairs $config = @{ Server = "DC-01.corp.com" Port = 389 UseSSL = $false } $config.Server # "DC-01.corp.com" $config["Port"] # 389 $config["Timeout"] = 30 # Add a new key dynamically $config.Keys # Server, Port, UseSSL, Timeout
if ($obj -ne $null) or simply if ($obj). Calling a method on a null reference throws a NullReferenceException — the most common crash in new PS scripts.# if / elseif / else if ($diskFreeGB -lt 10) { Write-Warning "Critical: only ${diskFreeGB}GB remaining" } elseif ($diskFreeGB -lt 25) { Write-Host "Warning: disk space getting low" } else { Write-Host "Disk OK" } # switch — cleaner than chained elseif for multiple discrete values switch ($env:COMPUTERNAME) { "DC-01" { Write-Host "Primary DC" } "DC-02" { Write-Host "Secondary DC" } Default { Write-Host "Member server" } }
# foreach — iterate a collection (the most common loop in PS scripts) foreach ($server in $servers) { $up = Test-Connection -ComputerName $server -Count 1 -Quiet Write-Host "$server : $(if ($up) { 'UP' } else { 'DOWN' })" } # for — use when you need an index for ($i = 0; $i -lt $servers.Count; $i++) { Write-Host "[$i] $($servers[$i])" } # while — keep going until a condition becomes false $tries = 0 while ($tries -lt 3) { if (Test-Connection "DC-01" -Count 1 -Quiet) { break } $tries++ Start-Sleep 5 }
try { # -ErrorAction Stop makes the error terminating so catch fires $user = Get-ADUser -Identity "missing-user" -ErrorAction Stop } catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { Write-Warning "User not found in AD" } catch { Write-Error "Unexpected error: $_" } finally { Write-Host "This runs regardless of success or failure" }
-ErrorAction Stop to force the error to become terminating. Alternatively, set $ErrorActionPreference = 'Stop' at the top of a script to make all errors terminating globally.function Get-DiskReport { param( [string]$ComputerName = "localhost", # Default value [int]$ThresholdGB = 20 ) $disks = Get-CimInstance Win32_LogicalDisk ` -ComputerName $ComputerName ` -Filter "DriveType=3" # Fixed drives only foreach ($disk in $disks) { $freeGB = [math]::Round($disk.FreeSpace / 1GB, 1) [PSCustomObject]@{ # Return structured objects, not text Computer = $ComputerName Drive = $disk.DeviceID FreeGB = $freeGB Status = if ($freeGB -lt $ThresholdGB) { "WARNING" } else { "OK" } } } } Get-DiskReport -ComputerName "FILE-01" -ThresholdGB 50 Get-DiskReport | Where-Object Status -eq WARNING # Pipeable just like built-in cmdlets
function Set-MaintenanceMode { param( [Parameter(Mandatory)] # PS prompts if not supplied [string]$ComputerName, [Parameter(Mandatory)] [ValidateSet("Enable", "Disable")] # Only these two values accepted [string]$Action, [ValidateRange(1, 480)] # Must be 1-480 minutes [int]$DurationMinutes = 60 ) Write-Host "$Action on $ComputerName for $DurationMinutes min" } # Tab-completion works for ValidateSet — type "En" then Tab gets "Enable" Set-MaintenanceMode -ComputerName DC-01 -Action Enable -DurationMinutes 30
Write-Host sends text directly to the console — it does NOT go into the pipeline. Write-Output (or just placing the value on its own line) puts the object into the pipeline so it can be piped to Export-Csv, Format-Table, etc. Use Write-Host only for status messages. Use Write-Output (or implicit output) for data your function produces.PowerShell scripts are .ps1 files. By default, Windows blocks script execution via Execution Policy — a safety measure (not a security boundary) to prevent accidental execution of untrusted scripts.
# Check what's set at each scope Get-ExecutionPolicy -List # RemoteSigned is the practical choice: local scripts run freely, # downloaded scripts require a digital signature Set-ExecutionPolicy RemoteSigned -Scope CurrentUser # For CI/CD pipelines and automation: bypass for a single process invocation # (does NOT change the system setting) powershell.exe -ExecutionPolicy Bypass -File "C:\scripts\deploy.ps1"
#Requires -Modules ActiveDirectory # Fail early if module missing #Requires -RunAsAdministrator # Fail early if not elevated param( [Parameter(Mandatory)] [string]$CsvPath ) # Import-Csv gives you objects — one per row, properties from headers $users = Import-Csv $CsvPath # CSV columns: FirstName,LastName,Department foreach ($u in $users) { $sam = ($u.FirstName[0] + $u.LastName).ToLower() $upn = "$sam@corp.example.com" $ou = "OU=$($u.Department),OU=Users,DC=corp,DC=example,DC=com" try { New-ADUser ` -Name "$($u.FirstName) $($u.LastName)" ` -SamAccountName $sam ` -UserPrincipalName $upn ` -Path $ou ` -AccountPassword (ConvertTo-SecureString "Welcome1!" -AsPlainText -Force) ` -ChangePasswordAtLogon $true -Enabled $true ` -ErrorAction Stop Write-Host "[OK] $sam" -ForegroundColor Green } catch { Write-Warning "[FAIL] $sam -- $_" } }
PowerShell Remoting (WinRM/WSMan) lets you run commands on remote machines over an encrypted channel. You can target one machine interactively, or fan out to dozens in parallel and collect all results back in your local session.
# Enable remoting (run as admin on the target) Enable-PSRemoting -Force # Interactive shell on a remote machine (like SSH) Enter-PSSession -ComputerName "DC-01" -Credential (Get-Credential) # Type 'exit' to return to your local session # Run a command remotely and get the result back as an object Invoke-Command -ComputerName "DC-01" -ScriptBlock { Get-Service NTDS } # Fan out to MULTIPLE machines in parallel — all run simultaneously $dcs = @("DC-01", "DC-02", "DC-03") Invoke-Command -ComputerName $dcs -ScriptBlock { [PSCustomObject]@{ Server = $env:COMPUTERNAME ADStatus = (Get-Service NTDS).Status Uptime = (Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime } } | Sort-Object Server | Format-Table
Enter-PSSession for interactive troubleshooting. Use Invoke-Command in scripts — it returns objects back to your local session, supports multiple targets simultaneously, and does not require staying connected.# Create a reusable session — avoids reconnect overhead for multiple commands $s = New-PSSession -ComputerName "EXCH-01" -Credential (Get-Credential) Invoke-Command -Session $s -ScriptBlock { Get-Mailbox -ResultSize 5 } Invoke-Command -Session $s -ScriptBlock { Get-TransportService } # Always clean up when done Remove-PSSession $s Get-PSSession | Remove-PSSession # Close all open sessions
Modules are packages of cmdlets, functions, and resources. The PowerShell Gallery hosts thousands of modules. One command installs them. The key sysadmin modules unlock management of Azure, Microsoft 365, AD, Hyper-V, DNS, DHCP, and more.
# Trust the Gallery first (one-time setup) Set-PSRepository -Name PSGallery -InstallationPolicy Trusted # Find, install, and import Find-Module -Name Az # Search Install-Module -Name Az -Scope CurrentUser # Install (no admin needed) Import-Module Az.Accounts # Load into session Get-Module -ListAvailable # All installed modules
| Module | Manages | How to get |
|---|---|---|
| ActiveDirectory | AD users, groups, OUs, computers, GPOs | RSAT feature (Windows) or Install-Module |
| Az | All of Azure — VMs, networking, storage, Entra ID | Install-Module Az |
| Microsoft.Graph | Microsoft 365, Entra ID, Teams, SharePoint | Install-Module Microsoft.Graph |
| Hyper-V | VMs, virtual switches, checkpoints on Hyper-V hosts | Built-in Windows Server feature |
| PSWindowsUpdate | Windows Update management and reporting, remotely | Install-Module PSWindowsUpdate |
| ImportExcel | Read and write Excel files without Excel installed | Install-Module ImportExcel |
# A module is a .psm1 file placed in a Modules directory # Path: Documents\PowerShell\Modules\MyTools\MyTools.psm1 function Get-ServerHealth { param([string]$ComputerName) # ... } function Send-AlertEmail { param([string]$To, [string]$Subject) # ... } function _InternalHelper { # private — not exported } # Only expose what users should call Export-ModuleMember -Function Get-ServerHealth, Send-AlertEmail # Now Import-Module MyTools makes Get-ServerHealth and Send-AlertEmail available # _InternalHelper is hidden from callers $env:PSModulePath # See where PS looks for modules
# Bulk password reset from a text file of SAM account names Get-Content "C:\lists\reset-users.txt" | ForEach-Object { Set-ADAccountPassword -Identity $_ ` -NewPassword (ConvertTo-SecureString "Temp@Reset1!" -AsPlainText -Force) ` -Reset Set-ADUser -Identity $_ -ChangePasswordAtLogon $true Write-Host "Reset: $_" } # Stale account report — enabled users inactive for 90+ days Search-ADAccount -AccountInactive -TimeSpan 90.00:00:00 -UsersOnly | Where-Object Enabled | Select-Object Name, SamAccountName, LastLogonDate, DistinguishedName | Export-Csv "C:\reports\stale-$(Get-Date -Format yyyyMMdd).csv" -NoTypeInformation # Who is in privileged groups? Export to CSV. @("Domain Admins", "Enterprise Admins", "Schema Admins") | ForEach-Object { $grp = $_ Get-ADGroupMember -Identity $grp -Recursive | Select-Object Name, SamAccountName, @{N="Group";E={$grp}} } | Export-Csv "C:\reports\privileged-members.csv" -NoTypeInformation -Append
# Get all servers from AD, check health in parallel $servers = Get-ADComputer -Filter * ` -SearchBase "OU=Servers,DC=corp,DC=example,DC=com" | Select-Object -ExpandProperty Name Invoke-Command -ComputerName $servers -ErrorAction SilentlyContinue -ScriptBlock { $os = Get-CimInstance Win32_OperatingSystem $disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'" [PSCustomObject]@{ Server = $env:COMPUTERNAME UptimeDays = [math]::Round(($os.LocalDateTime - $os.LastBootUpTime).TotalDays, 1) MemFreeGB = [math]::Round($os.FreePhysicalMemory / 1MB, 1) DiskFreeGB = [math]::Round($disk.FreeSpace / 1GB, 1) } } | Sort-Object DiskFreeGB | Format-Table -AutoSize
# Register a daily 6am scheduled task — runs as a gMSA (no stored password) $action = New-ScheduledTaskAction ` -Execute "pwsh.exe" ` -Argument "-NonInteractive -File C:\scripts\daily-report.ps1" $trigger = New-ScheduledTaskTrigger -Daily -At 6am $settings= New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 1) Register-ScheduledTask ` -TaskName "Daily AD Report" ` -Action $action ` -Trigger $trigger ` -Settings $settings ` -RunLevel Highest ` -User "CORP\svc-reports$" # gMSA — no password to manage