TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

Safe JSON Updates in PowerShell: Diff-Friendly, Atomic, and Idempotent

Configuration files should be predictable to edit, easy to review, and safe to deploy. When you work with JSON in PowerShell, a few guardrails make a big difference: load once, change in memory, write only if something actually changed, back up with a timestamp, and save as UTF-8 (no BOM) for clean diffs. This post shows you a practical pattern and a production-ready function you can drop into your scripts and pipelines.

The core pattern: load, compare, write only if changed

Here is the minimal, reliable approach. You read the file once, modify the object in memory, and write the file only if a change is needed. You also create a timestamped backup and save with UTF-8 (no BOM) to avoid diff noise.

$path = './settings.json'
$json = Get-Content -Path $path -Raw -ErrorAction Stop
$obj  = $json | ConvertFrom-Json -Depth 10

$newApi = 'https://api.example.com'
if ($obj.ApiBase -ne $newApi) {
  $backup = "$path.bak.$(Get-Date -Format 'yyyyMMdd-HHmmss')"
  Copy-Item -Path $path -Destination $backup -Force
  $obj.ApiBase = $newApi
  $out = $obj | ConvertTo-Json -Depth 10
  [IO.File]::WriteAllText($path, $out, [Text.UTF8Encoding]::new($false))
  Write-Host ("Updated ApiBase -> {0}" -f $obj.ApiBase)
} else {
  Write-Host 'No change. Skipped write.'
}

Why this works

  • Idempotent: You only write when the value actually changes.
  • Diff-friendly: UTF-8 without BOM avoids noisy encoding changes. Pretty JSON stays readable.
  • Auditable: Timestamped backups let you recover quickly and compare versions.

Normalize before you compare (optional)

Sometimes you want to avoid writing even if the file contains the same data with different whitespace. You can normalize both the existing file and your updated object through ConvertTo-Json and compare the strings:

$existingNormalized = ($json | ConvertFrom-Json -Depth 10 | ConvertTo-Json -Depth 10)
$updatedNormalized  = ($obj  | ConvertTo-Json -Depth 10)
if ($existingNormalized -eq $updatedNormalized) {
  Write-Host 'No semantic change. Skipped write.'
  return
}

This keeps diffs focused on actual data changes, not formatting differences.

A production-ready function: atomic, backup-aware, and diff-safe

For repeatable updates in CI/CD or admin scripts, wrap the pattern into a function with dry-run support and atomic writes. Atomic writes ensure reviewers and other processes never see a half-written file.

function Update-JsonFile {
  [CmdletBinding(SupportsShouldProcess)]
  param(
    [Parameter(Mandatory)] [string]$Path,
    [hashtable]$Set,
    [string[]]$Remove,
    [switch]$Backup,
    [switch]$Atomic
  )

  # Load once
  $json = Get-Content -Path $Path -Raw -ErrorAction Stop
  $obj  = $json | ConvertFrom-Json -Depth 50

  # Snapshot for diff (normalized)
  $before = $obj | ConvertTo-Json -Depth 50

  # Apply changes in memory
  if ($Set) {
    foreach ($k in $Set.Keys) {
      $obj | Add-Member -NotePropertyName $k -NotePropertyValue $Set[$k] -Force
    }
  }
  if ($Remove) {
    foreach ($k in $Remove) {
      if ($obj.PSObject.Properties[$k]) {
        $obj.PSObject.Properties.Remove($k)
      }
    }
  }

  # Normalize for comparison
  $out = $obj | ConvertTo-Json -Depth 50
  if ($out -eq $before) {
    Write-Verbose 'No change; skipping write.'
    return
  }

  # Optional backup
  $backupPath = $null
  if ($Backup) {
    $backupPath = "$Path.bak.$(Get-Date -Format 'yyyyMMdd-HHmmss')"
    Copy-Item -Path $Path -Destination $backupPath -Force
  }

  if ($PSCmdlet.ShouldProcess($Path, 'Write updated JSON')) {
    if ($Atomic) {
      # Atomic write: write to a temp file in the same directory, then move
      $dir = Split-Path -Parent $Path
      $tmp = Join-Path $dir ('.{0}.tmp' -f [Guid]::NewGuid())
      [IO.File]::WriteAllText($tmp, $out, [Text.UTF8Encoding]::new($false))
      Move-Item -Path $tmp -Destination $Path -Force
    } else {
      [IO.File]::WriteAllText($Path, $out, [Text.UTF8Encoding]::new($false))
    }
  }
}

# Example usage
Update-JsonFile -Path './settings.json' -Set @{ ApiBase = 'https://api.example.com' } -Backup -Atomic -WhatIf
# Remove -WhatIf to actually write

