If you write PowerShell scripts, whether for automation, system administration, or any other purpose, writing professional code means more than just getting it to work. It means validating your code, documenting it properly, ensuring compatibility across environments, and following standards that make your scripts maintainable and portable.

In this blog post, I will cover five essential aspects of professional PowerShell development:

  • Structuring and documenting your code - How to organize your scripts with regions, use comment-based help, write effective comments, and leverage code snippets.
  • Documentation and versioning - How to create script header comments that provide metadata, version history, requirements, and links for your scripts.
  • Code validation with PSScriptAnalyzer - How to catch errors, enforce best practices, and ensure your scripts are secure using the industry-standard static code analyzer.
  • Constrained Language Mode compatibility - How to write scripts that work reliably in enterprise environments with strict security policies like WDAC and AppLocker.
  • File encoding and line endings - How to configure your files for cross-platform compatibility and seamless version control.

By the end of this post, you will have the knowledge to produce PowerShell code that is not only functional but well-documented, secure, compatible, and ready for any environment.

Why you should validate your PowerShell code

Before diving into the how-to, let me explain why validating your PowerShell code is important:

Catch errors early. PSScriptAnalyzer can detect issues like undefined variables, missing cmdlet parameters, or incorrect syntax before you run your script. This saves time and prevents unexpected failures in production.

Follow best practices. The tool enforces coding standards and best practices, making your code more readable, maintainable, and consistent.

Improve security. PSScriptAnalyzer includes security rules that help identify potential vulnerabilities, such as using plain text passwords or invoking expressions dynamically.

Ensure compatibility. Some rules check for compatibility issues across different PowerShell versions and platforms, helping you write portable scripts.

Professionalism. Clean, validated code demonstrates professionalism and attention to detail, which is especially important when sharing code with colleagues or the community.

Structuring and documenting your code

Well-structured and well-documented code is easier to understand, maintain, and debug. This section covers how to organize your scripts using regions, document your code with comment-based help and inline comments, and leverage Visual Studio Code snippets to scaffold common patterns quickly.

Using regions to organize code

PowerShell supports #region and #endregion directives to create collapsible sections in your code. This is a PowerShell feature (not just a Visual Studio Code feature) that works in Visual Studio Code, PowerShell ISE, and other editors that support code folding.

Regions are particularly useful for organizing large scripts:

#region Parameters and Variables
$LogPath = "C:\Logs\Script.log"
$MaxRetries = 3
$Timeout = 30
#endregion

#region Helper Functions
function Write-Log {
    param([string]$Message)
    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    "$timestamp - $Message" | Out-File -FilePath $LogPath -Append
}

function Test-Connection {
    param([string]$Target)
    # Connection test logic
}
#endregion

#region Main Script Logic
Write-Log "Script started"
# Main processing code here
Write-Log "Script completed"
#endregion

Using regions to organize PowerShell code

In Visual Studio Code, you can collapse regions by clicking the arrow next to the #region line, or use keyboard shortcuts:

ActionPrimary shortcutAlternative shortcut
Fold regionCtrl + Shift + [Ctrl + K, Ctrl + [
Unfold regionCtrl + Shift + ]Ctrl + K, Ctrl + ]
Fold all regionsCtrl + K, Ctrl + 0
Unfold all regionsCtrl + K, Ctrl + J
Visual Studio Code region folding shortcuts

Visual Studio Code snippets for PowerShell

Visual Studio Code with the PowerShell extension includes many built-in snippets that help you quickly scaffold common code patterns. Type the snippet prefix and press Tab to expand.

SnippetDescription
comment-helpComplete comment-based help template
region#region / #endregion block
Documentation and structure snippets
SnippetDescription
functionBasic function with param block
function-advancedAdvanced function with [CmdletBinding()] and full parameter attributes
function-inlineInline function definition
Function snippets

To discover all available snippets, press Ctrl + Space in a PowerShell file to open the IntelliSense suggestions, or type a partial snippet name and browse the list.

You can also create custom snippets in Visual Studio Code for your own templates. Go to File > Preferences > Configure User Snippets > powershell.json and add your templates.

Script header comments

For standalone scripts (not functions), include a header comment block at the top of the file. This serves multiple purposes:

  • Enables Get-Help support - Users can run Get-Help .\YourScript.ps1 -Full to see the synopsis, description, and examples, just like with cmdlets.
  • Self-documenting - Anyone opening the script immediately understands its purpose, requirements, and history.
  • Maintenance tracking - The .NOTES section provides a natural place for version history and change logs.
  • Requirements documentation - Clearly states prerequisites like PowerShell version, modules, or permissions needed.

Here is an example of a script header:

<#
.SYNOPSIS
    Daily backup script for user profile data.

.DESCRIPTION
    This script creates compressed backups of user profile folders and stores
    them on the backup server. It runs daily via scheduled task and maintains
    the last 30 days of backups.

.NOTES
    File Name  : Backup-UserProfiles.ps1
    Author     : Your Name
    Created    : 2025-01-01
    Modified   : 2025-06-15
    Version    : 2.1

    Requirements:
    - PowerShell 5.1 or later
    - Write access to \\backup\profiles$
    - Run as scheduled task with appropriate permissions

    Change Log:
    v2.1 - Added email notification on failure
    v2.0 - Switched to 7-Zip compression for better ratios
    v1.0 - Initial release

.LINK
    https://internal-wiki/backup-procedures
#>

#Requires -Version 5.1
#Requires -RunAsAdministrator

# Script code begins here...

Script header comments

Comment-based help for functions

