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
Hashtablefor 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-Objectall 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:
- A
[hashtable]returned fromConvertFrom-Json(orConvertFrom-Yaml) is[ordered]if you pass-AsHashtablebut only because the parser preserved the key order. Lose that flag and you getPSCustomObject, which is also order-preserving but harder to mutate. Pick before you parse. Export-Csvagainst hashtables emitsKey,Valuecolumns, not your-keys-as-headers. Cast to[pscustomobject]first.- 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.