Behavior notes

  • -WhatIf: Because the function supports ShouldProcess, you can dry-run safely.
  • -Atomic: Write to a temp file and rename in-place. On the same volume, the rename is effectively atomic.
  • Backups: Keep a short retention policy to avoid clutter (see snippet below).

Backup retention snippet

$path = './settings.json'
Get-ChildItem -Path ("$path.bak.*") |
  Sort-Object LastWriteTime -Descending |
  Select-Object -Skip 5 |
  Remove-Item -Force

Practical tips, pitfalls, and CI integration

Keep diffs clean

  • UTF-8 without BOM: [Text.UTF8Encoding]::new($false) avoids BOM bytes that show up as noise in diffs.
  • Pretty JSON: Avoid -Compress if you want readable diffs. Single-line JSON is hard to review.
  • Stable formatting: Normalize via ConvertTo-Json before comparing to suppress whitespace-only changes.

Depth and nested data

  • Use a sufficient -Depth for both ConvertFrom-Json and ConvertTo-Json when handling deep objects/arrays (e.g., 10–50).
  • If you truncate with too small a depth, nested properties may serialize to ... or be lost, causing unintended changes.

Preserve property order

  • PowerShell preserves property order on PSCustomObject. Updating an existing property like $obj.ApiBase = 'x' keeps the original order, which helps keep diffs minimal.
  • If you reconstruct objects from hashtables, be aware that order can differ; prefer working with the loaded PSCustomObject.

Types matter

  • Set numbers as numbers, booleans as booleans. For example, $obj.RetryCount = 3, not '3'. This prevents noisy diffs and runtime surprises.
  • For arrays, replace with a new array when appropriate to avoid accidental reordering diffs: $obj.Allowed = @('a','b','c').

Dealing with JSON comments and trailing commas

Strict JSON does not allow comments or trailing commas. If your file uses JSONC-style comments, strip them before ConvertFrom-Json:

# Remove // line comments and /* */ block comments (basic approach)
$raw = Get-Content -Path $path -Raw -ErrorAction Stop
$clean = $raw -replace '(?s)/\*.*?\*/', '' -replace '(?m)^\s*//.*$', ''
$obj = $clean | ConvertFrom-Json -Depth 20

Tip: Keep the canonical, comment-free JSON under source control. If you must support comments, enforce a normalization step in CI.

Atomicity and concurrency

  • Use the atomic rename pattern shown earlier. Writing to a temp file in the same directory and then renaming minimizes the window where the file is inconsistent.
  • If multiple writers may update the same file, coordinate via file locks or a higher-level orchestration mechanism (e.g., a single updater in your pipeline).

Error handling and safety

  • Always use -ErrorAction Stop on file reads so you fail fast on missing or locked files.
  • Wrap updates in try/catch when running unattended, and log the backup path on success for easy rollback.
  • Validate JSON after writing if an external tool might post-process it.

CI/CD integration

  • Run Update-JsonFile in a build step to inject environment-specific values, then commit or package the artifact.
  • Pin the PowerShell version in CI to keep ConvertTo-Json behavior consistent across build agents.
  • Gate merges with a job that fails if the script would introduce non-semantic changes (normalize-and-compare).

End-to-end example

Assume settings.json:

{
  "ApiBase": "https://api.dev.local",
  "RetryCount": 3,
  "Features": {
    "Beta": false
  }
}

Update for production, safely and diff-friendly:

$path = './settings.json'
$json = Get-Content -Path $path -Raw -ErrorAction Stop
$obj  = $json | ConvertFrom-Json -Depth 20

# Desired changes
$desired = [ordered]@{
  ApiBase    = 'https://api.example.com'
  RetryCount = 5
}

# Apply changes idempotently
$changed = $false
foreach ($k in $desired.Keys) {
  if ($obj.$k -ne $desired[$k]) {
    $obj | Add-Member -NotePropertyName $k -NotePropertyValue $desired[$k] -Force
    $changed = $true
  }
}

# Normalize compare to avoid whitespace-only rewrites
$before = ($json | ConvertFrom-Json -Depth 20 | ConvertTo-Json -Depth 20)
$after  = ($obj  | ConvertTo-Json -Depth 20)
if ($after -ne $before -and $changed) {
  $backup = "$path.bak.$(Get-Date -Format 'yyyyMMdd-HHmmss')"
  Copy-Item -Path $path -Destination $backup -Force
  [IO.File]::WriteAllText($path, $after, [Text.UTF8Encoding]::new($false))
  Write-Host ("Updated settings.json; backup: {0}" -f $backup)
} else {
  Write-Host 'No change. Skipped write.'
}

What you get: fewer diffs, safer updates, cleaner reviews, and predictable configuration changes. Adopt these patterns once, and your scripts, pipelines, and code reviews will be calmer and more trustworthy.

← All Posts Home →