Skip to content
Shell & TerminalDeep Dive Published Updated 7 min readViews unavailable

PowerShell JSON Round Trips: Depth, Arrays, and Type Boundaries

Make PowerShell JSON serialization predictable by controlling depth and array shape, validating schemas, and accounting for conversion limits.

JSON is a text interchange format, not a transparent serialization of every PowerShell or .NET object. ConvertTo-Json selects object properties and turns them into JSON values; ConvertFrom-Json reconstructs PowerShell objects from that text. Methods, runtime identity, custom type metadata, and some collection distinctions do not survive the round trip automatically.

This matters in configuration pipelines, REST clients, build systems, and test fixtures. A JSON document that looks plausible in the console can still have a missing nested object because the default serialization depth was too shallow. A single-element array can be confused with a scalar by a caller that enumerates pipeline output. A .NET object may expose more properties than the external schema should contain. Treat the conversion as an API boundary and specify the schema you intend to publish.

Build a transport object instead of serializing everything

Passing a large runtime object directly to ConvertTo-Json can leak implementation details or produce unstable output. Prefer constructing a small hashtable or [pscustomobject] that contains only the fields the external contract requires.

$transport = [pscustomobject]@{
    schemaVersion = 1
    service       = 'catalog'
    generatedAt   = (Get-Date).ToUniversalTime().ToString('o')
    checks        = @(
        [pscustomobject]@{ name = 'database'; passed = $true }
        [pscustomobject]@{ name = 'queue'; passed = $false }
    )
}

$json = $transport | ConvertTo-Json -Depth 5

The Depth parameter controls how many levels of nested objects are represented; the default is 2. PowerShell 7.1 and later warn when input nesting exceeds the chosen depth. Do not respond by always using the maximum of 100: a large depth can make unintended object graphs expensive or expose more data than the contract requires. Set depth based on the schema, and inspect the resulting JSON in tests.

$json = $transport | ConvertTo-Json -Depth 5
$roundTrip = $json | ConvertFrom-Json

if ($roundTrip.schemaVersion -ne 1 -or $roundTrip.checks.Count -ne 2) {
    throw 'Serialized payload does not satisfy the expected contract.'
}

This assertion checks a small portion of the contract; production code should validate required fields, field types, allowed values, nullability, and compatibility rules. PowerShell conversion is not a JSON Schema validator. If the receiving API publishes a schema, validate against that schema using a dedicated validator or API test.

Arrays and pipeline enumeration need deliberate tests

PowerShell functions emit objects to the success stream, and pipelines enumerate collections. A one-element array can therefore appear to the caller as one scalar object unless the function or caller preserves the container deliberately. JSON arrays are different from “multiple PowerShell pipeline outputs,” even though they can be rendered similarly in a console.

ConvertTo-Json -AsArray wraps even a single input object in JSON array brackets. This is useful when the receiver’s contract requires an array regardless of count. Do not infer whether the JSON root is an object or array from PowerShell’s formatting; parse the serialized text or inspect it as JSON.

$oneRecord = [pscustomobject]@{ id = 'A-17' }
$jsonArray = ConvertTo-Json -InputObject $oneRecord -AsArray -Depth 3

# The serialized document has an array root even with one member.
$jsonArray

On deserialization, ConvertFrom-Json -NoEnumerate can preserve a single-element array in situations where pipeline output would otherwise unwrap it. -AsHashtable returns a hashtable representation, and recent versions provide an ordered hashtable implementation with case-sensitive key distinctions. Choose the object shape deliberately; do not assume all older Windows PowerShell versions expose the same parameter set as the latest PowerShell 7 release.

$document = '[{"id":"A-17"}]'
$records = ConvertFrom-Json -InputObject $document -NoEnumerate

if ($records -isnot [array]) {
    throw 'The service contract requires an array root.'
}

For test cases, cover zero, one, and multiple array members. Also include nested arrays, empty arrays, $null, empty strings, numeric-looking strings, booleans, and property names that differ only by letter casing if the downstream system can produce them. JSON allows duplicate object member names, but PowerShell’s object and hashtable representations cannot preserve duplicate keys reliably; the documented conversion behavior uses the last such key. Reject or normalize duplicate-key documents at a boundary where they matter.

Dates, numbers, and runtime types