PowerShell supports a special comment syntax that provides built-in help for your functions. When you add comment-based help, users can run Get-Help on your function just like they would for any built-in cmdlet. PSScriptAnalyzer’s PSProvideCommentHelp rule encourages this practice.

Here is a template for comment-based help:

function Get-SystemInfo {
    <#
    .SYNOPSIS
        Retrieves basic system information from a computer.

    .DESCRIPTION
        The Get-SystemInfo function collects and returns basic system information
        including computer name, operating system, and memory details. This function
        can be used for inventory or troubleshooting purposes.

    .PARAMETER ComputerName
        The name of the computer to query. Defaults to the local computer.

    .PARAMETER IncludeMemory
        If specified, includes detailed memory information in the output.

    .EXAMPLE
        Get-SystemInfo
        Returns system information for the local computer.

    .EXAMPLE
        Get-SystemInfo -ComputerName "SERVER01" -IncludeMemory
        Returns system information including memory details for SERVER01.

    .INPUTS
        System.String
        You can pipe computer names to this function.

    .OUTPUTS
        PSCustomObject
        Returns a custom object with system information properties.

    .NOTES
        Author: Your Name
        Version: 1.0
        Date: 2025-01-01

    .LINK
        https://your-documentation-url.com
    #>
    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline = $true)]
        [string]$ComputerName = $env:COMPUTERNAME,

        [switch]$IncludeMemory
    )

    # Function implementation here
}

Example of comment-based help in PowerShell

The most commonly used comment-based help keywords are:

KeywordPurpose
.SYNOPSISA brief description of the function (one line)
.DESCRIPTIONA detailed description of what the function does
.PARAMETERDescription of a specific parameter (repeat for each)
.EXAMPLEUsage examples (repeat for multiple examples)
.INPUTSTypes of objects that can be piped to the function
.OUTPUTSTypes of objects the function returns
.NOTESAdditional information like author, version, or caveats
.LINKLinks to related documentation or functions
Common comment-based help keywords

Best practices for inline comments

Inline comments explain specific lines or blocks of code. Here are some guidelines for effective commenting:

Explain why, not what. The code already shows what is happening. Comments should explain why you made a particular decision:

# Bad: This comment just repeats the code
# Set the timeout to 30
$Timeout = 30

# Good: This comment explains the reasoning
# 30 seconds allows for slow network connections without blocking too long
$Timeout = 30

Effective inline comments

Comment complex logic. When code is not immediately obvious, add a comment:

# Use bitwise AND to check if the third bit is set (indicates admin rights)
if ($flags -band 0x4) {
    # User has administrative privileges
}

Commenting complex logic

Mark workarounds and technical debt. When you implement a workaround, document it:

# WORKAROUND: The API returns dates in a non-standard format.
# Remove this conversion when API v2 is released (planned Q2 2025).
$date = [datetime]::ParseExact($apiDate, "dd-MM-yyyy", $null)

Documenting workarounds

Use TODO comments for future work. Many editors highlight TODO comments:

# TODO: Add error handling for network timeouts
# TODO: Implement retry logic for transient failures
# FIXME: This breaks when the path contains special characters

Using TODO comments

Avoid obvious comments. Do not comment code that is self-explanatory:

# Bad: Obvious and adds no value
# Get the current date
$today = Get-Date

# Also bad: Comment lies or is outdated
# Get yesterday's date
$today = Get-Date  # This is actually today, not yesterday!

Avoiding obvious comments

Keeping comments up to date

Outdated comments are worse than no comments at all. When you modify code, always review and update related comments. A comment that contradicts the code will confuse anyone reading it, including your future self.

Consider using a linting process or code review checklist that specifically checks for:

  • Comments that no longer match the code
  • TODO items that have been completed but not removed
  • Workaround comments for issues that have been fixed upstream

Using AI to help update comments

AI coding assistants like GitHub Copilot, ChatGPT, or Claude can help identify outdated comments and suggest updates. When you modify code, you can ask the AI to review whether the associated comments still accurately describe the behavior.

Review this function and its comments. Are the inline comments and comment-based help still accurate after the recent changes? Suggest updates where needed.

Using AI to review comments

This approach is especially useful after refactoring or when inheriting code from others. You can also ask the AI to generate comment-based help for functions that lack documentation, or to improve existing descriptions.


Using PSScriptAnalyzer in Visual Studio Code

If you use Visual Studio Code for PowerShell development (which I highly recommend), you get PSScriptAnalyzer integration out of the box. Visual Studio Code monitors your PowerShell code as you write it, providing real-time feedback on errors, warnings, and best practice violations. This means you can catch and fix issues immediately, rather than discovering them later when running your scripts.

For most developers, using PSScriptAnalyzer through Visual Studio Code is the recommended approach. It provides immediate feedback as you type and integrates seamlessly into your development workflow.

Install the PowerShell extension

First, ensure you have the PowerShell extension  installed in Visual Studio Code. This extension provides rich PowerShell language support, including IntelliSense, debugging, and real-time code analysis powered by an internal copy of PSScriptAnalyzer. The extension handles editor linting automatically - squiggly underlines, Problems panel entries, and quick fixes work without any additional setup. However, if you need to run Invoke-ScriptAnalyzer from the terminal or in tasks, you must install PSScriptAnalyzer as a standalone module.

To install the extension:

  1. Open Visual Studio Code.
  2. Press Ctrl + Shift + X to open the Extensions view.
  3. Search for “PowerShell” and install the extension by Microsoft.

Once installed, PSScriptAnalyzer starts working immediately - no additional configuration required.

Real-time code monitoring

