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.
$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.
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
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
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.
| Piece | What it is | The part that catches people |
|---|---|---|
ConvertTo-Json | Object to text. Implemented with Newtonsoft Json.NET in PowerShell 7. | -Depth defaults to 2 and truncates past it. |
ConvertFrom-Json | Text 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 middle | A 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.
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.
-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.
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.
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.
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
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-Json | ConvertFrom-Json | |
|---|---|---|
What -Depth limits | How deep it will serialize | How deep the input may be |
| Documented default | 2 | 1024 |
| Documented range | 0 to 100 | not stated |
| Past the limit | truncates and warns | throws a terminating error |
| Measured at the boundary | 4 nested objects warn at the default | 1024 brackets parse, 1025 do not |
-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.
-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.
-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 page | What it says | What 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 |
-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.
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.
-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.
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.
-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.
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.
-AsHashtable on 7.3 and later avoids it, because that switch returns an OrderedHashtable rather than a Hashtable.
[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
| Switch | What changes | When it matters |
|---|---|---|
-Compress | Removes the indentation and the newlines. | A payload going over the wire, or a file you will not read by eye. |
-EscapeHandling EscapeHtml | Escapes <, >, &, ' and " as \u sequences. | JSON embedded in an HTML page or a script tag. |
-EscapeHandling EscapeNonAscii | Escapes everything outside ASCII. | A consumer that mishandles UTF-8, or a transport that is not 8 bit clean. |
-EnumsAsStrings | Writes "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.
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 file | Read it with | Why |
|---|---|---|
| One JSON document, pretty printed or not | Get-Content -Raw | One string in, one object out. The documented form. |
| One document per line, as log shippers emit | Get-Content with no -Raw | Each document is handled in turn. -Raw raises a terminating error. |
-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:
| Cmdlet | Windows PowerShell 5.1 | PowerShell 7.4 | PowerShell 7.5 |
|---|---|---|---|
ConvertFrom-Json | -InputObject only | plus -AsHashtable, -Depth, -NoEnumerate | plus -DateKind |
ConvertTo-Json | -InputObject, -Depth, -Compress | plus -AsArray, -EnumsAsStrings, -EscapeHandling | same as 7.4 |
Test-Json | does not exist | present, introduced in 6.1 | present |
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.
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.
-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
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
-DepthonConvertTo-Jsondefaults to 2 and truncates.-DepthonConvertFrom-Jsondefaults to 1024 and rejects. They share a name and nothing else.- The truncated output is valid JSON.
Test-Jsonpasses it andConvertFrom-Jsonreads it, so no validity check anywhere will catch the loss. - The truncation warning goes to the warning stream, which means
$WarningPreference,-WarningActionand redirecting standard output alone all hide it. - The placeholder it leaves behind depends on what the object was:
System.Collections.Hashtablefor a hashtable, and the console display form such as@{Seconds=5; Jitter=True}for an object that came out ofConvertFrom-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.
-AsHashtablesolves the case collision, the empty key and the key order in one switch, and returns anOrderedHashtableon PowerShell 7.3 and later.- A one element JSON array comes back as the element. Read with
-NoEnumerateand write with-AsArraywhen the length is not fixed. - Strings that parse as timestamps become
[datetime]values with three differentKindresults depending on the text. A date with no time stays a string. - An integer beyond
Int64arrives as aBigIntegerand, 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 -Rawis right for one document and wrong for one document per line. Check the first line before adding it.- Windows PowerShell 5.1 has
-InputObjectand nothing else onConvertFrom-Json, and noTest-Jsonat all. Its engine isJavaScriptSerializerrather than Newtonsoft Json.NET. - A property cannot be added to the result by assignment. Use
Add-Memberor 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
- ConvertTo-Json: the
-Depthrange and default, the warning since 7.1, and theBigIntegerchange in 7.5. - ConvertFrom-Json: the duplicate key paragraph, the
-AsHashtablenotes, and theKindrules for timestamps. - Test-Json: introduced in PowerShell 6.1, returns a boolean.
- about_Comments: the JSON comments section, and which versions accept them.
- Get-Content: what
-Rawreturns, in one sentence. - about_Execution_Policies: the scopes, and what the
Processscope does. - about_Path_Syntax: backslash or forward slash as the separator.
- Installing PowerShell on Windows:
pwsh.exeinstalls side by side with Windows PowerShell.
Related tools
- 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.
Related guides
- How to upgrade to PowerShell 7 on Windows: every switch on this page except two needs it.
- PowerShell commands cheat sheet: the one line form of the conversion, alongside the rest of the daily set.
- Scripted internet speed tests in Windows: a working example of reading a command line tool that emits JSON.
- WMIC against PowerShell: where converting CIM output to JSON fits, and what replaced the old tool.
- Checking certificate expiry and chains in PowerShell: the same shape of problem, a default nobody set, in a different corner of the shell.
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.