JSON has strings, numbers, booleans, null, arrays, and objects; it does not have a native DateTime, Guid, decimal scale, enum, or arbitrary .NET object type. Decide how to represent dates and identifiers. An ISO 8601 string with an explicit UTC marker is a common interoperable choice, but the receiver must agree on that convention. Do not rely on locale-formatted dates such as 10/03/2026, which can be interpreted differently by different systems.

PowerShell 7.2 stopped serializing Extended Type System properties on DateTime and String instances; only the simple object is serialized. PowerShell 7.5 can serialize BigInteger values as raw JSON numbers. These version changes are reasons to test the actual supported engine matrix and not assume an older PowerShell host produces identical JSON text. JSON object property ordering may also be observed by downstream systems even though consumers should generally treat an object as a name/value mapping.

Use explicit conversion where representation is a business rule:

$wireRecord = [ordered]@{
    id         = [string]$record.Id
    createdUtc = $record.CreatedAt.ToUniversalTime().ToString('o')
    amount     = [decimal]$record.Amount
    enabled    = [bool]$record.Enabled
}

$json = ConvertTo-Json -InputObject $wireRecord -Depth 4 -Compress

-Compress removes formatting whitespace; it does not change the JSON data model or make a payload more valid. Keep pretty output in diagnostics unless the API requires compact text. Avoid logging payloads that may contain personal information, access tokens, or secret values.

Read and write files with explicit encoding and failure policy

When a JSON document comes from disk, use Get-Content -Raw so the parser receives one complete string instead of a stream of lines. Use Set-Content with a deliberate encoding supported by your PowerShell version, and write to a temporary path before replacing an existing configuration file if partial writes would be harmful.

$path = './settings.json'
$text = Get-Content -LiteralPath $path -Raw -ErrorAction Stop
$settings = ConvertFrom-Json -InputObject $text -ErrorAction Stop

if (-not $settings.schemaVersion) {
    throw "Unsupported or incomplete configuration: $path"
}

For a configuration update, validate the candidate object and serialized text before publication. Use a same-filesystem temporary file and an atomic replacement mechanism appropriate to the operating system when crash consistency matters. Do not treat successful parsing as schema validation: {} is valid JSON but may be an invalid application configuration.

Debug the boundary, not only the PowerShell object

When a remote API rejects a payload, inspect the serialized JSON text after removing secrets. Compare its root type, property casing, null behavior, array shape, date strings, and nesting depth with the API contract. Save a minimal failing fixture and test both serialization and deserialization with the oldest and newest supported PowerShell engines.

Keep the typed source object and its wire representation separate in code. That helps reviewers identify which values are intentionally omitted, normalized, or renamed. A stable schema version can support controlled migrations when fields change. The JSON document is an interface that another system will depend on; the object in your local runspace is merely one possible implementation of that interface.

Avoid accidental conversion and precision changes

JSON numbers are not tagged with a .NET numeric type. A reader may choose an integer, floating-point, decimal, or arbitrary-precision representation according to its own rules. If the domain depends on exact decimal currency, do not assume a binary floating-point number round-trips exactly across every consumer. Define the representation in the API contract, consider decimal strings when required for exactness, and test values at the supported bounds. Similarly, an identifier that looks numeric may need to remain a string so leading zeroes are not discarded.

Do not confuse PowerShell’s display formatting with JSON serialization. Format-Table creates display-oriented format data; ConvertTo-Json serializes the objects it actually receives. Inspect the input type with Get-Member, build an explicit transport object, and parse the resulting JSON with an independent validator when interoperability is important. A few representative fixtures should cover minimum and maximum values, Unicode text, embedded quotes, backslashes, and newlines.

When calling a REST API, let the API’s documented content type and encoding determine how bytes are sent. ConvertTo-Json returns text; the HTTP client is responsible for turning that text into a request body with an appropriate content type. A valid JSON string accidentally sent as form data, text/plain, or with a mismatched character encoding is still a broken request. Verify the actual request headers and body with a safe test endpoint or mocked server, and avoid recording authorization headers or sensitive payloads in diagnostic transcripts.

Serialization tests should be semantic where possible: parse the output and compare the resulting values and types against the intended contract. Exact textual comparison is justified only when canonical property ordering or whitespace is a documented requirement. Otherwise, a harmless formatting change can make a test noisy while a missing nested property goes unnoticed.

Related:

Sources:

Comments