Predictable JSON Output in PowerShell: Stable Shape, Order, and UTF-8 for Clean Diffs
When your JSON output is predictable, everything downstream gets easier: API contracts stay stable, diffs are readable, code reviews are faster, and merges are smaller. In PowerShell, predictability comes from three levers you control: the shape (what fields exist), the order (how fields and arrays are arranged), and the encoding (how bytes hit disk). This post shows you how to build deterministic JSON pipelines in PowerShell using ordered objects, robust serialization, and UTF-8 without BOM.
Why Predictable JSON Matters
JSON is the lingua franca between services and teams. Unstable output creates churn. You’ll see noisy diffs, accidental contract breaks, and avoidable PR conflicts. Deterministic JSON fixes that:
- Cleaner diffs: Field order and stable array sorting cut noise, making reviews faster.
- Predictable merges: Smaller, localized changes reduce conflicts.
- Trustworthy APIs: Clients rely on consistent schemas and value formats.
- Reproducible builds: Artifacts are byte-for-byte stable across machines and runs.
- Better caching: Deterministic payloads increase cache hit rates for CDNs and CI.
Building Deterministic JSON in PowerShell
1) Lock field order with [ordered]
PowerShell’s hashtables are insertion-ordered only if you explicitly request it. Use [ordered] to preserve property order in the final JSON.
$path = './payload.json'
# Stable field order with [ordered]
$body = [pscustomobject][ordered]@{
Version = '1.0'
Meta = [pscustomobject][ordered]@{
Env = 'Prod'
When = (Get-Date -Format 'o')
Trace = ([System.Diagnostics.TraceLevel]::Info).ToString()
}
Data = @(
[pscustomobject][ordered]@{ Id = 1; Name = 'alpha' },
[pscustomobject][ordered]@{ Id = 2; Name = 'beta' }
)
}
$json = $body | ConvertTo-Json -Depth 6 -Compress
[IO.File]::WriteAllText($path, $json, [Text.UTF8Encoding]::new($false))
Write-Host ("Saved -> {0}" -f (Resolve-Path -Path $path))
Notes:
- Why ordered? Without
[ordered], PowerShell may rearrange keys, producing noisy diffs. - Depth:
-Depth 6is usually plenty for nested objects; tune as needed. - -Compress: Removes whitespace for smaller payloads and byte-for-byte stability across platforms.
2) Normalize values for stable shape
Consistency isn’t just field order. It’s also about consistent types and formats:
- Dates: Use ISO 8601:
(Get-Date -Format 'o'). It’s unambiguous and lexicographically sortable. - Enums: Convert to strings once:
SomeEnumValue.ToString()to avoid numeric drift. - Numbers: Ensure numeric types are numbers (not strings). Avoid culture-specific formats.
- Booleans: Use
$true/$false, not'true'/'false'. - Nulls vs empties: Decide on
$nullvs@()for empty collections and be consistent.
# Normalize fields consistently
$meta = [pscustomobject][ordered]@{
Env = $env:BUILD_ENV ?? 'Prod'
When = (Get-Date -Format 'o')
Trace = 'Info'
Revision = [int]($env:BUILD_NUMBER ?? 0)
Flags = @{ canary = $false; preview = $false }
}
3) Sort arrays deterministically
Even if your fields are ordered, arrays can still shuffle across runs if they come from nondeterministic sources. Sort by a stable key before serialization.
$body.Data = $body.Data | Sort-Object -Property Id, Name
For nested arrays, sort at each level. If you must preserve a natural order from the source, include and sort by an explicit Ordinal field.
4) Serialize with intent
ConvertTo-Json is powerful; use it intentionally:
- -Depth: Set high enough to include all nested objects. If you see
... (truncated)in output, increase it. - -Compress: Prefer compressed output for consistency and smaller files.
- Culture-invariance: Pre-format culture-sensitive values (dates, decimals-as-strings) before serialization.
function Save-Json {
param(
[Parameter(Mandatory)] [object] $InputObject,
[Parameter(Mandatory)] [string] $Path,
[int] $Depth = 6,
[switch] $Compress
)
if ($Compress) {
$json = $InputObject | ConvertTo-Json -Depth $Depth -Compress
} else {
$json = $InputObject | ConvertTo-Json -Depth $Depth
}
$utf8NoBom = [Text.UTF8Encoding]::new($false)
[IO.File]::WriteAllText($Path, $json, $utf8NoBom)
}
Save-Json -InputObject $body -Path './payload.json' -Depth 6 -Compress
5) Write UTF-8 without BOM for cross-platform diffs
Windows defaults can introduce BOM or a different encoding that breaks cross-platform tooling and Git diffs. Always write UTF-8 without BOM.
# PowerShell 5.1 and 7+
$utf8NoBom = [Text.UTF8Encoding]::new($false)
[IO.File]::WriteAllText($path, $json, $utf8NoBom)
# Posh 5.1 alternative syntax
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($path, $json, $utf8NoBom)
Why this matters:
- Git diffs: No accidental “binary change” due to BOM insertion.
- Linux/macOS tools: Fewer parsing issues in strict parsers.
- APIs: Content-Length matches exactly; no BOM skew.
Automate, Test, and Ship
Make JSON builds reproducible
Dynamic fields (timestamps, GUIDs) produce noisy diffs. Control them:
- Inject values: Accept
Whenfrom environment variables in CI to freeze a value during a pipeline run. - Round timestamps: If exact seconds aren’t needed, round to seconds; use a stable timezone (UTC).
- Scope volatility: Keep volatile fields under a dedicated
Metasection to isolate diffs.
$when = if ($env:BUILD_TIMESTAMP) { [DateTime]::Parse($env:BUILD_TIMESTAMP).ToString('o') } else { (Get-Date).ToUniversalTime().ToString('o') }
$body.Meta.When = $when
Golden file comparison in CI
Put a canonical JSON file under version control and compare the build output to catch regressions. Use Pester or a simple string comparison for byte-level checks.
# tests/StableJson.Tests.ps1
Describe 'Stable JSON' {
It 'matches the golden file exactly' {
$actual = Get-Content './payload.json' -Raw
$expected = Get-Content './payload.golden.json' -Raw
$actual | Should -BeExactly $expected
}
}
In GitHub Actions:
name: json-stability
on: [push, pull_request]
jobs:
check-json:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Setup PowerShell
uses: PowerShell/PowerShell@v1
with:
version: '7.4.x'
- name: Build JSON
shell: pwsh
run: |
./build.ps1
- name: Run Pester
shell: pwsh
run: |
Invoke-Pester -CI
Tip: If certain fields are intentionally volatile, compare a filtered view in tests (e.g., remove Meta.When before comparison) or validate shape and order separately with regex assertions.
API-facing pitfalls to avoid
- Accidental type drift: Don’t mix numbers and strings for the same field across runs.
- Missing -Depth: Truncated JSON causes silent schema breaks. Set depth high enough.
- Reordered merges: If you mutate objects in multiple places, always re-wrap with
[ordered]or rebuild the final shape in one place. - Locale surprises: Don’t rely on
ToString()for numbers or dates without a format.
A reusable pattern you can drop in today
function New-OrderedObject { param([hashtable]$Map) return [pscustomobject][ordered]$Map }
function New-Payload {
param(
[string] $Env = 'Prod',
[string] $Trace = 'Info',
[datetime] $When = (Get-Date).ToUniversalTime()
)
$meta = New-OrderedObject @{ Env = $Env; When = $When.ToString('o'); Trace = $Trace }
$data = @(
New-OrderedObject @{ Id = 1; Name = 'alpha' },
New-OrderedObject @{ Id = 2; Name = 'beta' }
) | Sort-Object Id, Name
return New-OrderedObject @{ Version = '1.0'; Meta = $meta; Data = $data }
}
function Save-Json {
param([Parameter(Mandatory)][object]$InputObject,[Parameter(Mandatory)][string]$Path,[int]$Depth=6,[switch]$Compress)
if ($Compress) { $json = $InputObject | ConvertTo-Json -Depth $Depth -Compress } else { $json = $InputObject | ConvertTo-Json -Depth $Depth }
$utf8NoBom = [Text.UTF8Encoding]::new($false)
[IO.File]::WriteAllText($Path, $json, $utf8NoBom)
}
$payload = New-Payload -Env ($env:BUILD_ENV ?? 'Prod')
Save-Json -InputObject $payload -Path './payload.json' -Depth 6 -Compress
Write-Host 'Payload saved.'
What you get
- Stable diffs: Reviewers see just what changed, not noise.
- Predictable APIs: Clients integrate once, not every sprint.
- Smaller merges: Less conflict, faster PRs.
- Cross-platform cleanly: UTF-8 no BOM works the same on Windows, Linux, and macOS.
Build trustworthy JSON pipelines in PowerShell: control shape with [ordered], serialize with ConvertTo-Json (-Depth and -Compress), and write UTF-8 without BOM. Your future self, your teammates, and your CI will thank you.