ConvertTo-Json -Depth: the valid JSON that lost half your config


Quick answer

ConvertTo-Json turns a PowerShell object into JSON text and ConvertFrom-Json turns JSON text back into an object. Both work in one line.

# object to text
[pscustomobject]@{ Server = 'sql01'; Port = 1433 } | ConvertTo-Json

# text to object
Get-Content -Raw ./appsettings.json | ConvertFrom-Json

The part that costs people data is not the syntax. ConvertTo-Json stops after two levels of nesting by default, replaces everything deeper with a string, raises a warning on a stream that scheduled scripts routinely silence, and produces a file that is still perfectly valid JSON. Nothing downstream complains. The section that shows it is example 4.

Common mistake: Reading a configuration file, changing one value and writing it back with $cfg | ConvertTo-Json is the single most common thing anyone does with these two cmdlets, and at the default depth it destroys any section nested more than three levels deep. Pass a -Depth you measured. Example 4 measures it, and the last section of this page is a script that works out the number for you.

What you are about to build, and why

This page is a small laboratory you build in one folder on your own machine. A single script writes seven short JSON files into it: a configuration file shaped like the ones you actually meet, a log file with one JSON document per line, and five tiny files that each hold one awkward thing the JSON standard allows and PowerShell has an opinion about. Then eighteen further scripts read those files and print what the two cmdlets did with them.

Nothing is installed. Nothing is written to the registry, no module is downloaded, and no network call is made. Everything lives in one folder and the last section deletes it in four lines.

By the end you will be able to answer the questions that cost real time: what -Depth to pass and how to measure it instead of guessing; why a file your own script wrote is valid JSON and wrong; which of your keys, numbers, dates and arrays survive a round trip unchanged and which do not; and which switches exist on the PowerShell in front of you rather than in the newest documentation.

Note: This is about the two conversion cmdlets and the objects they produce. If your JSON is being rejected as invalid, the error messages and what each reader accepts are covered on the JSON errors reference. Calling an API is a different subject and is not on this page.

Before you start: four steps

Four things, and the first one decides whether half of this page works at all.

1. Check which PowerShell you are in. Everything here needs PowerShell 7. Windows ships two shells and they are separate programs: Windows PowerShell 5.1 is powershell.exe and PowerShell 7 is pwsh.exe. They install side by side, so having 7 does not remove 5.1. Open a PowerShell 7 window and paste this:

'Version: ' + $PSVersionTable.PSVersion.ToString()
'Edition: ' + $PSVersionTable.PSEdition
'Running: ' + (Get-Process -Id $PID).ProcessName

Three lines come back. The first must start with a 7:

Version: 7.4.6
Edition: Core
Running: pwsh
If the first line starts with 5.1: you are in Windows PowerShell and seven of the switches used on this page do not exist there. Close the window and start pwsh.exe instead. The section near the end of this page measures exactly which parameters each shell has, and the PowerShell 7 upgrade walk-through covers the install.

2. Make the working folder. Every script on this page uses relative paths, so they all work from one folder and nothing escapes it. Create it and change into it. Neither line prints anything:

New-Item -ItemType Directory -Path C:\json-lab -Force
Set-Location C:\json-lab

3. Let the scripts run in this window only. A freshly downloaded or freshly typed .ps1 may refuse to run. Set the policy for the Process scope: it applies to this window alone, needs no elevation, and is gone when you close it. It prints nothing:

Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned

4. Note how the paths are written. The scripts use forward slashes, as in ./data/appsettings.json. PowerShell accepts a backslash or a forward slash as the path separator, so .\data\appsettings.json is the same file. Forward slashes are used here because they survive being pasted into anything.

Now build the lab. This is the first script you save. It writes the seven data files and prints how many it created.

Save this as Build-JsonLab.ps1 in C:\json-lab and run it with .\Build-JsonLab.ps1.

# Creates every file the rest of this page reads. Run it once, from the lab
# folder. Nothing is installed and nothing is written outside this folder.
New-Item -ItemType Directory -Path ./data -Force | Out-Null