Once the PowerShell extension is installed, Visual Studio Code continuously monitors your PowerShell code as you write it. Every time you type, save, or open a PowerShell file, PSScriptAnalyzer automatically runs in the background and analyzes your code. Issues detected by PSScriptAnalyzer appear as:

  • Squiggly underlines in the editor (yellow for warnings, red for errors).
  • Entries in the Problems panel (Ctrl + Shift + M).
  • Hover information - hover over an underlined issue to see the rule name and description.
  • Quick fixes - some issues offer automatic fixes via the lightbulb icon or Ctrl + ..

This real-time feedback loop means you are always aware of potential issues in your code. You do not need to manually run PSScriptAnalyzer or wait until you execute your script to discover problems. Visual Studio Code acts as your coding assistant, guiding you toward better PowerShell code as you write it.

Verify PSScriptAnalyzer is enabled in Visual Studio Code

PSScriptAnalyzer should be enabled by default, but it is good practice to verify that it is active, especially if you are not seeing any analysis feedback. Here is how to check:

  1. Press Ctrl + , to open the Settings.
  2. In the search bar, type powershell script analysis.
  3. Look for the setting PowerShell > Script Analysis: Enable.
  4. Ensure the checkbox is checked (enabled).

Alternatively, you can verify via the Command Palette:

  1. Press F1 or Ctrl + Shift + P to open the Command Palette.
  2. Type PowerShell: Show Session Menu and press Enter.
  3. Check that the PowerShell extension is running (you should see version information).

If script analysis is not working, try:

  • Reloading the window (Ctrl + Shift + P > Developer: Reload Window).
  • Checking the PowerShell extension output (View > Output > select PowerShell Extension Logs).
  • Ensuring no workspace settings are overriding user settings.

Configure PSScriptAnalyzer settings

You can customize PSScriptAnalyzer behavior in Visual Studio Code by modifying your settings. Press Ctrl + , to open Settings, then search for “script analysis”.

Key settings include:

powershell.scriptAnalysis.enable: Enable or disable script analysis (default: true).

powershell.scriptAnalysis.settingsPath: Path to a PSScriptAnalyzer settings file for custom rule configurations.

powershell.codeFormatting.preset: Choose a formatting preset (OTBS, Stroustrup, Allman, or Custom).

You can also configure these settings in your settings.json:

{
  "powershell.scriptAnalysis.enable": true,
  "powershell.scriptAnalysis.settingsPath": "C:\\Scripts\\PSScriptAnalyzerSettings.psd1"
}

Create a custom settings file

For advanced configurations, you can create a PSScriptAnalyzer settings file (.psd1) to customize which rules to include or exclude:

# PSScriptAnalyzerSettings.psd1
@{
  Severity = @('Error', 'Warning')
  ExcludeRules = @(
    'PSAvoidUsingWriteHost'
  )
  Rules = @{
    PSUseCompatibleSyntax = @{
      Enable = $true
      TargetVersions = @('5.1', '7.0', '7.4')
    }
  }
}

Example PSScriptAnalyzer settings file

This settings file:

  • Shows only errors and warnings (not information).
  • Excludes the PSAvoidUsingWriteHost rule.
  • Enables compatibility checking for PowerShell 5.1, 7.0, and 7.4.

Using PSScriptAnalyzer from the command line

While Visual Studio Code includes PSScriptAnalyzer automatically, there are scenarios where you need to run PSScriptAnalyzer from the command line:

  • CI/CD pipelines - Running automated code analysis in GitHub Actions, Azure DevOps, or other build systems.
  • Servers without Visual Studio Code - Validating scripts on production servers or jump hosts.
  • Automated scripts - Building custom validation workflows or pre-commit hooks.
  • PowerShell ISE users - If you prefer PowerShell ISE over Visual Studio Code.

Prerequisites: Installing PSScriptAnalyzer

Unlike Visual Studio Code (where PSScriptAnalyzer is bundled with the PowerShell extension), command-line usage requires installing the module separately. PSScriptAnalyzer is available from the PowerShell Gallery  and can be installed with a single command.

To install PSScriptAnalyzer, open a PowerShell terminal and run:

Install-Module -Name PSScriptAnalyzer -Repository PSGallery -Force

To verify the installation, run:

Get-Module -Name PSScriptAnalyzer -ListAvailable

You should see output similar to:

