Parse JSON in PowerShell: ConvertFrom-Json, ConvertTo-Json, and PSCustomObject
Last updated:
PowerShell handles JSON through two cmdlets — ConvertFrom-Json for parsing and ConvertTo-Json for serialization — plus Invoke-RestMethod for REST clients that auto-parse JSON responses. The mental model is simple: a JSON object becomes a PSCustomObject with dot-accessible properties, a JSON array becomes a native PowerShell array, and primitives map to the smallest .NET type that fits. The two things most engineers trip on are the -Depth default (2 on writes — too low for almost any real document) and the choice between PSCustomObject and the optional -AsHashtable output mode. PowerShell 7.x added meaningful improvements over 5.1 — better BigInteger handling, -NoEnumerate, -AsArray, faster serialization — but the core API is stable across both versions.
Got a JSON payload PowerShell refuses to parse? Paste it into Jsonic's JSON Validator — it pinpoints trailing commas, smart quotes, and bracket mismatches with exact line numbers before you waste another ConvertFrom-Json attempt.
ConvertFrom-Json and ConvertTo-Json basics
ConvertFrom-Json takes a JSON string from the pipeline and returns a PowerShell object graph. JSON objects become PSCustomObject instances with one NoteProperty per JSON field. JSON arrays become regular PowerShell arrays. Strings, numbers, booleans, and null map to [string], the narrowest .NET numeric type that fits, [bool], and $null respectively.
# Parse a JSON literal
$json = '{"name":"Ada","age":36,"skills":["math","engineering"]}'
$obj = $json | ConvertFrom-Json
$obj.name # Ada
$obj.age # 36
$obj.skills[0] # math
$obj.skills.Count # 2
# Inspect what came back
$obj | Get-Member
# TypeName: System.Management.Automation.PSCustomObject
# Name MemberType Definition
# ---- ---------- ----------
# age NoteProperty long age=36
# name NoteProperty string name=Ada
# skills NoteProperty Object[] skills=...ConvertTo-Json is the inverse — it accepts any PowerShell object and emits a JSON string. Both cmdlets are pipeline-aware, so the typical pattern is a short chain rather than a temporary variable.
# Build and serialize
$config = [PSCustomObject]@{
name = "production"
enabled = $true
limits = @{ requests = 1000; bytes = 524288 }
}
$config | ConvertTo-Json -Depth 100
# {
# "name": "production",
# "enabled": true,
# "limits": {
# "requests": 1000,
# "bytes": 524288
# }
# }Use -Compress on ConvertTo-Json for single-line output (no indentation, no whitespace) when shipping over the wire or storing in a database.
-AsHashtable parameter for true hashtable output (PS 6.0+)
The default PSCustomObject output is great for fixed schemas where you know every field name at the time you write the script — dot completion works, and the type appears in Get-Member. It is awkward for dynamic-key payloads where you do not know the keys in advance (config files keyed by environment name, translation dictionaries, sparse maps). For those, pass -AsHashtable — available since PowerShell 6.0 — and you get an ordered hashtable instead.
$json = '{"prod":{"replicas":5},"staging":{"replicas":2},"dev":{"replicas":1}}'
# PSCustomObject: dot access works, but enumeration is awkward
$obj = $json | ConvertFrom-Json
$obj.PSObject.Properties | ForEach-Object { "$($_.Name) -> $($_.Value.replicas)" }
# Hashtable: native enumeration with .Keys, .Values, .GetEnumerator()
$ht = $json | ConvertFrom-Json -AsHashtable
foreach ($env in $ht.Keys) {
"$env -> $($ht[$env].replicas)"
}
# prod -> 5
# staging -> 2
# dev -> 1-AsHashtable also returns an ordered hashtable, so insertion order from the JSON source is preserved through round trips. This matters when JSON key order is semantically meaningful — for example, OpenAPI specs where path ordering affects route matching priority in some tools.
On PowerShell 5.1 the parameter does not exist. Two options: upgrade to 7.x (the shipping version on Windows Server 2025 and the recommended path), or post-process the PSCustomObject into a hashtable with a manual loop over PSObject.Properties. The upgrade is almost always cheaper.
-Depth parameter: avoiding "serialization too deep" errors
The -Depth parameter is the most common PowerShell-JSON gotcha. The asymmetry between read and write defaults catches even experienced scripters.
| Cmdlet | PS 5.1 default | PS 7.x default | Recommended value |
|---|---|---|---|
ConvertFrom-Json | 1024 | 1024 | 1024 (rarely needs change) |
ConvertTo-Json | 2 | 2 | 100 |
When ConvertTo-Json hits the depth limit, it does not error — it truncates silently, emitting the type name as a string in place of the nested object. The output looks valid but is meaningless.
# Nested structure
$data = @{
name = "outer"
inner = @{
name = "middle"
deepest = @{
name = "bottom"
value = 42
}
}
}
# Default depth 2 — silently truncates
$data | ConvertTo-Json
# {
# "name": "outer",
# "inner": {
# "name": "middle",
# "deepest": "System.Collections.Hashtable"
# }
# }
# Depth 100 — actually serializes everything
$data | ConvertTo-Json -Depth 100
# {
# "name": "outer",
# "inner": {
# "name": "middle",
# "deepest": { "name": "bottom", "value": 42 }
# }
# }The fix is simple: pass -Depth 100 on every ConvertTo-Json call. The maximum allowed depth is 100 in PowerShell 5.1 and 100 in 7.x (raised from 100 in some preview builds but stable at 100 in releases). If you genuinely need more, you have probably modeled the data wrong — flatten the structure or split it across files.
Reading JSON from files: Get-Content + ConvertFrom-Json
Reading JSON from disk has one mandatory flag: -Raw on Get-Content. Without it, Get-Content returns one string per line, and ConvertFrom-Json attempts to parse each line as its own document — which fails for any multi-line JSON.
# Wrong — fails on any pretty-printed JSON file
$obj = Get-Content -Path config.json | ConvertFrom-Json
# ConvertFrom-Json : Conversion from JSON failed with error: Unexpected character...
# Right — -Raw reads the whole file as one string
$obj = Get-Content -Raw -Path config.json | ConvertFrom-Json -Depth 100
# Even faster for large files (skips PowerShell pipeline overhead)
$text = [System.IO.File]::ReadAllText("$PWD\config.json")
$obj = $text | ConvertFrom-Json -Depth 100For files larger than a few megabytes, the [System.IO.File]::ReadAllText approach is noticeably faster because it bypasses PowerShell's line-buffered pipeline. For multi-gigabyte JSON files you should not be using PowerShell at all — reach for a streaming parser like jq or a script in a language with proper streaming JSON support.
Encoding matters on read too. Get-Content -Raw on PowerShell 5.1 defaults to system codepage detection, which can misread UTF-8 files written by non-Windows tools. Pass -Encoding utf8 explicitly: Get-Content -Raw -Encoding utf8 -Path file.json. PowerShell 7.x defaults to UTF-8 already, but the explicit flag stays portable.
See also our companion Parse JSON in Bash guide for shell-script equivalents and the JSON across languages reference for a side-by-side comparison of file I/O patterns.
Calling REST APIs: Invoke-RestMethod auto-parses JSON
Invoke-RestMethodis PowerShell's HTTP client tuned for JSON APIs. When the response Content-Type is application/json, it parses the body automatically and returns the parsed object — you skip ConvertFrom-Json entirely.
# GET — response is auto-parsed
$user = Invoke-RestMethod -Uri "https://api.github.com/users/torvalds" `
-Headers @{ "User-Agent" = "PowerShellScript" }
$user.name # Linus Torvalds
$user.public_repos # 8
# POST with JSON body
$payload = @{
title = "Hello from PowerShell"
body = "Created via Invoke-RestMethod"
labels = @("bug", "tooling")
} | ConvertTo-Json -Depth 10
$response = Invoke-RestMethod -Uri "https://api.example.com/issues" `
-Method Post `
-Body $payload `
-ContentType "application/json" `
-Headers @{ Authorization = "Bearer $env:API_TOKEN" }
$response.id
$response.html_urlFor paginated responses and rate-limit metadata in headers, switch to Invoke-WebRequest instead — it returns the full HTTP response with .StatusCode, .Headers, and .Content. You then run $response.Content | ConvertFrom-Json yourself.
# Invoke-WebRequest for header access
$response = Invoke-WebRequest -Uri "https://api.example.com/items?page=1"
$body = $response.Content | ConvertFrom-Json -Depth 100
$rateLimit = $response.Headers['X-RateLimit-Remaining']
$nextPage = $response.Headers['Link'] -match '<([^>]+)>; rel="next"' | Out-NullFor more examples across HTTP clients, see our curl JSON guide.
Modifying JSON and writing back to file
The read-modify-write loop on JSON files in PowerShell is a three-step pipeline: parse, mutate, serialize back. The mutation syntax depends on whether you parsed into a PSCustomObject (the default) or a hashtable (-AsHashtable).
# Read
$obj = Get-Content -Raw -Path package.json | ConvertFrom-Json -Depth 100
# Modify existing field
$obj.version = "2.0.0"
# Modify nested field
$obj.scripts.test = "vitest run"
# Add a new property (PSCustomObject requires Add-Member)
$obj | Add-Member -NotePropertyName 'private' -NotePropertyValue $true -Force
# Remove a property
$obj.PSObject.Properties.Remove('deprecated_field')
# Write back with explicit encoding
$obj | ConvertTo-Json -Depth 100 | Set-Content -Path package.json -Encoding utf8If you parsed with -AsHashtable, mutation is much cleaner — you can add and remove keys with normal hashtable syntax:
$ht = Get-Content -Raw -Path package.json | ConvertFrom-Json -AsHashtable
$ht['version'] = "2.0.0"
$ht['private'] = $true
$ht.Remove('deprecated_field')
$ht | ConvertTo-Json -Depth 100 | Set-Content -Path package.json -Encoding utf8Always wrap file-mutation scripts in try/catch so a failed parse does not leave you with a half-written file:
try {
$obj = Get-Content -Raw -Path config.json -ErrorAction Stop | ConvertFrom-Json -Depth 100
$obj.environment = "production"
$obj | ConvertTo-Json -Depth 100 | Set-Content -Path config.json -Encoding utf8 -ErrorAction Stop
}
catch {
Write-Error "Failed to update config: $_"
exit 1
}PowerShell 5.1 vs 7.x: BigInt support, depth defaults, performance
PowerShell 5.1 is the Windows-PowerShell branch shipped with every Windows installation since Server 2016 — .NET Framework-based, Windows-only, and effectively frozen. PowerShell 7.x is the open-source, cross-platform successor built on modern .NET. Both support ConvertFrom-Json and ConvertTo-Json, but 7.x has meaningful improvements for JSON workloads.
| Feature | PowerShell 5.1 | PowerShell 7.x |
|---|---|---|
-AsHashtable | Not available | Yes (since 6.0) |
-NoEnumerate on ConvertTo-Json | Not available | Yes |
-AsArray on ConvertTo-Json | Not available | Yes |
-EscapeHandling | Not available | Yes (since 6.2) |
| BigInteger preservation | Lossy for > [long] | Preserved in 7.2+ |
| Default depth on writes | 2 | 2 (unchanged) |
| JSON engine | Newtonsoft.Json | System.Text.Json (faster) |
| UTF-8 default encoding | No (Latin-1 / UTF-16 BOM) | Yes |
For scripts that must run on both versions, the safest pattern is to detect the version and branch:
if ($PSVersionTable.PSVersion.Major -ge 6) {
$config = Get-Content -Raw config.json | ConvertFrom-Json -AsHashtable -Depth 100
} else {
# 5.1 fallback — parse to PSCustomObject then convert
$obj = Get-Content -Raw config.json | ConvertFrom-Json
$config = @{}
foreach ($prop in $obj.PSObject.Properties) {
$config[$prop.Name] = $prop.Value
}
}The System.Text.Json backend in 7.x is roughly 2–3x faster on large payloads compared to the Newtonsoft-based 5.1 implementation. If you process JSON at scale, the upgrade pays for itself in script runtime alone.
For language-comparison context see our Parse JSON in C# guide — PowerShell 7.x and modern C# share the same underlying serializer, so the edge cases overlap.
Working with arrays: foreach, Where-Object, Select-Object on JSON
JSON arrays become standard PowerShell arrays after parsing, which means the full pipeline arsenal works on them: foreach, ForEach-Object, Where-Object, Select-Object, Sort-Object, Group-Object, Measure-Object.
$json = '[
{ "name": "alice", "role": "admin", "active": true },
{ "name": "bob", "role": "user", "active": false },
{ "name": "carol", "role": "user", "active": true },
{ "name": "dave", "role": "admin", "active": true }
]'
$users = $json | ConvertFrom-Json
# foreach loop
foreach ($u in $users) {
"$($u.name) ($($u.role))"
}
# Filter with Where-Object
$activeAdmins = $users | Where-Object { $_.role -eq 'admin' -and $_.active }
# Project fields with Select-Object
$names = $users | Select-Object -ExpandProperty name
# Group by role
$users | Group-Object -Property role | ForEach-Object {
"$($_.Name): $($_.Count) users"
}
# admin: 2 users
# user: 2 users
# Sort and take top N
$users | Sort-Object name | Select-Object -First 2One trap with single-element arrays: when ConvertTo-Json serializes a collection containing exactly one item, it collapses it to a bare object instead of a one-element array. Pass -AsArray (PowerShell 7.x) or wrap with the comma operator (,$collection) on 5.1 to force array output.
# Wrong — single item collapses to object
@(@{ id = 1 }) | ConvertTo-Json
# {
# "id": 1
# }
# Right — -AsArray forces JSON array (7.x)
@(@{ id = 1 }) | ConvertTo-Json -AsArray
# [
# {
# "id": 1
# }
# ]
# Right — comma-operator trick (works on 5.1 and 7.x)
,@(@{ id = 1 }) | ConvertTo-JsonFor language-by-language array iteration patterns, see our companion guides for Parse JSON in Perl and Parse JSON in Lua.
Key terms
- ConvertFrom-Json
- PowerShell cmdlet that parses a JSON string into a
PSCustomObjectgraph (or an ordered hashtable when-AsHashtableis passed on 6.0+). Default-Depthis 1024 — almost never the bottleneck on reads. - ConvertTo-Json
- PowerShell cmdlet that serializes any PowerShell object to a JSON string. Default
-Depthis 2 — the single most common source of silent truncation in PowerShell JSON scripts. Always pass-Depth 100for real-world data. - PSCustomObject
- PowerShell's default object type for parsed JSON. Each JSON field becomes a
NotePropertyaccessed with dot notation. Strong for fixed schemas, awkward for dynamic-key payloads — use-AsHashtablefor the latter. - Invoke-RestMethod
- PowerShell HTTP client that auto-parses
application/jsonresponses by inspecting Content-Type. Returns the parsed object directly. Compare withInvoke-WebRequestwhen you need response headers or status codes. - -AsHashtable
- Switch on
ConvertFrom-Json(PowerShell 6.0+) that returns an ordered hashtable instead of aPSCustomObject. Best for dynamic keys, heavy mutation, and round-trip preservation of insertion order. - -NoEnumerate
- Switch on
ConvertTo-Json(PowerShell 7.x) that prevents single-element collections from being unwrapped into a single object. Combined with-AsArray, ensures array semantics survive serialization.
Frequently asked questions
How do I parse JSON in PowerShell?
Pipe a JSON string into ConvertFrom-Json: $obj = $jsonString | ConvertFrom-Json. To read from a file first, use Get-Content with the -Raw switch so the whole file arrives as a single string rather than an array of lines: $obj = Get-Content -Raw -Path data.json | ConvertFrom-Json. The result is a PSCustomObject for JSON objects and a typed array for JSON arrays, and you access fields with dot notation ($obj.name) or array indices ($obj[0]). For deeply nested payloads, add -Depth 100 (the default may truncate). If you want a hashtable instead of a PSCustomObject — which is friendlier for dynamic keys you do not know at write time — add -AsHashtable (PowerShell 6.0 and later). For REST APIs, skip ConvertFrom-Json entirely and use Invoke-RestMethod, which detects application/json responses and parses them automatically.
Why does ConvertFrom-Json return a PSCustomObject and not a Hashtable?
PSCustomObject is the default because it gives PowerShell ergonomics: dot-property access, tab completion in the console, and Get-Member output that shows each JSON field as a NoteProperty. The tradeoff is that PSCustomObject treats keys as fixed properties — iterating dynamic keys you do not know in advance is awkward. For that case, pass -AsHashtable (PowerShell 6.0+) and ConvertFrom-Json returns an [ordered] hashtable where keys are enumerable with .Keys, .Values, and .GetEnumerator(). Hashtables are also the right choice when you plan to mutate the structure heavily (adding or removing keys at runtime) or convert it back to JSON with ConvertTo-Json, since hashtable insertion order is preserved when -AsHashtable returns an ordered hashtable. In PowerShell 5.1 the parameter does not exist, so you either upgrade or post-process the PSCustomObject with a manual conversion loop.
What is -Depth and why do I need to increase it?
The -Depth parameter caps how many levels of nested objects ConvertFrom-Json reads and ConvertTo-Json writes. The asymmetry is the trap: the read default is 1024 (effectively unlimited), but the write default on ConvertTo-Json is just 2 — anything deeper gets serialized as the literal string "System.Collections.Hashtable" or a similar type name instead of recursing into it. The behavior is the same in PowerShell 5.1 and 7.x. For any real-world JSON (API responses, config files, nested arrays of objects), pass -Depth 100 on every ConvertTo-Json call to be safe. ConvertFrom-Json rarely needs the override unless you are processing pathological inputs. The error message "the converted JSON string is in bad format" or output containing type names instead of values almost always means -Depth was too low on the serialization side.
How do I read a JSON file in PowerShell?
Use Get-Content with the -Raw switch and pipe to ConvertFrom-Json: $obj = Get-Content -Raw -Path data.json | ConvertFrom-Json. The -Raw switch is mandatory — without it, Get-Content returns an array of strings (one per line), and ConvertFrom-Json tries to parse each line as its own JSON document, which fails for any multi-line file. For very large files, prefer [System.IO.File]::ReadAllText("$PWD\data.json") which is faster than Get-Content -Raw because it skips PowerShell pipeline overhead. To write modifications back, pass the object through ConvertTo-Json with sufficient -Depth and use Set-Content (also with appropriate encoding): $obj | ConvertTo-Json -Depth 100 | Set-Content -Path data.json -Encoding utf8. On PowerShell 5.1 the default encoding is UTF-16 with a BOM, which often breaks tools downstream — always pass -Encoding utf8 (or utf8NoBOM on 7.x) when interoperating with non-Windows systems.
What's the difference between Invoke-RestMethod and Invoke-WebRequest for JSON?
Invoke-RestMethod inspects the response Content-Type header and auto-parses application/json into PSCustomObject (or hashtable on 7.x with no extra flag — the parsed form is what you get). The return value is the parsed object, ready to use with dot access. Invoke-WebRequest returns a richer response object with .StatusCode, .Headers, .RawContent, and .Content as a string — you parse the body yourself with ConvertFrom-Json on .Content. Reach for Invoke-RestMethod for typical API consumption where you only care about the parsed body. Reach for Invoke-WebRequest when you need response headers (pagination links, rate-limit metadata, redirect tracing), the raw body bytes, or precise control over how content is decoded. Both share the same parameters for method, body, headers, and authentication — switching between them is a one-word change.
How do I send JSON in a POST request from PowerShell?
Build a hashtable or PSCustomObject, convert it with ConvertTo-Json, then pass it as -Body to Invoke-RestMethod with -ContentType "application/json". Example: $body = @{ name = "Ada"; age = 30 } | ConvertTo-Json; Invoke-RestMethod -Uri "https://api.example.com/users" -Method Post -Body $body -ContentType "application/json". Two pitfalls to avoid. First, if the server expects a JSON array body and you build a single-element collection, ConvertTo-Json will collapse it into a non-array object — wrap with the comma operator ($body = ,@($item) | ConvertTo-Json) or use -NoEnumerate. Second, on PowerShell 5.1 the default body encoding is Latin-1, which corrupts non-ASCII characters; explicitly pipe through [System.Text.Encoding]::UTF8.GetBytes($body) and pass the bytes as -Body, or upgrade to 7.x where UTF-8 is the default.
Why does my number turn into a long with E+ exponent?
PowerShell parses JSON numbers into the smallest .NET numeric type that fits, which means a 19-digit Twitter snowflake ID or a 64-bit account number ends up as [long] (Int64), and large floats serialize back as scientific notation like 1.23E+18. For IDs that should round-trip as strings, the safest fix is server-side — wrap them in JSON strings to begin with. If you cannot change the producer, parse with -AsHashtable on PowerShell 7.2+ where large integers preserve precision better, or pre-process the JSON text with a regex to wrap candidate numbers in quotes before ConvertFrom-Json. PowerShell 7.2 added improved BigInteger support so values beyond the [long] range no longer lose precision silently — earlier versions could round trillion-plus integers to floats. When precision matters for money or identifiers, use strings in JSON and decimal/string types in PowerShell, never raw numbers.
What's new in PowerShell 7.x JSON support?
PowerShell 7.x (open-source, cross-platform, built on .NET 6+) added several JSON improvements over the Windows-only 5.1 baseline. Key additions: -AsHashtable on ConvertFrom-Json (since 6.0) returns an ordered hashtable for dynamic-key payloads; -NoEnumerate on ConvertTo-Json forces single-element collections to stay as arrays; -EscapeHandling on ConvertTo-Json (since 6.2) controls how non-ASCII characters and HTML-significant characters are escaped; -AsArray on ConvertTo-Json wraps any single object in a JSON array; improved [bigint] preservation in 7.2+ so large integers no longer silently round to floats; and faster serialization throughput overall thanks to System.Text.Json under the hood. The -Depth gotcha (default 2 on writes) survives — that has not changed. If you maintain code that must run on both 5.1 and 7.x, gate the new flags behind a $PSVersionTable.PSVersion.Major check.
Further reading and primary sources
- Microsoft Docs — ConvertFrom-Json — Official cmdlet reference with parameter table and examples
- Microsoft Docs — ConvertTo-Json — Serialization reference including -Depth, -Compress, -EscapeHandling, and -AsArray
- Microsoft Docs — Invoke-RestMethod — REST client cmdlet that auto-parses JSON responses
- PowerShell 7 What's New — Migration notes from 5.1, including JSON improvements like -AsHashtable and System.Text.Json
- System.Text.Json on .NET — The underlying JSON serializer used by PowerShell 7.x — same edge cases apply