This guide assumes Powershell 5.1 or 7+ and basic familiarity with @{ ... } and [pscustomobject].

Three containers come up constantly in PowerShell, and most scripts use them as if they were synonyms. The bug that follows looks like this: a script that worked fine in dev produces a CSV with shuffled columns in prod, the test framework that read a config returns properties in the wrong order, a splat that ran cleanly five times in a row mysteriously fails on the sixth. None of those are bugs in Export-Csv, ConvertFrom-Json, or the splat operator. They're the result of picking [hashtable], [ordered], or [pscustomobject] by reflex instead of by intent.

The three containers look interchangeable from the inside (same @{ a = 1 } literal, same dot access, same indexer access) and behave very differently the moment the data crosses a boundary, JSON, CSV, parameter binding, the pipeline. This post is the tour of those boundaries with the concrete pick-this-one rule for each of them.

The Three Types at a Glance

Type Literal Order Property access Splatting JSON round-trip
Hashtable @{ a = 1 } Random $h.a, $h['a'] Yes Loses key order
OrderedDictionary [ordered]@{ a = 1 } Insertion order $h.a, $h['a'] Yes Preserves order
PSCustomObject [pscustomobject]@{ a = 1 } Insertion order $o.a only No Preserves order

That single table is most of what people get wrong.

Hashtable Fast Lookup, No Guarantees About Order

$h = @{ b = 2; a = 1; c = 3 }
$h.Keys                   # could come back in any order

Backed by [System.Collections.Hashtable]. O(1) lookup, mutable, the underlying type for splatting and for the parameters Powershell already passes you ($PSBoundParameters is a hashtable). When iteration order doesn't matter and you want fast key lookup, this is the right type.

Don't use Hashtable for anything that gets serialized to JSON, YAML, or a CSV column header. Keys come out in non-deterministic order.

OrderedDictionary Hashtable That Remembers

$o = [ordered]@{ b = 2; a = 1; c = 3 }
$o.Keys                   # b, a, c - exactly as inserted

Backed by [System.Collections.Specialized.OrderedDictionary]. Same lookup performance as a hashtable plus deterministic enumeration. Splats just like a hashtable. The right default for any data you're going to write out.

$o['d'] = 4               # new keys go at the end
$o.Insert(0, 'first', 0)  # explicit position

When in doubt between @{} and [ordered]@{}, pick [ordered]@{}. The cost is negligible and the determinism is worth it for anything you'll diff.

PSCustomObject A Real Object With Real Properties

$obj = [pscustomobject]@{ Name = 'alice'; Id = 1 }
$obj.Name                 # alice
$obj.GetType().Name       # PSCustomObject

This is the one most people underuse. PSCustomObject is the type Powershell formats nicely (Format-Table columns, Sort-Object keys, Where-Object property filters). Calculated properties on Select-Object produce them. JSON deserialization produces them.

What you give up:

  • No splatting. You can't pass it to a cmdlet as @params.
  • No mutation by index. $obj['Name'] doesn't work; only dotted access.

What you get:

  • Pipeline-friendly. Property names become column headers automatically.
  • Type-stable across the pipeline. Select-Object, Sort-Object, Where-Object all work cleanly.
  • JSON round-trip safe with order preserved.

Use [pscustomobject] for output anything that flows down the pipeline, lands in CSV, gets serialized, or shows up to the user. Use [ordered]@{} for input to other cmdlets and for splatting.

Conversions

# Hashtable → PSCustomObject
$o = [pscustomobject]$h

# PSCustomObject → Hashtable (all properties)
$h = @{}
$obj.PSObject.Properties | ForEach-Object { $h[$_.Name] = $_.Value }

# OrderedDictionary → PSCustomObject (preserves order)
$obj = [pscustomobject]([ordered]@{ a = 1; b = 2 })

Powershell 7 added -AsHashtable to ConvertFrom-Json:

$json | ConvertFrom-Json -AsHashtable        # hashtable, faster, mutable
$json | ConvertFrom-Json                     # PSCustomObject, default

-AsHashtable is significantly faster on large JSON because it skips wrapping every node in a PSObject. Use it when you're going to walk the structure and not output it.

The JSON Round-Trip Surprise