ModuleType Version    PreRelease Name            PSEdition ExportedCommands
---------- -------    ---------- ----            --------- ----------------
Script     1.23.0                PSScriptAnalyzer Desktop  {Get-ScriptAnalyzerRule, Invoke-Forma...

Verifying PSScriptAnalyzer installation

Note that if you use both Visual Studio Code and the standalone module, they operate independently. Visual Studio Code uses its bundled version, while command-line usage relies on the installed module.

Running Invoke-ScriptAnalyzer

The primary cmdlet for analyzing scripts is Invoke-ScriptAnalyzer. Here are some examples of how to use it:

Analyze a single script

To analyze a single PowerShell script file:

Invoke-ScriptAnalyzer -Path "C:\Scripts\MyScript.ps1"

This command analyzes the specified script and outputs any warnings or errors found.

Analyze a folder of scripts

To analyze all PowerShell scripts in a folder:

Invoke-ScriptAnalyzer -Path "C:\Scripts" -Recurse

The -Recurse parameter ensures that all scripts in subfolders are also analyzed.

Include specific rules

If you want to check only specific rules, use the -IncludeRule parameter:

Invoke-ScriptAnalyzer -Path "C:\Scripts\MyScript.ps1" -IncludeRule PSAvoidUsingPlainTextForPassword, PSAvoidUsingConvertToSecureStringWithPlainText

Exclude specific rules

Conversely, if you want to exclude certain rules:

Invoke-ScriptAnalyzer -Path "C:\Scripts\MyScript.ps1" -ExcludeRule PSAvoidUsingWriteHost

Analyze with a specific severity

To show only errors or warnings of a certain severity:

Invoke-ScriptAnalyzer -Path "C:\Scripts\MyScript.ps1" -Severity Error, Warning

Available severity levels are: Error, Warning, Information, and ParseError.

Example output

When PSScriptAnalyzer finds issues, the output looks like this:

RuleName                              Severity    ScriptName      Line    Message
-----------                          ----------  --------------  ------  ---------
PSAvoidUsingWriteHost                 Warning     MyScript.ps1     15     Avoid using Write-Host because it might not work in all hosts...
PSUseDeclaredVarsMoreThanAssignments  Warning     MyScript.ps1     23     Variable 'unused' is assigned but never used.
PSAvoidUsingPlainTextForPassword      Warning     MyScript.ps1     42     Parameter 'Password' should use SecureString...

Sample PSScriptAnalyzer output

Understanding PSScriptAnalyzer rules and recommendations

PSScriptAnalyzer rules are organized into categories that help you understand the type of issue being flagged. Understanding these categories helps you prioritize which issues to address first.

Rule categories

Security rules - These rules identify potential security vulnerabilities in your scripts, such as using plain text passwords, hardcoded credentials, or dangerous cmdlets like Invoke-Expression. Security rules should always be addressed.

Code quality rules - These rules enforce best practices that improve code readability and maintainability, such as using approved verbs, providing comment-based help, and avoiding aliases.

Compatibility rules - These rules check for syntax and cmdlets that may not work across different PowerShell versions or platforms. Essential for scripts that need to run in mixed environments.

Performance rules - These rules identify patterns that could slow down script execution or consume unnecessary resources.

Common rules you should know

PSScriptAnalyzer includes many rules. Here are some of the most important ones:

Rule NameSeverityDescription
PSAvoidUsingPlainTextForPasswordWarningAvoid using plain text passwords
PSAvoidUsingConvertToSecureStringWithPlainTextWarningAvoid converting plain text to SecureString
PSAvoidUsingInvokeExpressionWarningAvoid using Invoke-Expression
PSUseShouldProcessForStateChangingFunctionsWarningUse ShouldProcess for functions that change state
PSAvoidUsingWriteHostWarningAvoid Write-Host, use Write-Output instead
PSUseDeclaredVarsMoreThanAssignmentsWarningVariables should be used after assignment
PSAvoidUsingCmdletAliasesWarningUse full cmdlet names instead of aliases
PSProvideCommentHelpInformationProvide comment-based help for functions
PSUseApprovedVerbsWarningUse approved PowerShell verbs for function names
PSReviewUnusedParameterWarningReview parameters that are defined but not used
PSUseSingularNounsWarningFunction names should use singular nouns
PSAvoidGlobalVarsWarningAvoid using global variables
Common PSScriptAnalyzer rules

Viewing all available rules

To see all available PSScriptAnalyzer rules, run:

Get-ScriptAnalyzerRule | Select-Object RuleName, Severity, Description

To view rules by severity:

Get-ScriptAnalyzerRule | Group-Object Severity | Select-Object Name, Count

To find rules related to a specific topic (e.g., security):

Get-ScriptAnalyzerRule | Where-Object { $_.RuleName -like "*Security*" -or $_.Description -like "*security*" }

Best practices and recommendations

When working with PSScriptAnalyzer, consider these recommendations:

Run it regularly. Make PSScriptAnalyzer part of your development workflow. Run it before committing code or as part of your CI/CD pipeline.

Start with default rules. The default rule set covers the most important best practices. Start with these before adding custom rules.

Always address errors first. Errors indicate serious issues that could cause your script to fail or behave unexpectedly.

Take warnings seriously. Warnings often highlight best practice violations that could lead to maintenance issues or security vulnerabilities down the road.

Do not ignore information messages. While less critical, these messages often suggest improvements that make your code more professional and easier to understand.

Review security rules carefully. Any rule flagged as a security issue should be carefully reviewed, even if you think it is a false positive.

Use suppression sparingly. You can suppress specific warnings using [Diagnostics.CodeAnalysis.SuppressMessageAttribute()], but only do this when you have a good reason and document why:

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')]
param()

Write-Host "This is intentional"

Suppressing a PSScriptAnalyzer warning

Keep PSScriptAnalyzer updated. New rules are added regularly. How you update PSScriptAnalyzer depends on how you use it:

  • Visual Studio Code users: The bundled PSScriptAnalyzer is updated when the PowerShell extension is updated. To update the extension, go to the Extensions view (Ctrl + Shift + X), find the PowerShell extension, and click Update if available.

  • Command line users: If you installed PSScriptAnalyzer separately via Install-Module, update it manually with Update-Module -Name PSScriptAnalyzer.

Share your settings. If you work in a team, share your PSScriptAnalyzer settings file in your repository to ensure consistent code quality across the team.

For a complete list of rules and detailed documentation, visit the PSScriptAnalyzer rules documentation .


PowerShell Constrained Language Mode

In enterprise environments, security policies like Application Control for Business (formerly Windows Defender Application Control, commonly known as WDAC) or AppLocker may enforce PowerShell Constrained Language Mode (CLM). Understanding CLM is essential for writing scripts that work reliably in secured environments.

What is Constrained Language Mode?

Constrained Language Mode (CLM) is a PowerShell security feature that restricts the language elements available to scripts. When CLM is active, PowerShell limits access to sensitive language features that could be exploited by malicious scripts. This is commonly enforced in organizations that use application control policies to protect their endpoints.

CLM is one of several language modes in PowerShell:

  • FullLanguage - All language features are available (default mode).
  • ConstrainedLanguage - Restricted access to .NET types, COM objects, and other sensitive features.
  • RestrictedLanguage - Very limited mode, primarily for Data sections.
  • NoLanguage - Only cmdlets and functions are allowed, no script language elements.

How to detect the current language mode

To check which language mode your PowerShell session is running in:

$ExecutionContext.SessionState.LanguageMode

Check current PowerShell language mode

This returns one of the language mode values. You can use this in your scripts to detect and handle CLM:

if ($ExecutionContext.SessionState.LanguageMode -eq 'ConstrainedLanguage') {
    Write-Warning "Running in Constrained Language Mode. Some features may be unavailable."
}

Detecting Constrained Language Mode in PowerShell

How to enable Constrained Language Mode for testing

To test whether your scripts are CLM-compatible, you can temporarily enable Constrained Language Mode in a PowerShell session. This allows you to verify your code works correctly before deploying it to secured environments.

Important: Once you switch to Constrained Language Mode, you cannot switch back to Full Language Mode in the same session. This is a security feature. You will need to close and reopen PowerShell to return to Full Language Mode.

To enable Constrained Language Mode in your current session:

$ExecutionContext.SessionState.LanguageMode = "ConstrainedLanguage"

Enable Constrained Language Mode in PowerShell

After running this command, verify the mode has changed:

$ExecutionContext.SessionState.LanguageMode

Verify PowerShell language mode

The output should show ConstrainedLanguage.

Now you can test your script in this restricted environment:

# Run your script to test CLM compatibility
.\MyScript.ps1

If your script uses features that are blocked in CLM, you will see errors like:

Cannot invoke method. Method invocation is supported only on core types in this language mode.

Example error in Constrained Language Mode

or

Cannot create type. Only core types are supported in this language mode.

Example error in Constrained Language Mode

These errors indicate which parts of your script need to be rewritten using CLM-compatible alternatives.

Testing workflow recommendation:

  1. Open a new PowerShell window specifically for CLM testing.
  2. Enable Constrained Language Mode using the command above.
  3. Run your script and note any errors.
  4. Close the PowerShell window when done.
  5. Fix the issues in your regular PowerShell environment.
  6. Repeat until your script runs without errors in CLM.

For more thorough testing, consider setting up a test environment with actual WDAC or AppLocker policies. This simulates the real conditions your scripts will face in production and catches edge cases that manual CLM testing might miss.

What is restricted in Constrained Language Mode?

When CLM is active, the following features are restricted or unavailable:

No direct .NET type access. You cannot use [System.IO.File]::ReadAllText() or similar .NET method calls. Only a small set of approved types are allowed.

No COM objects. Creating COM objects with New-Object -ComObject is blocked.

No Add-Type. You cannot compile and load custom .NET code using Add-Type.

No custom classes. PowerShell 5+ class definitions are not allowed.

Limited type conversions. Many type accelerators and conversions are restricted.

No Invoke-Expression with untrusted input. Dynamic code execution is blocked.

Writing CLM-compatible scripts

To ensure your scripts work in Constrained Language Mode, follow these guidelines:

Use cmdlets instead of .NET methods. Replace .NET method calls with equivalent PowerShell cmdlets:

# Instead of this (blocked in CLM):
# [System.IO.File]::ReadAllText("C:\Scripts\config.txt")

# Use this (works in CLM):
Get-Content -Path "C:\Scripts\config.txt" -Raw

CLM-compatible file reading in PowerShell

Avoid Add-Type and custom classes. If you need custom functionality, consider using modules or functions instead of compiled .NET code.

Use approved type accelerators. Only certain type accelerators work in CLM. Stick to basic types like [string], [int], [bool], [array], and [hashtable].

Test in CLM. Before deploying scripts to secured environments, test them in Constrained Language Mode. You can simulate CLM for testing (though this requires specific conditions that are typically set by system policies).

Provide graceful fallbacks. When possible, detect CLM and provide alternative code paths:

function Get-CustomFileHash {
    param([string]$Path)

    if ($ExecutionContext.SessionState.LanguageMode -eq 'ConstrainedLanguage') {
        # Use cmdlet approach (CLM-compatible)
        Get-FileHash -Path $Path -Algorithm SHA256
    } else {
        # Use .NET approach (faster, but not CLM-compatible)
        $stream = [System.IO.File]::OpenRead($Path)
        try {
            $sha256 = [System.Security.Cryptography.SHA256]::Create()
            $hash = $sha256.ComputeHash($stream)
            [BitConverter]::ToString($hash) -replace '-', ''
        } finally {
            $stream.Close()
        }
    }
}

CLM-compatible function example in PowerShell

Using AI to refactor for CLM compatibility

Refactoring .NET method calls to cmdlet equivalents can be tedious, especially for complex code. AI coding assistants can help with this conversion. If CLM testing reveals incompatible code, try a prompt like:

This PowerShell code uses .NET methods that do not work in Constrained Language Mode. Rewrite it using only built-in cmdlets and approved type accelerators.

AI prompt for CLM refactoring

AI assistants are particularly good at suggesting cmdlet alternatives for common .NET patterns, such as file operations, string manipulation, and date handling. This can significantly speed up the process of making your scripts CLM-compatible.

PSScriptAnalyzer compatibility rules

While PSScriptAnalyzer does not have rules specifically designed to detect CLM restrictions, its compatibility rules can help you write more portable scripts that are less likely to cause issues in constrained environments:

PSUseCompatibleTypes - Checks for types that may not be available in all PowerShell environments. Scripts that avoid uncommon types are generally easier to adapt for CLM.

PSUseCompatibleCommands - Verifies that cmdlets are available in target environments. Using standard cmdlets instead of .NET methods aligns with CLM best practices.

To enable these compatibility rules, add them to your PSScriptAnalyzer settings file:

# PSScriptAnalyzerSettings.psd1
@{
  Rules = @{
    PSUseCompatibleCommands = @{
      Enable = $true
      TargetProfiles = @(
        'win-8_x64_10.0.17763.0_7.0.0_x64_3.1.2_core'
        'win-8_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework'
      )
    }
    PSUseCompatibleTypes = @{
      Enable = $true
      TargetProfiles = @(
        'win-8_x64_10.0.17763.0_7.0.0_x64_3.1.2_core'
        'win-8_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework'
      )
    }
  }
}

PSScriptAnalyzer settings for compatibility rules

When CLM matters

Consider CLM compatibility when:

  • Your scripts will run on endpoints managed by Microsoft Intune with WDAC policies.
  • Your organization uses AppLocker or similar application control solutions.
  • You are developing scripts for enterprise deployment where security policies are strictly enforced.
  • You are creating tools or modules for the broader community that may be used in secured environments.

By understanding and testing for Constrained Language Mode, you ensure your PowerShell scripts work reliably across all environments, including those with the strictest security policies.


File encoding and line endings

Beyond code analysis rules, the way you save your PowerShell files matters. File encoding and line endings can affect script compatibility, version control, and even script execution. Choosing the right settings ensures your scripts work consistently across different environments and tools.

Understanding file encoding

When you save a PowerShell script, the text is converted to bytes using a character encoding. The most common encodings for PowerShell scripts are:

  • UTF-8 without BOM - The recommended encoding for modern PowerShell scripts.
  • UTF-8 with BOM - UTF-8 with a Byte Order Mark at the beginning of the file.
  • UTF-16 (Unicode) - Used by older PowerShell and some Windows tools.
  • ASCII - Limited to 128 characters, no support for special characters.

Why UTF-8 without BOM is the best choice

UTF-8 without BOM is the recommended encoding for PowerShell scripts, and here is why:

Cross-platform compatibility. PowerShell 7 and later run on Windows, macOS, and Linux. UTF-8 without BOM is the standard encoding on Unix-like systems. Scripts saved with BOM may cause issues on non-Windows platforms, especially when used as shell scripts or when the first line contains a shebang (#!/usr/bin/env pwsh).

Git and version control friendly. Git and other version control systems handle UTF-8 without BOM more gracefully. The BOM can appear as unexpected characters in diffs, cause merge conflicts, and create inconsistencies when team members use different editors.

Web and API compatibility. When PowerShell scripts interact with web services, REST APIs, or read/write JSON and XML files, UTF-8 without BOM is the expected encoding. The BOM can corrupt data or cause parsing errors in some scenarios.

Modern tooling standard. Most modern editors, linters, and CI/CD pipelines expect UTF-8 without BOM. Tools like PSScriptAnalyzer, GitHub Actions, and Azure DevOps work best with this encoding.

No hidden characters. The BOM adds three invisible bytes (EF BB BF) at the beginning of your file. These bytes can cause subtle issues, such as breaking scripts that are concatenated together or parsed by other tools.

PowerShell 7 default. PowerShell 7 and later default to UTF-8 without BOM for file operations, aligning with cross-platform best practices. Using the same encoding for your scripts ensures consistency.

When to use UTF-8 with BOM

There are a few scenarios where UTF-8 with BOM might still be appropriate:

  • Legacy Windows PowerShell 5.1 scripts that must work with older Windows tools expecting BOM.
  • Scripts opened in Notepad (older versions of Notepad needed BOM to detect UTF-8).
  • Integration with legacy systems that specifically require BOM.

However, for new scripts and cross-platform development, UTF-8 without BOM is the clear choice.

Line endings: LF vs CRLF

Line endings are another important consideration. Different operating systems use different characters to mark the end of a line:

  • LF (Line Feed, \n) - Used by Linux, macOS, and Unix systems.
  • CRLF (Carriage Return + Line Feed, \r\n) - Used by Windows.
  • CR (Carriage Return, \r) - Used by old Mac systems (rarely seen today).

Why LF is the better choice for PowerShell scripts

While Windows traditionally uses CRLF, LF is increasingly the preferred choice for PowerShell scripts, especially for cross-platform and collaborative development:

Cross-platform compatibility. PowerShell 7 runs on Windows, macOS, and Linux. Scripts with LF line endings work correctly on all platforms, while CRLF can cause issues on Unix-like systems.

Git best practices. Git’s default behavior is to normalize line endings. Using LF consistently avoids messy diffs where the only change is line ending conversion. It also prevents the “no newline at end of file” warnings and whitespace noise in pull requests.

Consistency in repositories. When working in teams or contributing to open-source projects, LF is the standard. Mixing line endings causes confusion and unnecessary changes in version control history.

Modern editor support. Visual Studio Code, and most modern editors can work with LF on Windows without issues. PowerShell itself handles both LF and CRLF correctly when executing scripts.

Smaller file size. LF uses one byte per line ending, while CRLF uses two. For large scripts, this adds up.

Configuring Visual Studio Code for optimal file settings

Visual Studio Code makes it easy to configure encoding and line endings for PowerShell development. Add these settings to your settings.json to ensure consistency:

{
  "[powershell]": {
    "files.encoding": "utf8",
    "files.eol": "\n"
  }
}

Visual Studio Code settings for PowerShell file encoding and line endings

These settings ensure:

  • files.encoding: utf8 saves files as UTF-8 without BOM (Visual Studio Code’s utf8 setting means without BOM; utf8bom would add the BOM).
  • files.eol: \n uses LF line endings for new files.

You can also set these as global defaults:

{
  "files.encoding": "utf8",
  "files.eol": "\n"
}

Visual Studio Code global settings for file encoding and line endings

Using .editorconfig for team consistency

While Visual Studio Code settings work well for individual developers, they do not travel with your code. When you share a repository with teammates or open-source contributors, each person may have different editor settings, leading to inconsistent formatting, mixed line endings, and noisy diffs.

EditorConfig  solves this problem. It is an open standard for defining coding styles that works across many editors and IDEs, including Visual Studio Code, Visual Studio, JetBrains IDEs, Sublime Text, and others. When you add an .editorconfig file to your repository, editors that support EditorConfig automatically apply those settings to files in that project, overriding user preferences for that workspace.

How it works:

  1. Create a file named .editorconfig (note the leading dot) in the root of your repository.
  2. The file uses a simple INI-style format with sections for different file types.
  3. When you open a file, your editor reads the .editorconfig file and applies the matching settings.
  4. The root = true line tells the editor to stop searching for .editorconfig files in parent directories.

Visual Studio Code requires the EditorConfig for VS Code  extension to support .editorconfig files. Once installed, the settings in .editorconfig override your user and workspace settings for matching files.

Here is an example .editorconfig for PowerShell projects:

# .editorconfig
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

[*.ps1]
indent_style = space
indent_size = 4

[*.psd1]
indent_style = space
indent_size = 4

[*.psm1]
indent_style = space
indent_size = 4

[*.json]
indent_style = space
indent_size = 2

Example .editorconfig for PowerShell projects

Key settings explained:

SettingDescription
root = trueStops the editor from looking for .editorconfig files in parent directories
charset = utf-8Sets file encoding to UTF-8 (without BOM)
end_of_line = lfUses LF line endings
insert_final_newline = trueEnsures files end with a newline character
trim_trailing_whitespace = trueRemoves trailing spaces when saving
indent_style = spaceUses spaces instead of tabs for indentation
indent_size = 4Sets indentation to 4 spaces (PowerShell community standard)
Common .editorconfig settings

The [*] section applies to all files, while [*.ps1] and similar sections apply only to files matching that pattern. This lets you define different settings for different file types in the same repository.

For more details on finding and removing trailing whitespace in Visual Studio Code, see my post How to remove trailing spaces in Visual Studio Code.

Configuring Git for line ending handling

While .editorconfig controls how your editor saves files, .gitattributes controls how Git handles files when committing and checking out. This ensures consistent line endings in your repository regardless of what operating system contributors use.

Create a file named .gitattributes (note the leading dot) in the root of your repository. Git reads this file automatically and applies the rules to all matching files.

my-project/
├── .git/                         ← Git's internal folder (hidden in Visual Studio Code)
├── .editorconfig                 ← Editor configuration
├── .gitattributes                ← Git attributes file goes here
├── .gitignore                    ← Git ignore file
├── PSScriptAnalyzerSettings.psd1 ← PSScriptAnalyzer settings
├── solution/
│   └── MyScript.ps1
└── README.md

Repository structure showing .gitattributes placement

Here is an example .gitattributes for PowerShell projects:

# Set default behavior to automatically normalize line endings
* text=auto eol=lf

# PowerShell files
*.ps1 text eol=lf
*.psd1 text eol=lf
*.psm1 text eol=lf

# Keep Windows batch files with CRLF
*.cmd text eol=crlf
*.bat text eol=crlf

Example .gitattributes for PowerShell projects

This configuration tells Git to:

  • Normalize all text files to LF in the repository.
  • Keep PowerShell files with LF line endings.
  • Preserve CRLF for Windows batch files that require it.

For more details on .gitattributes and line ending configuration, see Configuring Git to handle line endings .

Using .editorconfig and .gitattributes together

You might wonder whether you need both .editorconfig and .gitattributes, or if one is enough. The recommendation is to use both:

  • .editorconfig prevents problems at the source - it ensures files are saved with the correct encoding and line endings when you create or edit them.
  • .gitattributes acts as a safety net at the repository level - it normalizes files when committing, even if a contributor’s editor is not configured correctly.

Think of it this way: .editorconfig says “save files the right way,” while .gitattributes says “even if someone did not, fix it on commit.”

If you could use one only, .gitattributes is more critical because it enforces consistency for the entire repository regardless of individual editor settings. However, using both gives you the best experience - correct files from the start and repository-level protection as a fallback.

Using .vscode/extensions.json to recommend Visual Studio Code extensions to your team

The extensions.json file in Visual Studio Code is used to recommend extensions for a project, ensuring that all team members have a consistent development environment. When a team member opens the project folder, Visual Studio Code will prompt them to install the recommended extensions, reducing setup friction and ensuring everyone has the right tools.

This file should be placed in the .vscode folder at the root of your project:

my-powershell-project/
├── .vscode/
│   ├── extensions.json           ← Extension recommendations
│   ├── settings.json             ← Workspace settings
└── README.md

Project structure with extensions.json

Here is an example extensions.json for PowerShell projects:

{
    "recommendations": [
        "ms-vscode.powershell",
        "editorconfig.editorconfig"
    ],
    "unwantedRecommendations": []
}

Example extensions.json for PowerShell projects

The recommendations array contains extension identifiers (in the format publisher.extension-name) that Visual Studio Code will suggest installing. The unwantedRecommendations array can be used to explicitly exclude extensions that should not be recommended for this project.

To find an extension’s identifier, open the extension in Visual Studio Code’s Extensions view, click the gear icon, and select Copy Extension ID. Alternatively, you can find it on the Visual Studio Code Marketplace  page for the extension.

When a team member opens the project, Visual Studio Code will display a notification suggesting they install the recommended extensions. They can also view all workspace recommendations by opening the Extensions view (Ctrl + Shift + X) and filtering by @recommended.

Checking and converting file encoding

To check the encoding of an existing file in PowerShell:

$bytes = [System.IO.File]::ReadAllBytes("C:\Scripts\MyScript.ps1")
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
    Write-Host "UTF-8 with BOM"
} elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
    Write-Host "UTF-16 LE (Unicode)"
} else {
    Write-Host "UTF-8 without BOM (or ASCII)"
}

