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
-Depthis. “Specifies how many levels of contained objects are included in the JSON representation. The value can be any number from0to100. The default value is2” (Microsoft Learn, ConvertTo-Json). In Windows PowerShell 5.1 the range is1to100(same page, 5.1). - The warning is new in PowerShell 7.1. “As of PowerShell 7.1,
ConvertTo-Jsonemits a warning if the depth of the input object exceeds the depth specified for the command” (Microsoft Learn, ConvertTo-Json). A warning does not stop a script, and nobody reads it in a scheduled job. - How we tested. Every script on this page ran three times in PowerShell 7.6.6 (Microsoft Store package) and three times in Windows PowerShell 5.1.26100.9444, on Windows 11 Pro, with the culture set to
en-USfor the run. All three runs gave the same output every time, apart from the timings under Limits. The orders, SKUs and IDs are invented. - Related. If the JSON is on its way to a CSV file, PowerShell JSON to CSV Without Losing Columns covers the columns that
Export-Csvdrops.
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}"
},
...
- The values became text.
geoand bothtaxobjects are now strings in PowerShell's display format. A program that reads the file finds a string where it expects an object, and the numbers inside it cannot be read back reliably. - Nothing failed.
$?wasTruein both versions, so a script with$ErrorActionPreference = 'Stop'goes on, and a scheduled task ends with exit code 0. - Windows PowerShell 5.1 did not warn, although its page says it does. The 5.1 reference says that
ConvertTo-Json“emits a warning if the number of levels in an input object exceeds this number”. In our runs on 5.1.26100.9444,-WarningVariablecaptured 0 warnings and-WarningAction Stopdid not stop the command. - A hashtable is cut the same way. Four levels of hashtables gave
{"a":{"b":{"c":"System.Collections.Hashtable"}}}in both versions: the type name, not even the values.
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:
- An array with one item, sent through the pipeline, loses its brackets.
@($order) | ConvertTo-Json -Depth 10 -Compresswrote{"id":5001,…, an object, in both versions. The pipeline sends the items one by one, so the cmdlet never sees the array. An empty array through the pipeline wrote nothing at all (0 strings). Pass it with-InputObjectinstead:ConvertTo-Json -InputObject @($order)wrote[{"id":5001,…and-InputObject @()wrote[]. PowerShell 7 also has-AsArray, which “Outputs the object in array brackets, even if the input is a single object” (Microsoft Learn, ConvertTo-Json). - In Windows PowerShell 5.1, lines read with
Get-Contentbecome objects. Each line carries extra properties (PSPath,PSParentPath,PSChildName,PSDrive,PSProvider,ReadCount), and 5.1 writes them: two SKUs from a text file turned into an array of objects of about 1,900 characters, with our folder paths in it. With-Depth 100, the same conversion in 5.1 was still running after 60 seconds in each of three runs, and we stopped it. PowerShell 7 wrote["A-100","B-200"]; its reference says “As of PowerShell 7.2, Extended Type System properties of DateTime and String objects are no longer serialized” (Microsoft Learn, ConvertTo-Json). Cast the lines to plain strings first:[string[]] (Get-Content skus.txt)gave["A-100","B-200"]in both. - Enums become numbers, and 5.1 writes dates its own way.
[DayOfWeek]::Wednesdaywas written as3in both versions; whoever reads the file has to know the enum. A UTC date was"2026-10-07T08:30:00Z"in PowerShell 7 and"\/Date(1791361800000)\/"in 5.1, which is not ISO 8601: check that whatever reads the file understands it. A[guid]came out as its text in both.
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)"
}
- Reading back is the check. The comparison walks the original and the parsed JSON side by side and names every path where they differ, such as
$.lines[0].tax: expected object, read back '@{rate=0.2; amount=8}'. It does not trust the warning, which 5.1 does not write. - The first walk runs before
ConvertTo-Json, so a string with extra properties in 5.1 fails in a moment instead of converting the file system provider for minutes. - Top-level arrays. PowerShell 7's
ConvertFrom-Jsonsends the items of a top-level array one by one unless you add-NoEnumerate, which “causes arrays to be sent as a single object instead of sending every element separately” (Microsoft Learn, ConvertFrom-Json). Windows PowerShell 5.1 has no such parameter and, in our tests, returned the array as one object anyway. - Dates are compared as points in time. “Beginning in PowerShell 6,
ConvertTo-Jsonattempts to convert strings formatted as timestamps to DateTime values” (Microsoft Learn, ConvertFrom-Json; the sentence namesConvertTo-Json, but it is on theConvertFrom-Jsonpage and describes reading). So a string such as"2026-10-07T08:30:00Z"comes back as a date in PowerShell 7, and the function accepts that when both mean the same instant, to the millisecond. - The file is written last, to a temporary file that then replaces the real one, as UTF-8 without a byte order mark in both versions. A run that fails writes nothing.
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
- One machine. Windows 11 Pro, PowerShell 7.6.6 and Windows PowerShell 5.1.26100.9444. We did not test PowerShell 7.0 to 7.5, Linux or macOS.
- It is slow on big data. The comparison is plain PowerShell. 5,000 copies of the order above took about 7 seconds in PowerShell 7 and about 25 seconds in 5.1 (three runs each), against 0.1 to 0.2 seconds for
ConvertTo-Jsonalone. For large exports, check a sample, or check the structure once and trust it while the source does not change. - Plain values only. Strings, numbers, true/false, null, dates, hashtables, ordered dictionaries,
[pscustomobject]and lists. Anything else (enums, Guids,FileInfo, objects from other cmdlets) is refused until you convert it. That is on purpose, but it means you write the mapping. - Dates are checked as instants, not as text. A date written by 5.1 as
\/Date(…)\/passes, because it reads back as the same instant; whether the program that reads the file understands that form is a separate question. In PowerShell 7.5 and later,ConvertFrom-Json -DateKind Stringkeeps date strings as text when you read your input; we did not use it here. - Extra properties are not reported. The comparison looks for every value of the original in the JSON. It does not report values in the JSON that were not in the original.
- Not tested: numbers that do not fit a JSON number exactly (
NaN, infinity, very large decimals), hashtables with keys that differ only in case, and strings with extra properties in PowerShell 7.0 and 7.1. - It checks the conversion, not the data. If the API sent the wrong order, the file has the wrong order, written faithfully. Checking that the records are the ones you expect is a separate step.
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.