Consistent Data Exports in PowerShell: Stable CSV and JSON for Tiny Diffs
Noisy diffs make code reviews painful. If your CSV and JSON exports change column order, quote behavior, BOM markers, or row ordering, every commit looks bigger than it is. In this post youll build deterministic PowerShell exports: fixed column order, stable row sorting, UTF-8 without BOM, and a single source of truth for both CSV and JSON so they never drift.
Why diffs explodeand how to tame them
- Property order drift: Objects built from hash tables arent ordered unless you use
[ordered], and JSON/CSV serializers wont rescue you. Fix it by explicitly selecting a property list withSelect-Object. - Row order instability: If your input order changes, diffs do too. Sort by a stable key (e.g.,
Id). - BOM surprises: UTF-8 with a BOM vs. without a BOM leads to churn in diffs and cross-platform tools. Write UTF-8 without BOM.
- Serializer divergence: Generating CSV and JSON from different pipelines lets them drift. Derive both from the same selected set.
- Culture differences: Decimal commas, date formats, and boolean representations change by locale. Use invariant formats.
- Whitespace and quoting: Pretty JSON, inconsistent newlines, and always-quoted CSV fields increase noise. Compress JSON; use quotes as needed for CSV (where available).
A deterministic export pattern
The pattern below enforces property order, stable sorting, and UTF-8 no BOM. It also writes CSV and JSON from the same selected set to guarantee parity.
# Deterministic exports: fixed property order, stable sorting, UTF-8 no BOM
# Works in Windows PowerShell 5.1 and PowerShell 7+
# 1) Build objects with [ordered] so the initial creation is stable
$items = @(
[pscustomobject][ordered]@{ Id = 3; Name = 'Gamma'; Active = $true; Score = 9.5 },
[pscustomobject][ordered]@{ Id = 1; Name = 'Alpha'; Active = $false; Score = 7.2 }
)
# 2) Declare a canonical property order for headers and JSON property order
$cols = 'Id','Name','Active','Score'
# 3) Sort rows by stable keys to keep diffs small
$sorted = $items | Sort-Object -Property Id, Name
$selected = $sorted | Select-Object $cols
# Output paths
$outDir = '.'
$csvPath = Join-Path $outDir 'report.csv'
$jsonPath = Join-Path $outDir 'report.json'
# Small helper to write UTF-8 without BOM in any PowerShell version
function Write-Utf8NoBomText {
param(
[Parameter(Mandatory)] [string] $Path,
[Parameter(Mandatory)] [string] $Content
)
[IO.File]::WriteAllText($Path, $Content, [Text.UTF8Encoding]::new($false))
}
function Write-Utf8NoBomLines {
param(
[Parameter(Mandatory)] [string] $Path,
[Parameter(Mandatory)] [string[]] $Lines
)
[IO.File]::WriteAllLines($Path, $Lines, [Text.UTF8Encoding]::new($false))
}
# 4) CSV: build lines with ConvertTo-Csv and write with UTF-8 no BOM
$useQuotesSupported = (Get-Command ConvertTo-Csv).Parameters.ContainsKey('UseQuotes')
if ($useQuotesSupported) {
$csvLines = $selected | ConvertTo-Csv -NoTypeInformation -Delimiter ',' -UseQuotes AsNeeded
} else {
# Older PS: no -UseQuotes. Defaults to quoting all fields.
$csvLines = $selected | ConvertTo-Csv -NoTypeInformation -Delimiter ','
}
Write-Utf8NoBomLines -Path $csvPath -Lines $csvLines
# 5) JSON: same selected data, compact and deterministic
$json = $selected | ConvertTo-Json -Depth 5 -Compress
Write-Utf8NoBomText -Path $jsonPath -Content $json
Write-Host ("Saved -> {0}; {1}" -f (Resolve-Path $csvPath), (Resolve-Path $jsonPath))If youre on PowerShell 7+, you can alternatively write CSV directly with no BOM:
$selected | Export-Csv -Path $csvPath -NoTypeInformation -UseQuotes AsNeeded -Delimiter ',' -Encoding utf8NoBOM
$selected | ConvertTo-Json -Depth 5 -Compress |
Set-Content -Path $jsonPath -Encoding utf8NoBOMWhy this works
- [ordered] + Select-Object: Guarantees header and property order.
- Sort-Object: A stable row order turns massive diffs into single-line changes.
- One pipeline for both formats: CSV and JSON describe the same data, no drift.
- UTF-8 no BOM: Plays well with Git, CI, and cross-platform parsers.
- -Compress JSON and optional -UseQuotes AsNeeded: Minimal whitespace and only necessary quotes keep diffs tiny.
Culture-safe numbers, dates, and booleans
For reproducibility across machines and build agents, normalize culture-sensitive values.
- Numbers: Ensure decimal
.instead of locale-specific,. - Dates: Serialize as UTC ISO 8601 (
yyyy-MM-ddTHH:mm:ss.fffffffZ). - Booleans: CSV shows
True/Falseby default; JSON istrue/false. If you need lowercase in CSV, convert to strings.
# Temporarily force invariant culture for the export scope
$oldCulture = [System.Threading.Thread]::CurrentThread.CurrentCulture
try {
[System.Threading.Thread]::CurrentThread.CurrentCulture = [System.Globalization.CultureInfo]::InvariantCulture
$items = @(
[pscustomobject][ordered]@{
Id = 42
Name = 'Widget'
Active = $true # CSV: True; JSON: true
Score = [double]::Parse('9.5', [Globalization.CultureInfo]::InvariantCulture)
CreatedAt = (Get-Date).ToUniversalTime().ToString('o') # ISO 8601
}
)
# ... then run the same export flow as above
}
finally {
[System.Threading.Thread]::CurrentThread.CurrentCulture = $oldCulture
}Make it CI-friendly
Consistent newlines and diffs
Declare text handling in Git so line endings and diff drivers are consistent on every machine.
# .gitattributes
*.csv text eol=lf
*.json text eol=lf
# Optional: a simple CSV diff driver (treats as text)
*.csv diff=csv
If you define a custom csv diff driver, add in .git/config or global config:
[diff "csv"]
textconv = python -c "import sys,csv; print('\n'.join(','.join(r) for r in csv.reader(sys.stdin)))"
Tip: Keep JSON compressed in CI to avoid whitespace diffs, or standardize a prettifier in a pre-commit hook if reviewers prefer indentation.
Guardrails with Pester
Add tests to ensure schema and order never change silently.
Describe 'Stable exports' {
It 'has the expected CSV header order' {
$cols = 'Id','Name','Active','Score'
$csv = Get-Content ./report.csv -First 1
$csv | Should -Be ('"' + ($cols -join '","') + '"')
}
It 'keeps JSON properties in the declared order' {
$json = Get-Content ./report.json -Raw | ConvertFrom-Json
$first = $json[0]
# PowerShell preserves insertion order in PSCustomObject
($first.PSObject.Properties.Name -join ',') | Should -Be 'Id,Name,Active,Score'
}
It 'is byte-for-byte UTF-8 without BOM' {
$bytes = [IO.File]::ReadAllBytes('./report.json')
# BOM for UTF-8 is EF BB BF; ensure its absent
$bytes[0..2] -join '-' | Should -Not -Be '239-187-191'
}
}Practical tips and gotchas
- Avoid -UseCulture with Export/Convert: It can reintroduce locale-dependent quirks. Prefer invariant handling as shown.
- Explicit delimiter: Use
-Delimiter ','even if comma is default, so intent is obvious. - Null handling: JSON writes
null. For CSV, consider replacing nulls with empty strings or a sentinel value to stabilize diffs. - Large exports: Stream with
System.IO.StreamWriterandUTF8Encoding($false)to avoid holding entire payloads in memory. - Schema changes: When you add a column, append it at the end of
$colsto minimize churn. Document the schema in version control. - One function, two outputs: Wrap the pattern in a module function (e.g.,
Export-StableData) so every pipeline uses the same logic.
Quick checklist
- Build objects using
[ordered]. - Declare a canonical
$colsarray andSelect-Object $cols. Sort-Objectby stable keys.- Write UTF-8 without BOM for both CSV and JSON.
- Generate CSV and JSON from the same selected set.
- Normalize culture for numbers and dates.
- Lock in Git newline and diff behavior.
- Test with Pester to prevent accidental drift.
With these patterns, your exports become predictable, reviews get cleaner, and CI runs are stable across machines and platforms.