Check file encoding in PowerShell

To convert a file to UTF-8 without BOM:

$content = Get-Content -Path "C:\Scripts\MyScript.ps1" -Raw
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText("C:\Scripts\MyScript.ps1", $content, $utf8NoBom)

Convert file to UTF-8 without BOM in PowerShell

To normalize line endings to LF:

$content = Get-Content -Path "C:\Scripts\MyScript.ps1" -Raw
$content = $content -replace "`r`n", "`n"
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText("C:\Scripts\MyScript.ps1", $content, $utf8NoBom)

Normalize line endings to LF in PowerShell

For consistent, cross-platform PowerShell scripts, use these settings:

By combining PSScriptAnalyzer validation with proper file encoding and line endings, you ensure your PowerShell scripts are not only well-written but also portable and version control friendly.


Adding badges to your GitHub repository

If you share your PowerShell code on GitHub, you can add badges to your README to show that your code meets quality standards. Badges demonstrate your commitment to code quality and help others quickly understand the state of your project.

PSScriptAnalyzer badge

Shields.io  is a service that generates badges for your projects. You can create a static PSScriptAnalyzer badge to add to your README:

![PSScriptAnalyzer](https://img.shields.io/badge/PSScriptAnalyzer-Passed-brightgreen?logo=powershell "PSScriptAnalyzer Validated")

This generates a badge that looks like this:

PSScriptAnalyzer Validated

You can customize the badge to reflect different statuses:

StatusBadge Code
Passed![PSScriptAnalyzer](https://img.shields.io/badge/PSScriptAnalyzer-Passed-brightgreen?logo=powershell)
Warnings![PSScriptAnalyzer](https://img.shields.io/badge/PSScriptAnalyzer-Warnings-yellow?logo=powershell)
Failed![PSScriptAnalyzer](https://img.shields.io/badge/PSScriptAnalyzer-Failed-red?logo=powershell)
PSScriptAnalyzer badge variations

CLM compatibility badge

If your scripts are designed to work in Constrained Language Mode, you can add a badge to indicate this:

![CLM Compatible](https://img.shields.io/badge/CLM-Compatible-blue?logo=powershell "Constrained Language Mode Compatible"){}

This generates a badge that looks like this:

Constrained Language Mode Compatible

You can combine both badges to show code quality and CLM compatibility together.


Quick reference

Here is a summary of the key practices covered in this post:

AreaKey ActionWhy It Matters
StructureUse regions, script headers, and snippetsOrganizes code for readability and maintenance
DocumentationAdd comment-based help to all functionsMakes code maintainable and discoverable
ValidationUse PSScriptAnalyzer in Visual Studio Code or CI/CDCatches errors early, enforces best practices
CLM CompatibilityTest scripts in constrained environmentsEnsures scripts work with WDAC/AppLocker
File SettingsConfigure UTF-8 without BOM, LF line endings earlyCross-platform and version control friendly
Quick reference for validating and improving PowerShell scripts

The wrap

Writing quality PowerShell code goes beyond just getting your scripts to work. It requires a combination of validation, documentation, compatibility awareness, and proper file handling.

PSScriptAnalyzer is the foundation of PowerShell code quality. It catches errors early, enforces best practices, and helps you write secure, maintainable scripts. Whether you use it from the command line, integrate it into Visual Studio Code, or automate it in your CI/CD pipelines, PSScriptAnalyzer should be part of every PowerShell developer’s toolkit.

Good documentation through comment-based help, meaningful inline comments, and organized code regions makes your scripts understandable and maintainable. Code that is well-documented today saves hours of confusion tomorrow, both for your colleagues and your future self.

Understanding Constrained Language Mode is equally important for enterprise environments. As organizations increasingly adopt security policies like WDAC and AppLocker, writing CLM-compatible scripts ensures your code works reliably across all environments, including those with the strictest security controls.

Finally, proper file encoding and line endings may seem like minor details, but they matter for cross-platform compatibility and team collaboration. UTF-8 without BOM and LF line endings are the modern standards that keep your scripts portable and version control friendly.

By combining these practices, validating with PSScriptAnalyzer, documenting thoroughly, testing for CLM compatibility, and using proper file settings, you produce PowerShell code that is not only functional but professional, secure, and ready for any environment.

Happy coding.

–Jesper