# A configuration file shaped like the ones you actually meet: four levels of
# nesting under Features, three under Logging. The depth difference is the point.
$appsettings = @'
{
  "ConnectionStrings": {
    "Default": "Server=sql01;Database=app;Trusted_Connection=True"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "Features": {
    "Retry": {
      "Enabled": true,
      "Backoff": {
        "Seconds": 5,
        "Jitter": true
      }
    }
  }
}
'@
Set-Content -Path ./data/appsettings.json -Value $appsettings

# Three complete JSON documents, one per line. This is JSON Lines, and it is
# what most log shippers and many APIs emit.
Set-Content -Path ./data/events.jsonl -Value '{"id":1,"level":"Information"}',
                                            '{"id":2,"level":"Warning"}',
                                            '{"id":3,"level":"Error"}'

# Five timestamps written five different ways, plus two strings that look like
# dates to a human and not to the parser.
Set-Content -Path ./data/dates.json -Value '{"naive":"2026-01-15T08:30:00","zulu":"2026-01-15T08:30:00Z","offset":"2026-01-15T08:30:00+02:00","dateonly":"2026-01-15","british":"15/01/2026"}'

# Six numbers that a sysadmin would call ordinary.
Set-Content -Path ./data/numbers.json -Value '{"small":42,"big":12345678901234567890,"dec":1.5,"lead":0.30,"exp":1e3,"money":10.00}'

# Two keys that differ only in case. Legal JSON, and PowerShell has an opinion.
Set-Content -Path ./data/casekeys.json -Value '{"key":"value1","Key":"value2"}'

# The same key twice, identical casing.
Set-Content -Path ./data/dupekeys.json -Value '{"port":1433,"port":1521}'

# A key that is an empty string. project.lock.json files really do this.
Set-Content -Path ./data/emptykey.json -Value '{"":"nothing","ok":1}'

'Created ' + (Get-ChildItem ./data -File).Count + ' files in .\data'

You should see one line:

Created 7 files in .\data

Check what it made. This is the second script you save, and it is worth keeping, because every later example says which of these files it needs.

Save this as Show-LabFiles.ps1 in C:\json-lab and run it with .\Show-LabFiles.ps1.

# Prints what Build-JsonLab.ps1 created, names only, so the listing is the same
# on every machine. File sizes are deliberately left out: Set-Content writes
# CRLF on Windows and LF elsewhere, so the byte counts would not match.
'data:'
Get-ChildItem ./data -File | Sort-Object Name | ForEach-Object { '  ' + $_.Name }

Seven files, in a data subfolder:

data:
  appsettings.json
  casekeys.json
  dates.json
  dupekeys.json
  emptykey.json
  events.jsonl
  numbers.json
Note: File sizes are deliberately left out of that listing. Set-Content writes Windows line endings on Windows and Unix line endings elsewhere, so the byte counts would not match from one machine to the next. The file names will.

The two cmdlets, and the object in between

Three things are worth holding in mind before the examples, because almost every surprise on this page comes from one of them.

PieceWhat it isThe part that catches people
ConvertTo-JsonObject to text. Implemented with Newtonsoft Json.NET in PowerShell 7.-Depth defaults to 2 and truncates past it.
ConvertFrom-JsonText to object. Also Newtonsoft in 7, but the JavaScriptSerializer class in 5.1.-Depth defaults to 1024 and rejects past it.
The object in the middleA PSCustomObject by default, or an OrderedHashtable with -AsHashtable.Property names are fixed, and property lookup is case insensitive.

The two cmdlets are not inverses of each other. Four of the examples below show a value that goes out and comes back as something else: a one element array, a twenty digit integer, a string that looks like a date, and a key that differs from another only in case.

Note: Both cmdlets have existed since PowerShell 3.0, so they are present in Windows PowerShell 5.1. What is not present there is almost every parameter that makes them predictable. That section is near the end and it is measured, not assumed.

Example 1: write an object and read it back

The baseline, so that the failures later have something to fail against. Build a three property object, convert it to JSON, convert it back, and check that a number is still a number.

Save this as Convert-ObjectToJson.ps1 in C:\json-lab and run it with .\Convert-ObjectToJson.ps1.

# The baseline both directions. Build an object, write it as JSON, read it back
# and prove the value survived.
$server = [pscustomobject]@{
    Server = 'sql01'
    Port   = 1433
    Role   = 'primary'
}

$text = $server | ConvertTo-Json
'--- what ConvertTo-Json produced ---'
$text

'--- and back again ---'
$back = $text | ConvertFrom-Json
'type:        ' + $back.GetType().Name
'Server:      ' + $back.Server
'Port + 1:    ' + ($back.Port + 1)
'Port is a:   ' + $back.Port.GetType().Name

You should see the JSON text, then four facts about what came back:

--- what ConvertTo-Json produced ---
{
  "Server": "sql01",
  "Port": 1433,
  "Role": "primary"
}
--- and back again ---
type:        PSCustomObject
Server:      sql01
Port + 1:    1434
Port is a:   Int64

Two things in that output are worth naming now. What comes back is a PSCustomObject, not a hashtable, which matters in example 7 and again in the hidden gems. And Port came back as an Int64, so arithmetic works without a cast: JSON has one number type and PowerShell picks a .NET one for you. Example 10 is about the cases where the one it picks is not the one you wanted.

Note: Without -Compress the output is indented over several lines, which is what you want in a file a human will read. -Compress is used in most of the blocks below purely so the results fit on one line and can be compared at a glance.

Example 2: how many levels the default depth keeps

The documentation for -Depth on ConvertTo-Json says the value can be any number from 0 to 100 and that the default is 2. It does not say what two levels buys you, and the answer is not what most people assume. This script builds an object one level deep, then two, then three, up to five, converts each at the default depth and counts the warnings.

Save this as Show-DepthDefault.ps1 in C:\json-lab and run it with .\Show-DepthDefault.ps1.

# How many levels of nesting survive the default -Depth of 2. The loop builds an
# object n levels deep, converts it, and counts the warnings the conversion
# raised. WarningAction keeps the table readable; the warning text is in the
# next example.
'levels  warnings  result'
foreach ($n in 1..5) {
    $o = 1
    for ($i = 0; $i -lt $n; $i++) { $o = @{ level = $o } }
    $w = $null
    $s = $o | ConvertTo-Json -Compress -WarningVariable w -WarningAction SilentlyContinue
    '{0,-7} {1,-9} {2}' -f $n, $w.Count, $s
}

Five rows. Read the warnings column first, then look at where the text changes:

levels  warnings  result
1       0         {"level":1}
2       0         {"level":{"level":1}}
3       0         {"level":{"level":{"level":1}}}
4       1         {"level":{"level":{"level":"System.Collections.Hashtable"}}}
5       1         {"level":{"level":{"level":"System.Collections.Hashtable"}}}

Three nested objects convert cleanly. The fourth is the one that breaks, and it does not break by failing: the innermost object is replaced by the string System.Collections.Hashtable, which is the .NET type name of the thing that used to be there. The five level object produces exactly the same text as the four level one, so the output cannot tell you how much was lost.

Note: A default of 2 rendering three levels of braces is consistent with the wording, which counts contained objects rather than braces, but it is the kind of off by one that is far better measured than reasoned about. That is what the last section of this page automates.

Example 3: the warning, and the two ways it disappears

PowerShell does tell you. Since PowerShell 7.1 ConvertTo-Json emits a warning when the input is deeper than the depth you allowed. The problem is where it sends it. This script converts the same object three ways: normally, with warnings turned off the way a scheduled script turns them off, and then checks whether the damaged text is still valid JSON.

Save this as Show-DepthWarning.ps1 in C:\json-lab and run it with .\Show-DepthWarning.ps1.

# The warning itself, then the two ways it disappears. Both of these are things
# a scheduled script does as a matter of course.
$deep = @{ a = @{ b = @{ c = @{ d = 1 } } } }

'--- with warnings on, which is the default ---'
$visible = $deep | ConvertTo-Json -Compress

'--- the string it produced ---'
$visible

'--- the same conversion inside a script that silences warnings ---'
$WarningPreference = 'SilentlyContinue'
$silent = $deep | ConvertTo-Json -Compress
$WarningPreference = 'Continue'
'identical output: ' + ($silent -eq $visible)

'--- and the truncated string is valid JSON ---'
'Test-Json:                    ' + (Test-Json -Json $visible)
'ConvertFrom-Json accepts it:  ' + (($visible | ConvertFrom-Json) -ne $null)

The warning first, then the string, then two booleans that are the whole problem:

--- with warnings on, which is the default ---
WARNING: Resulting JSON is truncated as serialization has exceeded the set depth of 2.
--- the string it produced ---
{"a":{"b":{"c":"System.Collections.Hashtable"}}}
--- the same conversion inside a script that silences warnings ---
identical output: True
--- and the truncated string is valid JSON ---
Test-Json:                    True
ConvertFrom-Json accepts it:  True

The warning goes to the warning stream. Setting $WarningPreference to SilentlyContinue at the top of a script, or passing -WarningAction SilentlyContinue, or redirecting only standard output to a log file, all remove it while changing nothing about the result. The output is byte for byte identical either way, which the identical output: True line proves.

And the truncated text passes every validity check there is. Test-Json returns True. ConvertFrom-Json reads it without complaint. A schema that only describes the top two levels passes it. The file is not corrupt; it is a different, smaller, completely well formed document.

Common mistake: Treating "the JSON is valid" as "the JSON is right". Validity is a property of the text, not of the data. Every tool on the JSON formatter page will accept the truncated file, because there is nothing wrong with it.

Example 4: the config edit that deletes a section, and the fix

This is the example to remember. Read a configuration file, change the connection string, write it back. Three lines of obvious PowerShell, and it is the most common thing anyone does with these cmdlets. This one needs .\data\appsettings.json, which Build-JsonLab.ps1 created.

Save this as Edit-AppSettings.ps1 in C:\json-lab and run it with .\Edit-AppSettings.ps1.

# Read a configuration file, change one value, write it back. This is the most
# common thing anyone does with these two cmdlets, and at the default depth it
# destroys part of the file. Needs .\data\appsettings.json from Build-JsonLab.ps1.
$cfg = Get-Content -Raw ./data/appsettings.json | ConvertFrom-Json
$cfg.ConnectionStrings.Default = 'Server=sql02;Database=app;Trusted_Connection=True'

# The obvious write, with no -Depth at all.
$cfg | ConvertTo-Json | Set-Content -Path ./appsettings.out.json

'--- the file it wrote ---'
Get-Content -Raw ./appsettings.out.json

'--- is the file still valid JSON? ---'
'Test-Json: ' + (Test-Json -Json (Get-Content -Raw ./appsettings.out.json))

'--- what an application reading it would find ---'
$back = Get-Content -Raw ./appsettings.out.json | ConvertFrom-Json
'Logging.LogLevel.Default:        ' + $back.Logging.LogLevel.Default
'Features.Retry.Backoff.Seconds:  ' + $back.Features.Retry.Backoff.Seconds
'Features.Retry.Backoff is a:     ' + $back.Features.Retry.Backoff.GetType().Name

The warning, then the file it wrote, then what an application reading that file would find. Look at the Backoff line in the middle:

WARNING: Resulting JSON is truncated as serialization has exceeded the set depth of 2.
--- the file it wrote ---
{
  "ConnectionStrings": {
    "Default": "Server=sql02;Database=app;Trusted_Connection=True"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "Features": {
    "Retry": {
      "Enabled": true,
      "Backoff": "@{Seconds=5; Jitter=True}"
    }
  }
}

--- is the file still valid JSON? ---
Test-Json: True
--- what an application reading it would find ---
Logging.LogLevel.Default:        Information
Features.Retry.Backoff.Seconds:  
Features.Retry.Backoff is a:     String

The connection string was updated correctly. Logging.LogLevel survived intact, because it sits three levels down. Features.Retry.Backoff sits four levels down, and it is gone: in its place is the string @{Seconds=5; Jitter=True}, which is how PowerShell displays a hashtable on a console, now stored as a JSON string value.

Note what the last two lines of output show. Asking for Backoff.Seconds returns nothing at all. No error, no null reference, just an empty result, because Backoff is a String now and strings do not have a Seconds property. A script that reads this file to decide a retry interval will quietly use whatever its own default is.

Common mistake: Assuming the damage is visible. The file still looks like the original, Test-Json still returns True, the keys are all still there and the indentation is unchanged. The only visible difference is that one object became one quoted string, four levels down, in a file nobody reads by eye.

The fix is one parameter. Here is the same edit with a depth that covers the file.

Save this as Repair-AppSettings.ps1 in C:\json-lab and run it with .\Repair-AppSettings.ps1.

# The same edit, written with a depth that covers the file. Needs
# .\data\appsettings.json from Build-JsonLab.ps1.
$cfg = Get-Content -Raw ./data/appsettings.json | ConvertFrom-Json
$cfg.ConnectionStrings.Default = 'Server=sql02;Database=app;Trusted_Connection=True'
$cfg | ConvertTo-Json -Depth 10 | Set-Content -Path ./appsettings.fixed.json

$back = Get-Content -Raw ./appsettings.fixed.json | ConvertFrom-Json
'Logging.LogLevel.Default:        ' + $back.Logging.LogLevel.Default
'Features.Retry.Backoff.Seconds:  ' + $back.Features.Retry.Backoff.Seconds
'Features.Retry.Backoff.Jitter:   ' + $back.Features.Retry.Backoff.Jitter
'ConnectionStrings.Default:       ' + $back.ConnectionStrings.Default

Four values, all of them intact, and no warning:

Logging.LogLevel.Default:        Information
Features.Retry.Backoff.Seconds:  5
Features.Retry.Backoff.Jitter:   True
ConnectionStrings.Default:       Server=sql02;Database=app;Trusted_Connection=True
Rule of thumb: Any time an object came out of ConvertFrom-Json and is going back in to ConvertTo-Json, pass -Depth. Guessing a large number works, because the cost of a depth larger than your data is nothing at all. Measuring it is better, and the last section of this page does that.

Example 5: -Depth is two parameters sharing one name

Both cmdlets take -Depth. They are not the same parameter. On ConvertTo-Json it is a budget that silently truncates the output; on ConvertFrom-Json it is a limit that rejects the input with an error. The defaults are 2 and 1024 respectively, which is a factor of five hundred.

Save this as Show-DepthBothCmdlets.ps1 in C:\json-lab and run it with .\Show-DepthBothCmdlets.ps1.

# -Depth is one parameter name on two cmdlets with two different jobs, two
# different defaults and two different failure modes. This measures all of it.
# The error messages are trimmed at ' Path ' because the reader's own path can be
# a thousand index brackets long.
function Short($message) { ($message -split ' Path ')[0] }

$fiveDeep = @{ a = @{ b = @{ c = @{ d = @{ e = 'bottom' } } } } }
$text = $fiveDeep | ConvertTo-Json -Depth 10 -Compress

'--- ConvertTo-Json: the limit truncates, and warns ---'
'default:  ' + ($fiveDeep | ConvertTo-Json -Compress -WarningAction SilentlyContinue)
'depth 10: ' + $text

'--- ConvertFrom-Json: the limit rejects the input ---'
try { $null = $text | ConvertFrom-Json -Depth 2; 'depth 2:  parsed' } catch { 'depth 2:  ' + (Short $_.Exception.Message) }
'depth 10: ' + (($text | ConvertFrom-Json -Depth 10).a.b.c.d.e)

'--- the documented ConvertFrom-Json default of 1024, tested at the boundary ---'
foreach ($n in 1024, 1025) {
    $brackets = ('[' * $n) + '1' + (']' * $n)
    try { $null = $brackets | ConvertFrom-Json; '{0,-6} parsed' -f $n } catch { '{0,-6} {1}' -f $n, (Short $_.Exception.Message) }
}

'--- the documented bounds on ConvertTo-Json ---'
try { $null = @{ a = 1 } | ConvertTo-Json -Depth 101 } catch { 'depth 101: ' + $_.Exception.Message }
'depth 0:   ' + (@{ a = @{ b = 1 } } | ConvertTo-Json -Depth 0 -Compress -WarningAction SilentlyContinue)

Six results. The first pair truncates, the second pair errors, and the last pair is the documented boundary:

--- ConvertTo-Json: the limit truncates, and warns ---
default:  {"a":{"b":{"c":"System.Collections.Hashtable"}}}
depth 10: {"a":{"b":{"c":{"d":{"e":"bottom"}}}}}
--- ConvertFrom-Json: the limit rejects the input ---
depth 2:  Conversion from JSON failed with error: The reader's MaxDepth of 2 has been exceeded.
depth 10: bottom
--- the documented ConvertFrom-Json default of 1024, tested at the boundary ---
1024   parsed
1025   Conversion from JSON failed with error: The reader's MaxDepth of 1024 has been exceeded.
--- the documented bounds on ConvertTo-Json ---
depth 101: Cannot validate argument on parameter 'Depth'. The 101 argument is greater than the maximum allowed range of 100. Supply an argument that is less than or equal to 100 and then try the command again.
depth 0:   {"a":"System.Collections.Hashtable"}
ConvertTo-JsonConvertFrom-Json
What -Depth limitsHow deep it will serializeHow deep the input may be
Documented default21024
Documented range0 to 100not stated
Past the limittruncates and warnsthrows a terminating error
Measured at the boundary4 nested objects warn at the default1024 brackets parse, 1025 do not
Note: The documentation disagrees with itself about the lower bound, across versions. The Windows PowerShell 5.1 page says the value "can be any number from 1 to 100"; the PowerShell 7.4 page says "from 0 to 100". On 7.4.6, -Depth 0 is accepted and flattens the very first level, which is the last line of the output above. The ConvertFrom-Json page states the 1024 default in its prose and leaves the default blank in its parameter table.
Common mistake: Copying a -Depth value from one cmdlet to the other. -Depth 2 on ConvertFrom-Json does not make it match ConvertTo-Json's default behaviour; it makes it refuse almost every real document, with the message in the output above.

Example 6: a one element array does not survive the round trip

A JSON array with one element in it comes back from ConvertFrom-Json as the element, not as an array of one. Convert that back to JSON and the brackets are gone. The documentation names this as the reason -NoEnumerate exists, and it is the most common cause of an API rejecting a request body that worked yesterday when the list happened to be longer.

Save this as Test-RoundTrip.ps1 in C:\json-lab and run it with .\Test-RoundTrip.ps1.

# A JSON array with one element does not survive a round trip. This is the
# documented reason -NoEnumerate exists, and the reason an API that expects a
# list sometimes gets a bare number.
'--- one element in, one element out? ---'
'plain:          ' + ('[1]' | ConvertFrom-Json | ConvertTo-Json -Compress)
'-NoEnumerate:   ' + ('[1]' | ConvertFrom-Json -NoEnumerate | ConvertTo-Json -Compress)
'two elements:   ' + ('[1,2]' | ConvertFrom-Json | ConvertTo-Json -Compress)

'--- what came out of ConvertFrom-Json in each case ---'
'plain is a:        ' + ('[1]' | ConvertFrom-Json).GetType().FullName
'-NoEnumerate is a: ' + ('[1]' | ConvertFrom-Json -NoEnumerate).GetType().FullName

'--- the other end of the same problem ---'
'one object:            ' + ([pscustomobject]@{ id = 1 } | ConvertTo-Json -Compress)
'one object -AsArray:   ' + ([pscustomobject]@{ id = 1 } | ConvertTo-Json -Compress -AsArray)

Three round trips, then the types, then the same problem seen from the writing end:

--- one element in, one element out? ---
plain:          1
-NoEnumerate:   [1]
two elements:   [1,2]
--- what came out of ConvertFrom-Json in each case ---
plain is a:        System.Int64
-NoEnumerate is a: System.Object[]
--- the other end of the same problem ---
one object:            {"id":1}
one object -AsArray:   [{"id":1}]

The first line is the whole issue: [1] went in and 1 came out. The type line explains why: without -NoEnumerate what you are holding is a System.Int64, because the pipeline unrolled the one element array into its single element. Two elements survive, so this only shows up when a collection happens to have one member.

The last two lines are the mirror image. -AsArray on ConvertTo-Json forces array brackets around a single object, which is what an endpoint that always expects a list wants to receive.

Rule of thumb: When the JSON is a list and the count is not fixed, read it with -NoEnumerate and write it with -AsArray. Both switches cost nothing when the collection is larger, and both are absent from Windows PowerShell 5.1.

Example 7: duplicate keys, and a page that contradicts itself

The JSON standard allows the same key twice. A PSCustomObject cannot hold two properties with one name, so something has to give. What gives depends on the casing, the two cases behave nothing alike, and the documentation describes one of them in two incompatible ways. This one needs .\data\dupekeys.json and .\data\casekeys.json.

Save this as Test-DuplicateKeys.ps1 in C:\json-lab and run it with .\Test-DuplicateKeys.ps1.

# Duplicate keys are legal JSON and illegal in a PSObject, so something has to
# give. What gives depends on the casing, and the two cases behave nothing alike.
# Needs .\data\dupekeys.json and .\data\casekeys.json from Build-JsonLab.ps1.
'--- the same key twice, identical casing ---'
'file:  ' + (Get-Content -Raw ./data/dupekeys.json).Trim()
'port:  ' + ((Get-Content -Raw ./data/dupekeys.json | ConvertFrom-Json).port)

'--- two keys differing only in case ---'
'file:  ' + (Get-Content -Raw ./data/casekeys.json).Trim()
try {
    $o = Get-Content -Raw ./data/casekeys.json | ConvertFrom-Json
    'parsed; properties: ' + (($o.psobject.Properties.Name) -join ',')
} catch {
    'error: ' + $_.Exception.Message
}

'--- the same file with -AsHashtable ---'
$h = Get-Content -Raw ./data/casekeys.json | ConvertFrom-Json -AsHashtable
'type:   ' + $h.GetType().Name
'count:  ' + $h.Count
'keys:   ' + (($h.Keys) -join ',')
'key:    ' + $h['key']
'Key:    ' + $h['Key']

The identical case pair first, then the pair that differs only in case, then the same file read as a hashtable:

--- the same key twice, identical casing ---
file:  {"port":1433,"port":1521}
port:  1521
--- two keys differing only in case ---
file:  {"key":"value1","Key":"value2"}
error: Cannot convert the JSON string because it contains keys with different casing. Please use the -AsHashTable switch instead. The key that was attempted to be added to the existing key 'key' was 'Key'.
--- the same file with -AsHashtable ---
type:   OrderedHashtable
count:  2
keys:   key,Key
key:    value1
Key:    value2

Two keys spelled exactly the same: the last one wins silently, so port is 1521 and the 1433 is gone with no warning. Two keys differing only in case: a terminating error, and the message names the switch that fixes it. With -AsHashtable both keys survive, and the result is an OrderedHashtable, which since PowerShell 7.3 preserves the order the keys appeared in the file.

Now compare that measurement against the cmdlet reference, which states the case insensitive behaviour twice:

Where on the pageWhat it saysWhat 7.4.6 does
Description"if the JSON string contains duplicate keys, only the last key is used by this cmdlet"Correct for keys spelled identically
-AsHashtable notes"only the last of those case-insensitively identical keys is included in the converted object"Wrong. It throws
Example 4"Without the switch, the command would have thrown an error"Correct
Common mistake: Writing a parser around the -AsHashtable note and getting a terminating error in production. The two sentences cannot both be true, the example is the one that matches the runtime, and the behaviour is worth a line in your own notes because it changes an error path into a data loss path depending on one letter of casing.
Note: Property lookup on the resulting object is case insensitive either way, which is PowerShell behaviour rather than JSON behaviour. If a file legitimately uses id and ID as different fields, -AsHashtable and index syntax are the only way to reach both.

Example 8: a key that is an empty string

An empty string is a legal JSON key and an illegal PowerShell property name. This one is an error rather than a silent loss, which makes it the friendliest trap on the page. The documentation points at project.lock.json files as a real world source. This one needs .\data\emptykey.json.

Save this as Test-EmptyKey.ps1 in C:\json-lab and run it with .\Test-EmptyKey.ps1.

# A key that is an empty string. Legal JSON, and a PSCustomObject cannot hold it.
# Needs .\data\emptykey.json from Build-JsonLab.ps1.
'file: ' + (Get-Content -Raw ./data/emptykey.json).Trim()

'--- the default conversion ---'
try {
    $o = Get-Content -Raw ./data/emptykey.json | ConvertFrom-Json
    'parsed; properties: ' + (($o.psobject.Properties.Name) -join '|')
} catch {
    'error: ' + $_.Exception.Message
}

'--- with -AsHashtable ---'
$h = Get-Content -Raw ./data/emptykey.json | ConvertFrom-Json -AsHashtable
'count:        ' + $h.Count
'empty key is: ' + $h['']
'ok is:        ' + $h['ok']

The file, the error, and then the same file read as a hashtable:

file: {"":"nothing","ok":1}
--- the default conversion ---
error: The provided JSON includes a property whose name is an empty string, this is only supported using the -AsHashTable switch.
--- with -AsHashtable ---
count:        2
empty key is: nothing
ok is:        1

The error message is specific and it names the fix, which is unusual and welcome. With -AsHashtable both entries are reachable, and the empty key is addressed as $h['']. There is no way to reach it on a PSCustomObject, which is why the cmdlet refuses rather than dropping it.

Rule of thumb: If you are reading JSON that someone else generates, reach for -AsHashtable first. It removes two whole classes of failure, the empty key and the case collision, and costs you only the dot notation.

Example 9: timestamps become DateTime whether you asked or not

Since PowerShell 6, a JSON string that looks like a timestamp does not come back as a string. It comes back as a [datetime], and which Kind it carries depends on how the text was written. This one needs .\data\dates.json, which holds the same instant written three ways plus two strings that look like dates to a human.

Save this as Show-DateConversion.ps1 in C:\json-lab and run it with .\Show-DateConversion.ps1.

# ConvertFrom-Json turns strings that look like timestamps into DateTime objects
# whether or not you asked for it, and which Kind you get depends on the text.
# The last column is printed with an explicit format so it reads the same in
# every locale, and as a UTC instant where the value has one.
# Needs .\data\dates.json from Build-JsonLab.ps1.
$o = Get-Content -Raw ./data/dates.json | ConvertFrom-Json

'key        type      kind         what you can rely on'
foreach ($p in $o.psobject.Properties) {
    if ($p.Value -is [datetime]) {
        $kind = $p.Value.Kind
        $shown = if ($kind -eq 'Unspecified') {
            'wall clock ' + $p.Value.ToString('yyyy-MM-dd HH:mm:ss') + ', no zone'
        } else {
            'instant ' + $p.Value.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
        }
    } else {
        $kind = '-'
        $shown = 'text, unchanged: ' + $p.Value
    }
    '{0,-10} {1,-9} {2,-12} {3}' -f $p.Name, $p.Value.GetType().Name, $kind, $shown
}

'--- which of the two cmdlets actually does this ---'
$o2 = [pscustomobject]@{ when = '2026-01-15T08:30:00Z' }
'property going in is a:        ' + $o2.when.GetType().Name
'what ConvertTo-Json wrote:     ' + ($o2 | ConvertTo-Json -Compress)
'what came back out as:         ' + (($o2 | ConvertTo-Json -Compress | ConvertFrom-Json).when.GetType().Name)

'--- the switch that would turn the guessing off ---'
'DateKind parameter exists:     ' + ((Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind'))

Five rows, then which cmdlet is really responsible, then one boolean:

key        type      kind         what you can rely on
naive      DateTime  Unspecified  wall clock 2026-01-15 08:30:00, no zone
zulu       DateTime  Utc          instant 2026-01-15T08:30:00Z
offset     DateTime  Local        instant 2026-01-15T06:30:00Z
dateonly   String    -            text, unchanged: 2026-01-15
british    String    -            text, unchanged: 15/01/2026
--- which of the two cmdlets actually does this ---
property going in is a:        String
what ConvertTo-Json wrote:     {"when":"2026-01-15T08:30:00Z"}
what came back out as:         DateTime
--- the switch that would turn the guessing off ---
DateKind parameter exists:     False

The three Kind values match the documentation exactly: no zone information gives Unspecified, a trailing Z gives Utc, and a trailing offset gives Local with the offset already applied to your machine time zone. Only the last two carry a real instant. The Unspecified one is a wall clock reading with no zone attached, and subtracting it from a Utc value gives an answer wrong by your own offset.

A date written without a time, and a date written in day first order, both stay strings. So whether a field arrives as text or as an object depends on the text in the field, which means it can change between two records in the same file.

Note: The notes on the ConvertFrom-Json reference page attribute this behaviour to ConvertTo-Json: "Beginning in PowerShell 6, ConvertTo-Json attempts to convert strings formatted as timestamps to DateTime values." The middle three lines of the output measure it the other way: ConvertTo-Json wrote the string out unchanged, and ConvertFrom-Json is what turned it into a DateTime. The page names the wrong cmdlet.
If you need the raw text: PowerShell 7.5 added -DateKind to ConvertFrom-Json, and -DateKind String keeps timestamps as strings. The last line of the output above is how to find out whether the shell in front of you has it: on 7.4.6 it does not, and the only workaround is to read the file with -AsHashtable and re-read the field you care about from the raw text yourself.

Example 10: what happens to six ordinary numbers

JSON has one number type. PowerShell has many, so it has to choose, and the choice is visible the moment you write the object back out. This one needs .\data\numbers.json, which holds six numbers any administrator would call ordinary.

Save this as Show-NumberConversion.ps1 in C:\json-lab and run it with .\Show-NumberConversion.ps1.

# What types six ordinary-looking numbers come back as, and what happens when you
# write them straight back out. Needs .\data\numbers.json from Build-JsonLab.ps1.
$o = Get-Content -Raw ./data/numbers.json | ConvertFrom-Json

'key      type        value'
foreach ($p in $o.psobject.Properties) {
    '{0,-8} {1,-11} {2}' -f $p.Name, $p.Value.GetType().Name, $p.Value
}

'--- the same object converted straight back to JSON ---'
$o | ConvertTo-Json -Compress -WarningAction SilentlyContinue

'--- the file it came from, for comparison ---'
(Get-Content -Raw ./data/numbers.json).Trim()

Six types, then the same object written straight back to JSON, then the original file for comparison. The second and last lines are the ones to read together:

key      type        value
small    Int64       42
big      BigInteger  12345678901234567890
dec      Double      1.5
lead     Double      0.3
exp      Double      1000
money    Double      10
--- the same object converted straight back to JSON ---
{"small":42,"big":{"IsPowerOfTwo":false,"IsZero":false,"IsOne":false,"IsEven":true,"Sign":1},"dec":1.5,"lead":0.3,"exp":1000.0,"money":10.0}
--- the file it came from, for comparison ---
{"small":42,"big":12345678901234567890,"dec":1.5,"lead":0.30,"exp":1e3,"money":10.00}

A twenty digit integer arrives as a BigInteger, which is correct and lossless. Writing it back out on 7.4.6 produces "big":{"IsPowerOfTwo":false,"IsZero":false,"IsOne":false,"IsEven":true,"Sign":1}, because ConvertTo-Json serialized the object properties instead of the number. The value is not truncated or rounded; it is replaced by five booleans and a sign. The release notes for PowerShell 7.5 say that version serializes BigInteger as a raw JSON number, so this is a version specific result and the first line of Show-PSVersion.ps1 tells you which side of it you are on.

The rest are quieter. 0.30 comes back as 0.3, 1e3 as 1000.0, and 10.00 as 10.0. The numeric values are identical and the text is not, so a round trip through PowerShell is not a safe way to leave a file byte unchanged.

Common mistake: Round tripping a file that contains identifiers stored as large numbers, such as a Windows SID component, a Twitter style snowflake id or a 64 bit serial. On 7.4.6 the ones that exceed Int64 come back out as an object. Read them with -AsHashtable and keep them as text, or compare the two files before you overwrite anything.

Example 11: a hashtable does not keep your key order

Three ways to build the same five key object, and only two of them produce JSON in the order you typed. This is documented, on the Windows PowerShell 5.1 page rather than the 7 one, and it is the reason a generated configuration file looks different every time someone regenerates it.

Save this as Show-KeyOrder.ps1 in C:\json-lab and run it with .\Show-KeyOrder.ps1.

# Three ways to build the same five-key object. Only two of them keep the order
# you typed. A hashtable is unordered by definition, so the order it emits is
# arbitrary: it is not printed here because it changes between runs.
$typed = 'name', 'port', 'role', 'site', 'owner'

$asHashtable = @{ name = 'sql01'; port = 1433; role = 'primary'; site = 'dc1'; owner = 'dba' }
$asOrdered   = [ordered]@{ name = 'sql01'; port = 1433; role = 'primary'; site = 'dc1'; owner = 'dba' }
$asObject    = [pscustomobject]@{ name = 'sql01'; port = 1433; role = 'primary'; site = 'dc1'; owner = 'dba' }

function Get-EmittedKeys($json) {
    ($json | ConvertFrom-Json).psobject.Properties.Name
}

foreach ($case in @(
    @{ Label = '@{}'; Json = ($asHashtable | ConvertTo-Json -Compress) }
    @{ Label = '[ordered]@{}'; Json = ($asOrdered | ConvertTo-Json -Compress) }
    @{ Label = '[pscustomobject]@{}'; Json = ($asObject | ConvertTo-Json -Compress) }
)) {
    $keys = Get-EmittedKeys $case.Json
    '{0,-21} keys {1}  order as typed: {2}' -f $case.Label, $keys.Count, (($keys -join ',') -eq ($typed -join ','))
}

'--- the two that keep the order, in full ---'
'[ordered]@{}        ' + ($asOrdered | ConvertTo-Json -Compress)
'[pscustomobject]@{} ' + ($asObject | ConvertTo-Json -Compress)

'--- and the hashtable, twice in the same session ---'
'both conversions identical: ' + (($asHashtable | ConvertTo-Json -Compress) -eq ($asHashtable | ConvertTo-Json -Compress))

Three comparisons, then the two that keep the order, written out in full:

@{}                   keys 5  order as typed: False
[ordered]@{}          keys 5  order as typed: True
[pscustomobject]@{}   keys 5  order as typed: True
--- the two that keep the order, in full ---
[ordered]@{}        {"name":"sql01","port":1433,"role":"primary","site":"dc1","owner":"dba"}
[pscustomobject]@{} {"name":"sql01","port":1433,"role":"primary","site":"dc1","owner":"dba"}
--- and the hashtable, twice in the same session ---
both conversions identical: True

A plain @{} hashtable is unordered by definition, so the JSON it produces is in an order nobody chose. The exact order is not printed here on purpose: it changed between two runs on the same machine while this page was being measured, which is exactly what "unordered" means and why there is nothing to compare your own output against. Within one session it is stable, which the last line shows.

[ordered]@{} and [pscustomobject]@{} both keep the order. For a file a person will read or a diff a reviewer will look at, that is the difference between a one line change and a whole file rewritten.

Note: The 5.1 reference states it plainly: "While the key-value pairs are added to the Hashtable in the order presented in the JSON string, Hashtable objects don't maintain that order." Reading with -AsHashtable on 7.3 and later avoids it, because that switch returns an OrderedHashtable rather than a Hashtable.
Rule of thumb: Build objects destined for JSON with [pscustomobject]@{}, or [ordered]@{} if you need to add keys in a loop. Reserve plain @{} for lookups you never serialize.

Example 12: the switches that change the text and not the data

Four switches on ConvertTo-Json change how the text is written without changing what it means. Three of them exist only in PowerShell 6.2 and later. Measuring them on one object puts the differences side by side.

Save this as Show-OutputSwitches.ps1 in C:\json-lab and run it with .\Show-OutputSwitches.ps1.

# The four switches that change the text without changing the data, measured on
# one object so the differences line up.
$o = [pscustomobject]@{
    text    = 'it''s "ok" <b>&'
    accented = 'caf' + [char]0x00E9
    day     = [System.DayOfWeek]::Friday
}

'--- default ---'
$o | ConvertTo-Json -Compress

'--- -EscapeHandling EscapeHtml ---'
$o | ConvertTo-Json -Compress -EscapeHandling EscapeHtml

'--- -EscapeHandling EscapeNonAscii ---'
$o | ConvertTo-Json -Compress -EscapeHandling EscapeNonAscii

'--- -EnumsAsStrings ---'
$o | ConvertTo-Json -Compress -EnumsAsStrings

'--- with and without -Compress, same data ---'
$pretty = [pscustomobject]@{ name = 'sql01'; port = 1433 } | ConvertTo-Json
$compact = [pscustomobject]@{ name = 'sql01'; port = 1433 } | ConvertTo-Json -Compress
# -split treats its pattern as a regex, so '\r?\n' counts the lines the same
# way on Windows and elsewhere without needing an escaped newline.
'pretty line count:  ' + ($pretty -split '\r?\n').Count
'compact line count: ' + ($compact -split '\r?\n').Count
'same data:          ' + ((($pretty | ConvertFrom-Json).port) -eq (($compact | ConvertFrom-Json).port))

Four versions of the same object, then three facts about -Compress:

--- default ---
{"text":"it's \"ok\" <b>&","accented":"café","day":5}
--- -EscapeHandling EscapeHtml ---
{"text":"it\u0027s \u0022ok\u0022 \u003cb\u003e\u0026","accented":"café","day":5}
--- -EscapeHandling EscapeNonAscii ---
{"text":"it's \"ok\" <b>&","accented":"caf\u00e9","day":5}
--- -EnumsAsStrings ---
{"text":"it's \"ok\" <b>&","accented":"café","day":"Friday"}
--- with and without -Compress, same data ---
pretty line count:  4
compact line count: 1
same data:          True
SwitchWhat changesWhen it matters
-CompressRemoves the indentation and the newlines.A payload going over the wire, or a file you will not read by eye.
-EscapeHandling EscapeHtmlEscapes <, >, &, ' and " as \u sequences.JSON embedded in an HTML page or a script tag.
-EscapeHandling EscapeNonAsciiEscapes everything outside ASCII.A consumer that mishandles UTF-8, or a transport that is not 8 bit clean.
-EnumsAsStringsWrites "Friday" instead of 5.Output a human or a schema will read. Numeric enum values are meaningless outside .NET.

The default escapes only control characters, which is why the newline in the earlier probe came out as \n while the angle brackets and the apostrophe came through as themselves. Both forms are valid JSON and both parse back to the same string, which the same data: True line confirms.

Note: The accented character stays as one character under EscapeHtml and becomes \u00e9 only under EscapeNonAscii. If an application is showing mojibake, the file encoding is the more likely cause than the escaping. The JSON errors reference covers the encoding side.

Example 13: -Raw is right for one document and wrong for many

The documented way to read a JSON file is Get-Content -Raw file.json | ConvertFrom-Json, and -Raw is documented as returning "the entire contents of a file in one string with the newlines preserved". For one document that is correct. For a file holding one document per line it is the wrong answer, and leaving -Raw off is the right one. This one needs both .\data\appsettings.json and .\data\events.jsonl.

Save this as Test-FileReading.ps1 in C:\json-lab and run it with .\Test-FileReading.ps1.

# Whether you need -Raw depends on what is in the file, and the two cases want
# opposite answers. Needs .\data\appsettings.json and .\data\events.jsonl from
# Build-JsonLab.ps1.
'--- one pretty-printed document ---'
'lines in the file: ' + (Get-Content ./data/appsettings.json).Count
try { 'piped:  ' + ((Get-Content ./data/appsettings.json | ConvertFrom-Json).Logging.LogLevel.Default) } catch { 'piped:  ERROR ' + $_.Exception.Message }
try { '-Raw:   ' + ((Get-Content -Raw ./data/appsettings.json | ConvertFrom-Json).Logging.LogLevel.Default) } catch { '-Raw:   ERROR ' + $_.Exception.Message }

'--- three documents, one per line ---'
'lines in the file: ' + (Get-Content ./data/events.jsonl).Count
try {
    $r = Get-Content ./data/events.jsonl | ConvertFrom-Json
    'piped:  ' + @($r).Count + ' objects, ids ' + ((@($r) | ForEach-Object { $_.id }) -join ',')
} catch { 'piped:  ERROR ' + $_.Exception.Message }
try {
    $r2 = Get-Content -Raw ./data/events.jsonl | ConvertFrom-Json
    '-Raw:   ' + @($r2).Count + ' objects'
} catch { '-Raw:   ERROR ' + $_.Exception.Message }

'--- one line of the pretty document on its own ---'
try { $null = '  "Default": "Information",' | ConvertFrom-Json; 'parsed' } catch { 'ERROR ' + $_.Exception.Message }

Two files, read both ways, then one line of the pretty file on its own:

--- one pretty-printed document ---
lines in the file: 20
piped:  Information
-Raw:   Information
--- three documents, one per line ---
lines in the file: 3
piped:  3 objects, ids 1,2,3
-Raw:   ERROR Conversion from JSON failed with error: Additional text encountered after finished reading JSON content: {. Path '', line 2, position 0.
--- one line of the pretty document on its own ---
ERROR Conversion from JSON failed with error: Additional text encountered after finished reading JSON content: :. Path '', line 1, position 11.

The pretty printed configuration file reads correctly either way, even though no single line of it is valid JSON on its own, which the last line of the output proves. The JSON Lines file is the reversal: piping the lines gives three objects, and -Raw fails with "Additional text encountered after finished reading JSON content". The same two commands, pointed at two files, with the correct answer swapped.

What is in the fileRead it withWhy
One JSON document, pretty printed or notGet-Content -RawOne string in, one object out. The documented form.
One document per line, as log shippers emitGet-Content with no -RawEach document is handled in turn. -Raw raises a terminating error.
Common mistake: Adding -Raw to fix a parse error on a JSON Lines file, because every example on the internet has it. It is the one case where -Raw is what broke it. Count the lines and look at the first one: if that line is a complete document on its own, leave -Raw off.

The Windows PowerShell 5.1 parts, and what the docs assume

Both cmdlets have been in PowerShell since 3.0, so a 5.1 script that calls them runs. Nearly every parameter used on this page is missing there, and the engine underneath is a different one: PowerShell 7 uses Newtonsoft Json.NET while 5.1 uses the JavaScriptSerializer class. Both facts are stated on the respective reference pages. Start with what the shell in front of you actually has.

Save this as Show-ParameterSets.ps1 in C:\json-lab and run it with .\Show-ParameterSets.ps1.

# Which parameters this PowerShell actually has. Run it before you copy a switch
# out of the documentation: the docs describe the newest release, and the
# parameter you want may not be in the build on the machine in front of you.
'PowerShell:  ' + $PSVersionTable.PSVersion.ToString()
'Edition:     ' + $PSVersionTable.PSEdition

$common = [System.Management.Automation.PSCmdlet]::CommonParameters
foreach ($name in 'ConvertTo-Json', 'ConvertFrom-Json') {
    $p = (Get-Command $name).Parameters.Keys | Where-Object { $_ -notin $common } | Sort-Object
    '{0,-17} {1}' -f $name, ($p -join ', ')
}

Two version lines and two parameter lists. Yours will show whichever build you are on:

PowerShell:  7.4.6
Edition:     Core
ConvertTo-Json    AsArray, Compress, Depth, EnumsAsStrings, EscapeHandling, InputObject
ConvertFrom-Json  AsHashtable, Depth, InputObject, NoEnumerate

Running the same two lines under Windows PowerShell 5.1 is the honest way to compare, and there is no 5.1 on the machine this page was measured on, so no output is published for it. The parameter lists in the documentation repository can be counted instead, and they are unambiguous:

CmdletWindows PowerShell 5.1PowerShell 7.4PowerShell 7.5
ConvertFrom-Json-InputObject onlyplus -AsHashtable, -Depth, -NoEnumerateplus -DateKind
ConvertTo-Json-InputObject, -Depth, -Compressplus -AsArray, -EnumsAsStrings, -EscapeHandlingsame as 7.4
Test-Jsondoes not existpresent, introduced in 6.1present

So on 5.1 there is no way to keep a case colliding key, no way to read a file with an empty key, no way to round trip a one element array, and no strict validator. -Depth on ConvertTo-Json is there, with the same default of 2, which means example 4 destroys data on 5.1 exactly as it does on 7 and there is no -AsHashtable escape hatch to read the result back with.

Note: Two further differences are documented rather than measured here. ConvertFrom-Json in 5.1 returns an error on a JSON comment, while 6 and later accept both // and /* */ comment styles and discard them; the reference for that is about_Comments, which carries a section headed JSON comments. And the Test-Json page for the 5.1 moniker does not exist in the documentation repository at all, which is the second proof that the cmdlet is not there.
Reading the docs on any version: the Learn pages default to the newest release. The -DateKind parameter is documented without a version banner on the page body and only the parameter note says 7.5. Running Show-ParameterSets.ps1 takes two seconds and is the only answer that applies to your machine.

Hidden gems

Measure the depth instead of guessing it. Passing -Depth 100 always works and tells you nothing about your data. This script reads every file in the lab, works out how deeply nested it is, and then finds the smallest -Depth that converts it back with no truncation warning. Point it at a folder of real configuration files and it tells you the number to put in your own script. It needs the data folder, so run Build-JsonLab.ps1 first if you have cleaned up.

Save this as Measure-JsonDepth.ps1 in C:\json-lab and run it with .\Measure-JsonDepth.ps1.

# The script that answers "what -Depth do I need". For every file in .\data it
# measures the structural depth, then finds the smallest -Depth that converts the
# object back to JSON with no truncation warning. Pass a number you measured
# instead of guessing 10 or 100.
function Get-JsonDepth {
    param($Node, [int]$Level = 0)
    if ($Node -is [System.Collections.IDictionary]) {
        $d = $Level
        foreach ($v in $Node.Values) { $d = [Math]::Max($d, (Get-JsonDepth -Node $v -Level ($Level + 1))) }
        return $d
    }
    if ($Node -is [System.Collections.IEnumerable] -and $Node -isnot [string]) {
        $d = $Level
        foreach ($v in $Node) { $d = [Math]::Max($d, (Get-JsonDepth -Node $v -Level ($Level + 1))) }
        return $d
    }
    return $Level
}

function Get-MinimumDepth($Object) {
    foreach ($d in 0..20) {
        $w = $null
        $null = $Object | ConvertTo-Json -Depth $d -Compress -WarningVariable w -WarningAction SilentlyContinue
        if ($w.Count -eq 0) { return $d }
    }
    return -1
}

'file                   structural depth   smallest -Depth that does not truncate'
foreach ($f in Get-ChildItem ./data -File | Sort-Object Name) {
    $raw = Get-Content -Raw $f.FullName
    try {
        $obj = $raw | ConvertFrom-Json -AsHashtable
        '{0,-22} {1,-18} {2}' -f $f.Name, (Get-JsonDepth -Node $obj), (Get-MinimumDepth $obj)
    } catch {
        '{0,-22} {1}' -f $f.Name, 'does not parse as a single document'
    }
}

One row per file, and one row that cannot be measured:

file                   structural depth   smallest -Depth that does not truncate
appsettings.json       4                  3
casekeys.json          1                  0
dates.json             1                  0
dupekeys.json          1                  0
emptykey.json          1                  0
events.jsonl           does not parse as a single document
numbers.json           1                  1

The configuration file is four levels deep and needs -Depth 3, which is one more than the default and explains example 4 exactly. The arithmetic rule you might derive from that, subtract one from the structural depth, is wrong on the last row: numbers.json is one level deep and needs -Depth 1, because the twenty digit integer in it is itself an object. That is the argument for measuring rather than reasoning. The JSON Lines file reports that it is not a single document, which is correct and is example 13 again.

What comes back cannot grow a new property by assignment. ConvertFrom-Json returns a PSCustomObject with a fixed set of properties. Changing one works and adding one does not, and the error is clear once you have seen it.

Save this as Show-PropertyAssignment.ps1 in C:\json-lab and run it with .\Show-PropertyAssignment.ps1.

# What ConvertFrom-Json hands back is a PSCustomObject with a fixed set of
# properties. Changing one works. Adding one does not, and the error says why.
$o = '{"port":1433}' | ConvertFrom-Json

'--- changing an existing property ---'
$o.port = 1521
'port is now: ' + $o.port

'--- adding a new one by assignment ---'
try { $o.host = 'sql01'; 'assignment worked' } catch { 'error: ' + $_.Exception.Message }

'--- adding a new one with Add-Member ---'
$o | Add-Member -NotePropertyName host -NotePropertyValue 'sql01'
'result: ' + ($o | ConvertTo-Json -Compress)

One success, one error, one working alternative:

--- changing an existing property ---
port is now: 1521
--- adding a new one by assignment ---
error: Exception setting "host": "The property 'host' cannot be found on this object. Verify that the property exists and can be set."
--- adding a new one with Add-Member ---
result: {"port":1521,"host":"sql01"}

Use Add-Member, or read the file with -AsHashtable where a hashtable takes a new key with no ceremony. The same error appears when a script tries to add a field to an API response before posting it back, which is where most people meet it.

A null and an empty string are not the same input. $null | ConvertTo-Json returns the four characters null, and an empty string returns two quote marks. In the other direction an empty string produces no output at all and no error, while $null is rejected: the reference states that -InputObject "can't be $null". A function that pipes an unset variable into ConvertFrom-Json therefore fails for a different reason than one that pipes in an empty file.

-Depth costs nothing when it is too large. There is no performance or size penalty for a depth bigger than the data, because it is a limit rather than a target. If you are not going to measure, -Depth 10 covers almost every configuration file and -Depth 100 is the documented maximum.


Clean up when you are finished

Everything this page created is in one folder: the data subfolder with its seven files, the nineteen scripts you saved, and the two files that Edit-AppSettings.ps1 and Repair-AppSettings.ps1 wrote beside them. Nothing was installed, no module was downloaded and nothing was registered anywhere, so removing the folder leaves no trace. Run this from C:\json-lab. It prints nothing:

Remove-Item -Path ./data -Recurse -Force
Remove-Item -Path ./appsettings.out.json -Force -ErrorAction SilentlyContinue
Remove-Item -Path ./appsettings.fixed.json -Force -ErrorAction SilentlyContinue
Remove-Item -Path ./*.ps1 -Force
Note: Keep Measure-JsonDepth.ps1 if you keep nothing else. Point it at a folder of real configuration files, or at one file, and it answers the only question on this page that has a different answer for every reader. Drop the last line of the block above to keep the scripts and remove only the data.

Where this matters

A configuration file that lost a section nobody noticed. A script read it, changed a connection string, wrote it back, and flattened everything four levels down into a string. The file is valid, the service starts, and one feature quietly uses its built in defaults. Example 4.

An API that started rejecting a request body. The payload is a list, the list had one member that day, and the brackets vanished on the way out. -NoEnumerate and -AsArray are the two halves of the fix. Example 6.

A parser that worked on one file and threw on the next. Two keys differing only in case, or a key that is an empty string, both of which are legal JSON and neither of which fits a PSCustomObject. Examples 7 and 8.

A date comparison that is wrong by exactly your UTC offset. One timestamp in the file carried a Z and another did not, so one came back as Utc and the other as Unspecified, and subtraction ignores the difference. Example 9.

A generated file that shows as fully rewritten in every review. The object was built with a plain hashtable, so the key order is arbitrary and changes between runs. Example 11.

A switch copied from the documentation that does not exist on the server. The Learn pages describe the newest release. -AsHashtable and -NoEnumerate are absent from Windows PowerShell 5.1 entirely and -DateKind needs 7.5. The parameter list section measures it.


Tips and limitations

  • -Depth on ConvertTo-Json defaults to 2 and truncates. -Depth on ConvertFrom-Json defaults to 1024 and rejects. They share a name and nothing else.
  • The truncated output is valid JSON. Test-Json passes it and ConvertFrom-Json reads it, so no validity check anywhere will catch the loss.
  • The truncation warning goes to the warning stream, which means $WarningPreference, -WarningAction and redirecting standard output alone all hide it.
  • The placeholder it leaves behind depends on what the object was: System.Collections.Hashtable for a hashtable, and the console display form such as @{Seconds=5; Jitter=True} for an object that came out of ConvertFrom-Json.
  • Two keys differing only in case are a terminating error, not a silent overwrite. Two keys spelled identically are a silent overwrite, last one wins.
  • -AsHashtable solves the case collision, the empty key and the key order in one switch, and returns an OrderedHashtable on PowerShell 7.3 and later.
  • A one element JSON array comes back as the element. Read with -NoEnumerate and write with -AsArray when the length is not fixed.
  • Strings that parse as timestamps become [datetime] values with three different Kind results depending on the text. A date with no time stays a string.
  • An integer beyond Int64 arrives as a BigInteger and, on 7.4, is written back out as an object of five booleans and a sign. PowerShell 7.5 changed this.
  • A plain @{} hashtable does not preserve key order, which the 5.1 reference states explicitly. Use [pscustomobject]@{} or [ordered]@{}.
  • Get-Content -Raw is right for one document and wrong for one document per line. Check the first line before adding it.
  • Windows PowerShell 5.1 has -InputObject and nothing else on ConvertFrom-Json, and no Test-Json at all. Its engine is JavaScriptSerializer rather than Newtonsoft Json.NET.
  • A property cannot be added to the result by assignment. Use Add-Member or read the document with -AsHashtable.
  • Nothing on this page needs elevation, a module or a network connection, and nothing it creates leaves the lab folder.

Official documentation


  • JSON formatter: paste the file this page truncated and see the structure that is left.
  • JSON errors explained: the other half of the subject, for a file that will not parse at all.
  • JWT decoder: a token payload is JSON, and the same depth and date behaviour applies once you read it into PowerShell.

Five cheat sheets, one PDF

Subnet masks, PowerShell, Linux commands, HTTP status codes and the ESXi command line - one page each, free to keep. Leave an address and it arrives in a minute.