Guides · Data for reports

PowerShell ConvertTo-Json Depth: Stop Losing Nested Data

Published · Tested with PowerShell 7.6.6 and Windows PowerShell 5.1 (build 26100) on Windows 11 Pro

A script reads an API response, adds a field and saves it with ConvertTo-Json. The file looks fine until someone opens an order and finds "tax": "@{rate=0.2; amount=8}" where the tax object used to be. By default ConvertTo-Json keeps two levels below the top and turns anything deeper into text. Windows PowerShell 5.1 does it without a word; PowerShell 7 writes a warning and goes on. This guide shows how the levels are counted, three more ways the same command changes your data, and a function that writes the JSON only after reading it back and finding every value.

Before you start

What the default depth does

One invented order, saved as order.json. The customer's address has a geo object; each line has a tax object:

{
  "id": 5001,
  "customer": { "name": "Ana Example", "address": { "city": "Lyon", "geo": { "lat": 45.76, "lon": 4.84 } } },
  "lines": [
    { "sku": "A-100", "qty": 2, "tax": { "rate": 0.2, "amount": 8.0 } },
    { "sku": "B-200", "qty": 1, "tax": { "rate": 0.055, "amount": 1.1 } }
  ]
}

Read it and write it back, the way a script that changes one field would:

$order = Get-Content order.json -Raw | ConvertFrom-Json
$json = $order | ConvertTo-Json
"`$? = $?"
$json

In PowerShell 7.6.6:

WARNING: Resulting JSON is truncated as serialization has exceeded the set depth of 2.
$? = True
{
  "id": 5001,
  "customer": {
    "name": "Ana Example",
    "address": {
      "city": "Lyon",
      "geo": "@{lat=45.76; lon=4.84}"
    }
  },
  "lines": [
    {
      "sku": "A-100",
      "qty": 2,
      "tax": "@{rate=0.2; amount=8}"
    },
    {
      "sku": "B-200",
      "qty": 1,
      "tax": "@{rate=0.055; amount=1.1}"
    }
  ]
}

In Windows PowerShell 5.1, the same lines gave the same loss with no warning at all (cut after the first line; the second line matches):