# Hashtable order is random
@{ b = 2; a = 1 } | ConvertTo-Json
# could be {"a":1,"b":2} or {"b":2,"a":1} on different runs
# OrderedDictionary preserves order
[ordered]@{ b = 2; a = 1 } | ConvertTo-Json
# {"b":2,"a":1}
# PSCustomObject also preserves order
[pscustomobject]@{ b = 2; a = 1 } | ConvertTo-Json
# {"b":2,"a":1}

If you've ever wondered why a config file you write from Powershell randomly reshuffles its keys, this is why. Switch the source to [ordered]@{} or [pscustomobject] and the diff stays clean.

Type Coercion Gotchas

$h = @{ name = 'alice' }
$h.NAME              # 'alice' - case-insensitive lookup
$h['NAME']           # 'alice' - same

$obj = [pscustomobject]@{ name = 'alice' }
$obj.NAME            # 'alice' - also case-insensitive
$obj['NAME']         # ERROR - PSCustomObject doesn't index by string

The case-insensitivity surprises people coming from Python or JavaScript. Trying to round-trip "case matters" data through any of these three types loses the casing. If casing matters, use [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal).

Splatting Reminder

Only hashtables and ordered dictionaries splat. PSCustomObject doesn't:

$params = [ordered]@{ Path = 'C:\temp'; Recurse = $true }
Get-ChildItem @params                        # works

$obj = [pscustomobject]$params
Get-ChildItem @obj                           # ERROR

If you find yourself building a PSCustomObject and then trying to splat it, you wanted an ordered dictionary all along.

A Decision Table

Need Use
Splatting parameters Hashtable or [ordered]@{}
Cheap mutable lookup, order doesn't matter Hashtable
Cheap mutable lookup, order matters (config, JSON output) [ordered]@{}
Output to the pipeline [pscustomobject]
Fields that need to be column headers [pscustomobject]
Round-trip through ConvertTo-Json and back [pscustomobject] or [ordered]@{}
You'll walk the structure but never output it Hashtable (with -AsHashtable)

A Common Anti-Pattern

# Build with a Hashtable, output the same Hashtable
$rows = foreach ($u in $users) {
    @{ Name = $u.Name; Group = $u.Group }    # WRONG
}
$rows | Format-Table                         # ugly default formatting
$rows | Export-Csv ./out.csv -NoTypeInformation  # one column called 'Value'

The fix is one character [pscustomobject]@{...} instead of @{...}:

$rows = foreach ($u in $users) 
{
    [pscustomobject]@{ Name = $u.Name; Group = $u.Group }
}
$rows | Format-Table                         # nice columns
$rows | Export-Csv ./out.csv -NoTypeInformation  # Name,Group

The Decision in One Line

If you remember nothing else from this post, remember this rule:

Splat with @{}. Read config with [ordered]@{}. Emit anything with [pscustomobject]@{}.

That single rule prevents most of the bugs in this category. The three exceptions, in order of how often they bite:

  1. A [hashtable] returned from ConvertFrom-Json (or ConvertFrom-Yaml) is [ordered] if you pass -AsHashtable but only because the parser preserved the key order. Lose that flag and you get PSCustomObject, which is also order-preserving but harder to mutate. Pick before you parse.
  2. Export-Csv against hashtables emits Key,Value columns, not your-keys-as-headers. Cast to [pscustomobject] first.
  3. Splatting an [ordered] works, but the engine internally rebuilds it to a hashtable on bind. If you've added [ordered] "for safety" everywhere, you're paying that cost on every splat call.

What to Do Tomorrow Morning

Open the most-edited PowerShell script in your team's automation repo and grep it for @{. For each match:

  • Is it a splat target? Leave it as a hashtable.
  • Is it being passed to Export-Csv, ConvertTo-Json, or anything that produces output for a human or another tool? Cast to [pscustomobject] at construction.
  • Is it a config blob you're loading from disk and round-tripping? Use [ordered] so the round-trip is stable.

Fixing this one type of bug at the source is dramatically cheaper than fixing the symptoms (header re-mapping, custom JSON serialisers, alphabetised configs) downstream. The next post in this thread, calculated properties with Select-Object, is the natural follow-up: once you're emitting PSCustomObject deliberately, the next question is how to shape it without an intermediate variable.