Quick answer: scope the query, then review the change
Use tags for category-based classification and custom attributes for named values. For exports, read the assignment’s Tag object and its Category and Name properties rather than expecting a top-level Category field.
Jump to tag creation and single-VM assignment, bulk review, CSV export, or custom attributes.
Before running the examples
Use a compatible PowerCLI installation, a trusted vCenter certificate, and an account with only the permissions required. Run this connection setup and helper once in a fresh interactive session; replace the example hostname. Every later query explicitly uses $vc.
$ErrorActionPreference = 'Stop'
$vc = Connect-VIServer -Server 'vcenter.example.com' -Credential (Get-Credential) -ErrorAction Stop
function Get-One {
param([object[]]$Items, [string]$Label)
if (@($Items).Count -ne 1) {
throw "Expected exactly one $Label; found $(@($Items).Count)."
}
$Items[0]
}
Run examples individually. Creation commands are commented out until you review and intentionally uncomment them; assignment and value changes default to $ApplyChanges = $false. These are local safety guards, not server-side transactions or a vendor -WhatIf mode. Confirm object IDs and downstream policy impact before enabling any write. After a change, rerun the relevant read-only query to confirm the result.
References: category-scoped tag lookup, tag assignment, category cardinality, custom-attribute target types, and annotation queries.
Introduction
Tags and custom attributes bring metadata structure to your VMware environment. Whether you’re classifying workloads, enforcing policies, or generating reports, PowerCLI enables full automation of tag and attribute management.
In this article, you will learn how to:
- Create and organize tag categories
- Assign tags to VMs, hosts, and other objects
- Query tags for filtering and automation
- Manage custom attributes and annotations
- Export tag metadata for documentation
My Personal Repository on GitHub
Understanding Tags vs Custom Attributes
| Feature | Tags | Custom attributes |
|---|---|---|
| Purpose | Classification labels organized into categories | Named values attached to inventory objects |
| Scope | The category’s entity types control where a tag can be assigned | The attribute’s target type controls its scope; supported objects include VMs, hosts, and other inventory types |
| Multiplicity | Single or Multiple cardinality is defined on the category | One value per attribute on an entity |
| Typical use | Application, environment, service tier, or policy selection | Owner, reference ID, cost center, or operational notes |
| PowerCLI | Get-Tag, Get-TagAssignment, New-TagAssignment | Get-CustomAttribute, Get-Annotation, Set-Annotation |
Tag Management with PowerCLI
Create a Tag Category
# Inspect first. Creating a category changes vCenter.
Get-TagCategory -Server $vc | Where-Object { $_.Name -eq 'ApplicationType' }
# Uncomment only after confirming that a new category is needed.
# New-TagCategory -Server $vc -Name 'ApplicationType' -Cardinality Single -EntityType VirtualMachine -ErrorAction Stop
Create Tags Inside the Category
$category = Get-One -Items @(Get-TagCategory -Server $vc -Name 'ApplicationType') -Label 'ApplicationType category' Get-Tag -Server $vc -Category $category # Create only missing, approved tags; uncomment the relevant line. # New-Tag -Server $vc -Name 'WebApp' -Category $category -ErrorAction Stop # New-Tag -Server $vc -Name 'Database' -Category $category -ErrorAction Stop
Assign a Tag to a VM
$ApplyChanges = $false # Set true only after reviewing this VM and its current tags.
$vm = Get-One -Items @(Get-VM -Server $vc -Name 'SQL01') -Label 'SQL01 VM'
$category = Get-One -Items @(Get-TagCategory -Server $vc -Name 'ApplicationType') -Label 'ApplicationType category'
$tag = Get-One -Items @(Get-Tag -Server $vc -Category $category -Name 'Database') -Label 'Database tag'
$current = @(Get-TagAssignment -Server $vc -Entity $vm -Category $category)
$current | Select-Object @{N='VM';E={$vm.Name}}, @{N='Tag';E={$_.Tag.Name}}
if (@($current | ForEach-Object { $_.Tag.Id }) -contains $tag.Id) {
Write-Output 'The requested tag is already assigned.'
} elseif ($current.Count -gt 0 -and $category.Cardinality -eq 'Single') {
throw 'A different tag already occupies this Single category. Review it; do not replace it automatically.'
} elseif ($ApplyChanges) {
New-TagAssignment -Server $vc -Entity $vm -Tag $tag -ErrorAction Stop
} else {
Write-Output "Preview only: assign $($tag.Name) to $($vm.Name) [$($vm.Id)]."
}
List Tags for a VM
$vm = Get-One -Items @(Get-VM -Server $vc -Name 'SQL01') -Label 'SQL01 VM'
Get-TagAssignment -Server $vc -Entity $vm |
Select-Object @{N='VM';E={$vm.Name}}, @{N='Category';E={$_.Tag.Category.Name}}, @{N='Tag';E={$_.Tag.Name}}
Bulk Tag Assignment Using Filters
Resolve one folder and preview its VM IDs before assigning anything. This example includes VMs in child folders. It refuses an empty target list and stops if a different tag already occupies the Single category. Tag changes can alter downstream backup or policy selection, so obtain the relevant owner’s approval.
$ApplyChanges = $false # Keep false until the complete target list is approved.
$folder = Get-One -Items @(Get-Folder -Server $vc -Type VM -Name 'Web Servers') -Label 'Web Servers VM folder'
$category = Get-One -Items @(Get-TagCategory -Server $vc -Name 'ApplicationType') -Label 'ApplicationType category'
$tag = Get-One -Items @(Get-Tag -Server $vc -Category $category -Name 'WebApp') -Label 'WebApp tag'
$targets = @(Get-VM -Server $vc -Location $folder)
if ($targets.Count -eq 0) { throw 'No VMs matched; nothing will be changed.' }
$pending = @(
foreach ($vm in $targets) {
$current = @(Get-TagAssignment -Server $vc -Entity $vm -Category $category)
if (@($current | ForEach-Object { $_.Tag.Id }) -contains $tag.Id) { continue }
if ($current.Count -gt 0 -and $category.Cardinality -eq 'Single') {
throw "Conflicting category assignment on $($vm.Name) [$($vm.Id)]."
}
$vm
}
)
$targets | Select-Object Name, Id
Write-Output "$($targets.Count) VMs in scope; $($pending.Count) need the tag."
if ($ApplyChanges -and $pending.Count -gt 0) {
foreach ($vm in $pending) {
New-TagAssignment -Server $vc -Entity $vm -Tag $tag -ErrorAction Stop
}
}
For guest-OS-based discovery, start with a read-only candidate list. Guest-reported OS data may be unavailable or stale; review the VM identities before using the assignment pattern above. Do not pass an unverified tag name directly into a bulk write.
Get-VM -Server $vc |
Where-Object { $_.Guest.OSFullName -like '*Ubuntu*' } |
Select-Object Name, Id, @{N='GuestOS';E={$_.Guest.OSFullName}}
Exporting Tag Assignments to CSV
# Read-only vCenter query; writes only a local CSV.
# One row per assignment. Untagged VMs do not appear in this report.
$rows = @(
foreach ($vm in @(Get-VM -Server $vc)) {
foreach ($assignment in @(Get-TagAssignment -Server $vc -Entity $vm)) {
[pscustomobject]@{
vCenter = $vc.Name
VM = $vm.Name
VMId = $vm.Id
Category = $assignment.Tag.Category.Name
Tag = $assignment.Tag.Name
}
}
}
)
if ($rows.Count -eq 0) { throw 'No tag assignments were returned; no CSV was written.' }
$reportPath = 'C:\Reports\VM_Tags.csv'
$reportDirectory = Split-Path -Path $reportPath -Parent
if (-not (Test-Path -LiteralPath $reportDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $reportDirectory -ErrorAction Stop | Out-Null
}
# Choose a new filename for subsequent exports; never overwrite an existing report.
$rows | Export-Csv -LiteralPath $reportPath -NoTypeInformation -Encoding UTF8 -NoClobber
Managing Custom Attributes
List Existing Attributes
Get-CustomAttribute -Server $vc
Create a New Attribute
Get-CustomAttribute -Server $vc | Where-Object { $_.Name -eq 'Owner' }
# Uncomment only if an approved VM-scoped Owner attribute does not already exist.
# New-CustomAttribute -Server $vc -Name 'Owner' -TargetType VirtualMachine -ErrorAction Stop
Set Value on a VM
$ApplyChanges = $false
$vm = Get-One -Items @(Get-VM -Server $vc -Name 'SQL01') -Label 'SQL01 VM'
$ownerAttribute = Get-One -Items @(Get-CustomAttribute -Server $vc -Name 'Owner') -Label 'Owner attribute'
Get-Annotation -Server $vc -Entity $vm -CustomAttribute $ownerAttribute
# Confirm the attribute applies to this VM and that replacing its value is intended.
if ($ApplyChanges) {
Set-Annotation -Server $vc -Entity $vm -CustomAttribute $ownerAttribute -Value 'TeamA' -ErrorAction Stop
} else {
Write-Output "Preview only: set Owner to TeamA on $($vm.Name) [$($vm.Id)]."
}
Report Custom Attribute Values
$ownerAttribute = Get-One -Items @(Get-CustomAttribute -Server $vc -Name 'Owner') -Label 'Owner attribute'
Get-VM -Server $vc | ForEach-Object {
$vm = $_
$owner = @(Get-Annotation -Server $vc -Entity $vm -CustomAttribute $ownerAttribute)
[pscustomobject]@{
VM = $vm.Name
VMId = $vm.Id
Owner = ($owner.Value -join '; ')
}
}
Diagram: Tag and Attribute Automation

Use Case: Backup Classification by Tag
You can tag VMs as Daily, Weekly, or Excluded and use backup tools to process workloads accordingly.
Before assigning a backup-selection tag, agree on its category, permitted values, and meaning with the backup owner. The following discovery step assumes an existing category named BackupPolicy; it makes no changes. Use the guarded assignment pattern above only after reviewing any current policy tag.
$vm = Get-One -Items @(Get-VM -Server $vc -Name 'AppServer01') -Label 'AppServer01 VM'
$backupCategory = Get-One -Items @(Get-TagCategory -Server $vc -Name 'BackupPolicy') -Label 'BackupPolicy category'
$dailyTag = Get-One -Items @(Get-Tag -Server $vc -Category $backupCategory -Name 'Daily') -Label 'Daily backup tag'
Get-TagAssignment -Server $vc -Entity $vm -Category $backupCategory
$dailyTag | Select-Object Name, @{N='Category';E={$_.Category.Name}}
A tag does not create or prove a backup by itself. Confirm how your backup product consumes tags, verify that the intended VM is included in the right job or policy, and test the resulting recovery workflow.
Troubleshooting
| Symptom | Check before changing anything |
|---|---|
| No match or multiple matches | Check the vCenter, object ID, category, and folder. The Get-One helper deliberately stops when a name does not resolve to exactly one object. |
| Tag assignment rejected | Check permissions, the category’s supported entity types, and its Single/Multiple cardinality. Do not silently remove a conflicting assignment. |
| Blank Category column or object names in CSV | Read the category through assignment.Tag.Category.Name and the tag through assignment.Tag.Name. |
| Missing custom-attribute value | Resolve the correct attribute and entity, check the attribute target type, and use Get-Annotation. An unset value can legitimately be blank. |
| UI and script disagree | Re-query the same object ID and vCenter, refresh the browser, and investigate permissions or API errors. A client restart is not a universal repair. |
| Bulk run stopped partway | Writes are not a transaction. Inspect current assignments before resuming; do not assume earlier writes rolled back. |
What’s Next
Next article will focus on:
- Generating compliance and health reports using PowerCLI
- Scheduling reports for VM state, orphaned snapshots, and configuration drift
Introduction Managing vCenter effectively means having tight control over roles, permissions, alarms, and automation. PowerCLI enables administrators to standardize access, monitor activity,...