$? = True
{
    "id":  5001,
    "customer":  {
                     "name":  "Ana Example",
                     "address":  {
                                     "city":  "Lyon",
                                     "geo":  "@{lat=45.76; lon=4.84}"
                                 }
                 },
    "lines":  [
                  {
                      "sku":  "A-100",
                      "qty":  2,
                      "tax":  "@{rate=0.2; amount=8.0}"
                  },
                  ...

In PowerShell 7 the warning can be made an error with -WarningAction Stop:

PS> try { $null = $order | ConvertTo-Json -WarningAction Stop } catch { $_.Exception.Message }
The running command stopped because the preference variable "WarningPreference" or common parameter is set to Stop: Resulting JSON is truncated as serialization has exceeded the set depth of 2.

That is a good line for PowerShell 7 scripts, but it does nothing in 5.1 and it only covers depth. The usual advice, -Depth 10 or -Depth 100, fixes this order; the next two sections show what it does not fix.

How the levels are counted

The top object is level 0. Every object or array inside it adds one level, and an array counts as a level of its own. With -Depth N, anything that would be an object or array at level N+1 is written as text instead. Plain values (strings, numbers, true/false) are never cut. The same results in both versions:

Input                       -Depth 1                -Depth 2
{"a":{"b":{"c":1}}}         {"a":{"b":"@{c=1}"}}    {"a":{"b":{"c":1}}}
{"a":[{"b":1}]}             {"a":["@{b=1}"]}        {"a":[{"b":1}]}

So the order above needs -Depth 3: lines is level 1, each line is level 2, each tax is level 3. A response with a data array of records that hold objects is already at level 3.

The ceiling is 100 in both versions. -Depth 101 fails: PowerShell 7 says “The 101 argument is greater than the maximum allowed range of 100”, and 5.1 says “The maximum depth allowed for serialization is 100.”

Three more ways the JSON changes

A larger -Depth does not help with these. All three ran without an error or a warning:

A function that checks before it writes

The function below does three things, in order. It walks the object and stops on anything JSON cannot hold as it is: a type that is not a plain value, a string with extra properties in 5.1, or more than 100 levels. It converts with -InputObject and -Depth 100. Then it reads the JSON back with ConvertFrom-Json and compares every value with the original, and writes the file only if nothing changed. Save it as Export-CheckedJson.ps1:

# Export-CheckedJson.ps1: write an object as JSON only when nothing is lost on the way.
# Checks the object before converting it, reads the JSON back and compares every value.
# Dot-source it, then call Export-CheckedJson. Windows PowerShell 5.1 and PowerShell 7.

$script:JsonMaxLevels = 100  # ConvertTo-Json -Depth accepts at most 100
$script:JsonNumberTypes = [System.Collections.Generic.HashSet[type]] @(
    [byte], [sbyte], [int16], [uint16], [int], [uint32], [long], [uint64],
    [single], [double], [decimal], [System.Numerics.BigInteger])

function Get-JsonKind {
    # How JSON sees a value: null, string, bool, number, date, object, array or unsupported.
    param($Value)
    if ($null -eq $Value) { return 'null' }
    if ($Value -is [string]) { return 'string' }
    if ($Value -is [bool]) { return 'bool' }
    if ($Value -is [datetime]) { return 'date' }
    if ($script:JsonNumberTypes.Contains($Value.GetType())) { return 'number' }
    if ($Value -is [System.Collections.IDictionary]) { return 'object' }
    if ($Value -is [System.Management.Automation.PSCustomObject]) { return 'object' }
    if ($Value -is [System.Collections.IList]) { return 'array' }
    return 'unsupported'
}

function Get-JsonMember {
    # Name/value pairs of a hashtable or a PSCustomObject.
    param($Value)
    if ($Value -is [System.Collections.IDictionary]) {
        foreach ($key in $Value.Keys) { [pscustomobject]@{ Name = [string] $key; Value = $Value[$key] } }
    }
    else {
        foreach ($p in $Value.PSObject.Properties) { [pscustomobject]@{ Name = $p.Name; Value = $p.Value } }
    }
}

function Find-JsonProblem {
    # Before converting: values that JSON cannot hold as they are. One line per problem.
    param($Value, [string] $Path, [int] $Level = 0)
    $kind = Get-JsonKind $Value
    if ($kind -eq 'unsupported') {
        return "${Path}: $($Value.GetType().FullName) is not a plain JSON value; convert it to a string or number first"
    }
    if ($kind -eq 'string' -and $PSVersionTable.PSVersion -lt [version] '7.2') {
        $extra = @($Value.PSObject.Properties | Where-Object { $_.MemberType -eq 'NoteProperty' })
        if ($extra.Count -gt 0) {
            return "${Path}: the string carries extra properties ($($extra[0].Name)...) that Windows PowerShell writes as an object; cast it with [string] first"
        }
    }
    if ($kind -ne 'object' -and $kind -ne 'array') { return }
    if ($Level -ge $script:JsonMaxLevels) {
        if ($Path.Length -gt 40) { $Path = $Path.Substring(0, 20) + '...' + $Path.Substring($Path.Length - 15) }
        return "${Path}: nested deeper than $($script:JsonMaxLevels) levels"
    }
    if ($kind -eq 'object') {
        foreach ($m in Get-JsonMember $Value) { Find-JsonProblem $m.Value "$Path.$($m.Name)" ($Level + 1) }
    }
    else {
        for ($i = 0; $i -lt $Value.Count; $i++) { Find-JsonProblem $Value[$i] "$Path[$i]" ($Level + 1) }
    }
}

function ConvertTo-UtcDate {
    param($Value)
    if ($Value -is [datetime]) { return $Value.ToUniversalTime() }
    $parsed = [datetime]::MinValue
    $styles = [System.Globalization.DateTimeStyles]::RoundtripKind
    if ([datetime]::TryParse([string] $Value, [cultureinfo]::InvariantCulture, $styles, [ref] $parsed)) {
        return $parsed.ToUniversalTime()
    }
    return $null
}

function Compare-JsonValue {
    # After converting: walks the original and the value read back together. One line per difference.
    param($Expected, $Actual, [string] $Path)
    $kind = Get-JsonKind $Expected
    $backKind = Get-JsonKind $Actual
    # PowerShell 7 reads date-like strings back as dates: compare those as points in time.
    if ($kind -eq 'date' -or ($kind -eq 'string' -and $backKind -eq 'date')) {
        $a = ConvertTo-UtcDate $Expected
        $b = ConvertTo-UtcDate $Actual
        if ($null -eq $a -or $null -eq $b -or [math]::Abs(($a - $b).TotalMilliseconds) -ge 1) {
            return "${Path}: expected date $Expected, read back '$Actual'"
        }
        return
    }
    if ($kind -ne $backKind) {
        $shown = if ($backKind -eq 'string') { "'$Actual'" } else { $backKind }
        return "${Path}: expected $kind, read back $shown"
    }
    switch ($kind) {
        'object' {
            $back = @{}
            foreach ($m in Get-JsonMember $Actual) { $back[$m.Name] = $m.Value }
            foreach ($m in Get-JsonMember $Expected) {
                if (-not $back.ContainsKey($m.Name)) { "${Path}.$($m.Name): missing"; continue }
                Compare-JsonValue $m.Value $back[$m.Name] "$Path.$($m.Name)"
            }
        }
        'array' {
            if ($Expected.Count -ne $Actual.Count) {
                return "${Path}: expected $($Expected.Count) items, read back $($Actual.Count)"
            }
            for ($i = 0; $i -lt $Expected.Count; $i++) { Compare-JsonValue $Expected[$i] $Actual[$i] "$Path[$i]" }
        }
        'string' { if ($Expected -cne $Actual) { "${Path}: expected '$Expected', read back '$Actual'" } }
        default  { if ($Expected -ne $Actual) { "${Path}: expected $Expected, read back $Actual" } }
    }
}

function Export-CheckedJson {
    <#
      Writes InputObject to Path as JSON (UTF-8 without BOM) only when the JSON, read back,
      has every value of InputObject. Otherwise it throws, lists what would be lost and
      writes nothing.
    #>
    param(
        [Parameter(Mandatory)] [AllowNull()] [AllowEmptyCollection()] $InputObject,
        [Parameter(Mandatory)] [string] $Path,
        [switch] $Compress
    )
    $fail = {
        param([string[]] $Lines)
        $shown = @($Lines | Select-Object -First 10) -join "`n  "
        throw ("JSON not written to {0}: {1} problem(s).`n  {2}" -f $Path, $Lines.Count, $shown)
    }
    $problems = @(Find-JsonProblem $InputObject '$')
    if ($problems.Count -gt 0) { & $fail $problems }

    # -InputObject, not the pipeline: an array with one item, or none, stays an array.
    $json = ConvertTo-Json -InputObject $InputObject -Depth $script:JsonMaxLevels -Compress:$Compress
    if ($null -eq $json) { $json = 'null' }  # Windows PowerShell 5.1 returns nothing for $null

    $read = @{ InputObject = $json }
    # PowerShell 7 unrolls a top-level array unless told not to; 5.1 has no such parameter.
    if ((Get-Command ConvertFrom-Json).Parameters.ContainsKey('NoEnumerate')) { $read.NoEnumerate = $true }
    try { $back = ConvertFrom-Json @read }
    catch { & $fail "could not read the JSON back: $($_.Exception.Message.Split([char]10)[0])" }

    $differences = @(Compare-JsonValue $InputObject $back '$')
    if ($differences.Count -gt 0) { & $fail $differences }

    $full = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
    $tmp = "$full.tmp"
    [System.IO.File]::WriteAllText($tmp, $json, (New-Object System.Text.UTF8Encoding $false))
    Move-Item -LiteralPath $tmp -Destination $full -Force
    "OK: $Path ($($json.Length) characters, read back and compared)"
}

Run it

Our test script dot-sources the function, reads order.json into $order as above and runs one case after another, printing the error message when a call fails. Its output in PowerShell 7.6.6, from the first run, without the cases shown further down (the warning comes from the one-liner, run first in the same case to write naive.json for the last section):

--- order: one-liner, then checked
WARNING: Resulting JSON is truncated as serialization has exceeded the set depth of 2.
OK: order.json (457 characters, read back and compared)
--- one order in an array
pipeline: {"id":5001,"
OK: one.json (228 characters, read back and compared)
file: [{"id":5001,
--- no orders
pipeline: 0 strings
OK: none.json (2 characters, read back and compared)
file: []
--- 101 levels
ERROR: JSON not written to deep.json: 1 problem(s).
  $.next.next.next.nex....next.next.next: nested deeper than 100 levels
--- a date, a guid and an enum
one-liner: {"id":5001,"created":"2026-10-07T08:30:00Z","ref":"6f1c1d2e-0000-4000-8000-000000000001","day":3}
ERROR: JSON not written to row.json: 2 problem(s).
  $.ref: System.Guid is not a plain JSON value; convert it to a string or number first
  $.day: System.DayOfWeek is not a plain JSON value; convert it to a string or number first
--- the same row, converted first
OK: row.json (107 characters, read back and compared)
file: {"id":5001,"created":"2026-10-07T08:30:00Z","ref":"6f1c1d2e-0000-4000-8000-000000000001","day":"Wednesday"}

The calls behind those lines are one-liners such as Export-CheckedJson -InputObject @($order) -Path one.json -Compress; the 101-level case is a [pscustomobject]@{ next = … } wrapped 101 times around a string.

Windows PowerShell 5.1 gave the same results, with no warning, a longer file for the order (5.1 indents with more spaces: 1,059 characters), the date as "\/Date(1791361800000)\/", and one more stop, for the lines read with Get-Content:

--- lines from Get-Content
ERROR: JSON not written to skus.json: 2 problem(s).
  $.skus[0]: the string carries extra properties (PSPath...) that Windows PowerShell writes as an object; cast it with [string] first
  $.skus[1]: the string carries extra properties (PSPath...) that Windows PowerShell writes as an object; cast it with [string] first
--- lines from Get-Content, cast to [string[]]
OK: skus.json (26 characters, read back and compared)
file: {"skus":["A-100","B-200"]}

The Guid is refused although ConvertTo-Json writes it correctly as text: the function accepts only plain values, so that every type that reaches the file is one you chose. Convert with .ToString(), as in the last case. In a scheduled job, let the error end the script with a non-zero exit code; Task Scheduler Says Success but Your PowerShell Script Failed has a template for that.

Check a file you already have

Compare-JsonValue also works on its own. Compare the object your script meant to write with the file it wrote, here the one written by the one-liner at the top of this page:

PS> $written = Get-Content naive.json -Raw | ConvertFrom-Json
PS> Compare-JsonValue $order $written '$'
$.customer.address.geo: expected object, read back '@{lat=45.76; lon=4.84}'
$.lines[0].tax: expected object, read back '@{rate=0.2; amount=8}'
$.lines[1].tax: expected object, read back '@{rate=0.055; amount=1.1}'

No output means no difference. 5.1 printed the same three lines, with amount=8.0.

Limits

The code on this page was written for this guide and tested on the versions above. The test scripts also set the culture to en-US, so that messages and numbers came out in English. The data is invented.