How to auto-format PowerShell messages for Azure DevOps pipelines
If you've worked with Azure DevOps pipelines and PowerShell, you know you've to change that tiny Write-* command text to be wrapped correctly:
Write-Warning -Message "My message"
# Becomes:
Write-Warning -Message "##[warning]My Message"
The same applies for Write-Error and Write-Warning.
But if you have an extensive script or function, it becomes a tremendous task to manually update each Write-* call throughout your codebase. Imagine going through hundreds of lines of code, wrapping each message with the specific levels, only to break compatibility when running the script locally outside Azure Pipelines.
What if your scripts could automatically detect if they're running in Azure Pipelines and format the output correctly without touching a single line of your existing code?
The challenge
When PowerShell code runs through Azure Pipelines, the standard output cmdlets don't get special treatment.
When you run PowerShell in Azure Pipelines, standard output cmdlets use the formatting commands known in Azure DevOps. Your warnings look like regular text, errors don't stand out, and debug messages are just... there.
Azure DevOps supports formatting commands that format output with colors:
##[warning]- Displays yellow warning messages.##[error]- Displays red error messages.##[debug]- Displays purple debug messages.
But each of the formatting commands requires manual writing:
Write-Host "##[warning]Configuration file not found"
Write-Host "##[error]Failed to connect to database"This means that every script or function needs to:
- Be littered with Azure DevOps-specific code.
- Handle both Azure DevOps and local execution separately.
- Lose the semantic meaning of
Write-WarningandWrite-Errorby usingWrite-Hostinstead.
The solution: Auto-wrapping
The solution is actually quite simple: a simple wrapper that detects Azure Pipelines and automatically overrides Write-Warning, Write-Error, and Write-Debug.
It works by:
- Detecting if PowerShell code is running through Azure Pipelines (the
$env:TF_BUILDenvironment variable). - Overriding the built-in cmdlets with global functions that shadow them.
- Formatting messages with Azure DevOps formatting command syntax.
The remaining part is transparency, as you don't have to change your existing code. Take a look at the following code snippet:
if ($env:TF_BUILD -eq 'true') {
Write-Host "Initializing Azure DevOps logging wrappers..."
# Override Write-Warning
function global:Write-Warning {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
[string]$Message
)
process {
Write-Host "##[warning]$Message"
}
}
# Override Write-Error
function global:Write-Error {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
[string]$Message
)
process {
Write-Host "##[error]$Message"
}
}
# Override Write-Debug
function global:Write-Debug {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
[string]$Message
)
process {
if ($DebugPreference -ne 'SilentlyContinue') {
Write-Host "##[debug]$Message"
}
}
}
Write-Host "Logging wrappers active"
}Now your scripts or functions work both locally and in Azure DevOps:
# This works locally AND in Azure DevOps
Write-Warning "API rate limit approaching"
Write-Error "Database connection failed"
Write-Debug "Processing item: $item" -Debug
# In Azure DevOps, output becomes:
# ##[warning]API rate limit approaching
# ##[error]Database connection failed
# ##[debug]Processing item: MyItemWrite-Verbose cannot be mapped to a formatting command because it isn't known in Azure Pipelines.How to implement
To have the script available for all your Azure Pipelines, you can store it as a function that others can standard in a PowerShell task:
function Initialize-AzureDevOpsLogging {
<#
.SYNOPSIS
Wraps Write-* cmdlets for Azure DevOps logging commands.
.DESCRIPTION
Automatically detects Azure DevOps environment and wraps
Write-Warning, Write-Error, and Write-Debug with proper
logging command syntax.
.EXAMPLE
Initialize-AzureDevOpsLogging
Write-Warning "This appears with warning icon in Azure DevOps"
#>
if ($env:TF_BUILD -ne 'true') {
Write-Verbose "Not in Azure DevOps - skipping wrapper initialization"
return
}
Write-Host "Initializing Azure DevOps logging wrappers..."
# Override Write-Warning
function global:Write-Warning {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
[string]$Message
)
process {
Write-Host "##[warning]$Message"
}
}
# Override Write-Error
function global:Write-Error {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
[string]$Message
)
process {
Write-Host "##[error]$Message"
}
}
# Override Write-Debug
function global:Write-Debug {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
[string]$Message
)
process {
if ($DebugPreference -ne 'SilentlyContinue') {
Write-Host "##[debug]$Message"
}
}
}
Write-Host "Logging wrappers active"
}Then, to sketch out the complete example, imagine you've the following function:
function Test-DatabaseConnection {
[CmdletBinding()]
param(
[string]$ConnectionString
)
Write-Host "Testing database connection..."
if ([string]::IsNullOrEmpty($ConnectionString)) {
Write-Warning "Connection string is empty, using default configuration"
$ConnectionString = "Server=localhost;Database=TestDB"
}
Write-Debug "Connection string: $ConnectionString"
try {
# Simulate connection attempt
$random = Get-Random -Minimum 1 -Maximum 10
if ($random -gt 7) {
Write-Error "Failed to connect to database: Connection timeout"
return $false
}
Write-Host "Database connection successful"
return $true
}
catch {
Write-Error "Unexpected error during connection: $_"
return $false
}
}In your Azure DevOps pipeline YAML file, you would add:
- task: PowerShell@2
displayName: 'Run script'
inputs:
targetType: 'inline'
script: |
# Dot-source the functions
. (Join-Path '$(System.DefaultWorkingDirectory)' 'Initialize-AzureDevOpsLogging.ps1')
. (Join-Path '$(System.DefaultWorkingDirectory)' 'Test-DatabaseConnection.ps1')
# Initialize the wrapper function
Initialize-AzureDevOpsLogging
# Run the function
Test-DatabaseConnection -ConnectionString MyConnectionString -Debug
errorActionPreference: 'continue'
pwsh: trueThe benefits are clear:
- No code changes required.
- It only activates in Azure DevOps (environment-aware).
- Keeps using the standard semantics, e.g.,
Write-Warning, notWrite-Host. - One wrapper for all your pipelines
- Better log messages are displayed in Azure Pipelines.

Conclusion
It's an easy trick to do. You don't have to wrap your PowerShell code with output manually ##[warning], ##[debug] and ##[error].
This simple wrapper makes your scripts work beautifully in Azure Pipelines while remaining functional locally. Your DevOps team will thank you for this. And of course, your pipeline logs will look beautified.