Reliable State Caching in PowerShell with Export-Clixml (TTL, Stable Shapes, and Fast Reuse)
Caching small, immutable results is a simple way to make PowerShell automation faster, safer, and more predictable. By serializing typed objects with Export-Clixml, you can persist state across runs without re-fetching data, hammering APIs, or recomputing expensive queries. Add a time-to-live (TTL) and a stable object shape, and you get reliable state caching that is easy to reason about and trivial to reuse in CI/CD, scheduled tasks, and containerized jobs.
This post shows you how to implement a robust cache pattern with Export-Clixml, keep your data model stable with PSCustomObject, and refresh on a predictable cadence using TTL. You will also learn practical techniques for invalidation, atomic writes, and pipeline integration.
Quick Start: Cache Objects with Export-Clixml + TTL
Here is a minimal, effective pattern you can paste into any script. It stores the collected data alongside a timestamp (When) and refreshes it after a TTL expires.
$cache = Join-Path -Path (Get-Location) -ChildPath 'inventory.clixml'
$ttl = New-TimeSpan -Hours 12
$state = if (Test-Path $cache) { Import-Clixml -Path $cache } else { $null }
if ($state -and ((Get-Date) - $state.When) -lt $ttl) {
$data = $state.Data
Write-Host 'Loaded from cache.'
} else {
$data = Get-ChildItem -Path (Join-Path $env:WINDIR 'System32') -File |
Select-Object -First 5 Name, Length
$state = [pscustomobject]@{ When = Get-Date; Data = $data }
$state | Export-Clixml -Path $cache
Write-Host 'Refreshed cache.'
}
$data | Sort-Object Name | Format-Table -AutoSizeWhat you get:
- Faster reruns
- Fewer network or filesystem calls
- Typed, reproducible state
- Simpler scripts that are easier to test
Designing a Resilient Cache
Keep the Shape Stable with PSCustomObject
CLIXML preserves types, but your cache will be easier to evolve if you control the shape. Wrap your cached data in a PSCustomObject that includes metadata. For example:
$state = [pscustomobject]@{
Version = 1 # bump this if the schema changes
When = Get-Date
Data = $data # a simple, flattened payload
}When you add properties, bump Version and handle old versions gracefully. That way, scripts remain predictable even as requirements grow.
Add Predictable Refresh with TTL
TTL-based invalidation is simple and works well for small, low-volatility datasets. Choose a TTL aligned to the freshness you need (e.g., 1–24 hours). For data tied to fast-changing systems, reduce TTL or add extra invalidation triggers (like input hashes).
Choose a Good Cache Path
- Local development:
$pwdor a.cachefolder near your script - CI/CD: runner/workspace temp like
$env:RUNNER_TEMPor$env:TEMP - Per-user caches:
$env:LOCALAPPDATAor$env:TMPDIRon Linux/macOS
function Get-CachePath {
param(
[Parameter(Mandatory)] [string] $Name
)
$root = $env:RUNNER_TEMP, $env:TEMP, (Get-Location).Path | Where-Object { $_ } | Select-Object -First 1
$dir = Join-Path $root 'ps-cache'
if (-not (Test-Path $dir)) { $null = New-Item -ItemType Directory -Path $dir }
return (Join-Path $dir $Name)
}Invalidate Reliably
- TTL expiry
- Version bump when the shape changes
- Hash of inputs that influence the result (e.g., parameters, file contents)
function Get-CacheKeyHash {
param([Parameter(Mandatory)] $InputObject)
$json = $InputObject | ConvertTo-Json -Depth 10
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
$sha = [System.Security.Cryptography.SHA256]::Create()
return ([System.BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant()
}
# Example: cache filename includes a hash of important inputs
$inputs = [pscustomobject]@{ Path = (Join-Path $env:WINDIR 'System32'); FirstN = 5 }
$key = Get-CacheKeyHash -InputObject $inputs
$cache = Get-CachePath -Name "inventory_$key.clixml"Atomic Writes and Locking
Prevent partially written files by writing to a temp file and renaming. A simple lock file avoids concurrent writers in CI or parallel tasks.
function Save-ClixmlAtomic {
param(
[Parameter(Mandatory)] $Object,
[Parameter(Mandatory)] [string] $Path
)
$temp = "$Path.tmp"
try {
$Object | Export-Clixml -Path $temp -Depth 5
if (Test-Path $Path) { Remove-Item -Path $Path -Force }
Move-Item -Path $temp -Destination $Path -Force
} finally {
if (Test-Path $temp) { Remove-Item $temp -Force }
}
}
function Use-FileLock {
param([Parameter(Mandatory)] [string] $Path, [scriptblock] $Script)
$lock = "$Path.lock"
while (Test-Path $lock) { Start-Sleep -Milliseconds 200 }
New-Item -Path $lock -ItemType File -Force | Out-Null
try { & $Script } finally { Remove-Item $lock -Force -ErrorAction SilentlyContinue }
}Reusable Helper: Get-Cached
Turn the pattern into a function so you can cache anything with a one-liner.
function Get-Cached {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $Name,
[Parameter(Mandatory)] [TimeSpan] $Ttl,
[Parameter(Mandatory)] [scriptblock] $Populate,
[int] $Version = 1,
[object] $KeyInputs
)
$suffix = if ($KeyInputs) { "_$(Get-CacheKeyHash -InputObject $KeyInputs)" } else { '' }
$path = Get-CachePath -Name ("$Name$suffix.clixml")
$load = {
if (Test-Path $path) {
try { Import-Clixml -Path $path } catch { $null }
}
}
$state = & $load
$fresh = $false
if ($state -and $state.Version -eq $Version -and ((Get-Date) - $state.When) -lt $Ttl) {
return $state.Data
}
Use-FileLock -Path $path -Script {
# Double-check inside lock
$state = & $load
if ($state -and $state.Version -eq $Version -and ((Get-Date) - $state.When) -lt $Ttl) {
return $state.Data
}
$data = & $Populate
$wrapped = [pscustomobject]@{ Version = $Version; When = Get-Date; Data = $data }
Save-ClixmlAtomic -Object $wrapped -Path $path
$script:fresh = $true
return $data
}
if ($fresh) { Write-Verbose "Cache refreshed: $path" } else { Write-Verbose "Cache hit: $path" }
}Using it:
$files = Get-Cached -Name 'sys32-top5' -Ttl (New-TimeSpan -Hours 12) -Version 1 -KeyInputs @{ Path = (Join-Path $env:WINDIR 'System32'); FirstN = 5 } -Populate {
Get-ChildItem -Path (Join-Path $env:WINDIR 'System32') -File |
Select-Object -First 5 Name, Length
}
$files | Sort-Object Name | Format-Table -AutoSizeDevOps and CI/CD: Where This Shines
Speeding Up Pipelines
In build agents and containers, recomputing inventories, dependency graphs, or metadata can waste minutes per run. Cache small, immutable results between steps or jobs:
- API lookups: service endpoints, configuration catalogs, feature flags
- Filesystem scans: tool discovery, module lists, signed file inventories
- Build meta'svc-hosts' -Ttl $ttl -Version 1 -KeyInputs @{ Url = 'https://api.example.com/hosts?env=prod' } -Populate {
Invoke-RestMethod -Uri 'https://api.example.com/hosts?env=prod' -Method Get
}
$svcHosts | Select-Object -First 10 Name, Ip
Because CLIXML preserves types, you keep rich objects and avoid string parsing. In ephemeral containers, point the cache to a bind-mounted volume to persist across runs.
Cross-Platform and Team-Friendly
Export-Clixml works on Windows, Linux, and macOS. Store caches under a shared convention like
ps-cachein the runner temp. When inputs change (e.g., different parameters or environment), the key hash gives each scenario its own file to avoid cross-talk.Performance, Reliability, and Security Tips
Performance and Size Control
- Limit depth to avoid bloated files:
Export-Clixml -Depth 3..5 - Cache only what you need: use
Select-Objectto project to essential fields - Prefer small, immutable, or slow-to-compute datasets
- Compress externally if needed; CLIXML is plain XML but typically small for simple shapes
Error Handling and Self-Healing
- Wrap
Import-Clixmlintry/catch. If deserialization fails, treat as a cache miss and rebuild - Use atomic writes to avoid half-written files after crashes
- Include
Versionand validate it before trustingData - Implement a lightweight lock for concurrent writers
Security Considerations
- Do not store secrets, tokens, or PII in CLIXML caches
SecureStringcan round-trip in CLIXML with DPAPI on Windows, but it is tied to user/machine and not portable. In CI or cross-machine scenarios, use a secret store (Microsoft.PowerShell.SecretManagement) instead- Set permissions on cache directories when needed (e.g.,
icaclson Windows,chmodon Linux) - Treat caches as untrusted input in security-sensitive contexts; validate before use
Schema Evolution Without Pain
- Encapsulate your cache format in a function
- Include a
Versionfield and migrate or invalidate older versions automatically - Avoid storing complex, non-serializable types; project them into simple
PSCustomObjectpayloads
Putting It All Together
Reliable state caching in PowerShell comes down to three principles:
- Use Export-Clixml to persist typed, stable objects
- Wrap data in a consistent
PSCustomObjectand version it - Apply a TTL (and optionally input hashing) for predictable refresh
With a few helper functions, you can add caching to any script in minutes and realize faster reruns, fewer external calls, and safer state across local development, CI/CD pipelines, and containerized jobs.
Make caching a habit. Your future builds—and your teammates—will thank you.
- Limit depth to avoid bloated files: