Guides · Data for reports
PowerShell: Fail When an API Property Is Null, Empty or Missing
Published · Tested with PowerShell 7.6.6 and Windows PowerShell 5.1 (build 26100) on Windows 11 Pro
A script reads orders from an API and builds a report. One order has no customer, another has null for the total, a third has an empty string. The script finishes, the report has blank cells and zeros, and a real order of 0 looks exactly like an order whose total never arrived. Nothing in PowerShell complains. This guide reproduces that against a small local server, shows what null, an empty string, an empty list and a missing property do in PowerShell 7 and Windows PowerShell 5.1, and ends with a check that names the property and the record and exits with code 1.
Before you start
- The server is yours. Everything runs against a short server on
127.0.0.1that returns eight invented orders. No real API, no account, no key. The code is under Run it. - What the rules say. With strict mode off, “References to non-existent properties return
$null”, and from version 2.0 it “Prohibits references to non-existent properties of an object” (Microsoft Learn, Set-StrictMode). So a property that is missing and a property that isnullread the same, unless you ask which one it is. - How we tested. Every script on this page ran three times in PowerShell 7.6.6 (Microsoft Store package) and three times in Windows PowerShell 5.1.26100.9444, on Windows 11 Pro, with the culture and the UI culture set to
en-USfor the run. The three runs gave the same output in each version. The orders and IDs are invented. - Related. PowerShell JSON to CSV Without Losing Columns covers the columns that
Export-Csvdrops, and Task Scheduler Says Success but Your PowerShell Script Failed covers what happens to the exit code when the script runs unattended.
The report that looks fine
The server returns this list (shortened to the properties that matter; each order also has currency and, except 1008, a lines list):
| id | customer | total | lines |
|---|---|---|---|
| 1001 | "Ana Example" | 120.5 | 1 line |
| 1002 | null | 80 | 1 line |
| 1003 | "" | 35 | 1 line |
| 1004 | not sent | 45 | 1 line |
| 1005 | "Ben Sample" | null | 1 line |
| 1006 | "Cleo Test" | 0 | 1 line |
| 1007 | "Dan Demo" | 60 | [] |
| 1008 | "Eva Sample" | "" | not sent |
Order 1006 is a real zero (a free replacement, say). The script that builds the report is the one most of us would write:
[Threading.Thread]::CurrentThread.CurrentCulture = 'en-US'
$r = Invoke-RestMethod http://127.0.0.1:8765/orders
$rows = foreach ($o in $r.data) { [pscustomobject]@{ Id = $o.id; Customer = $o.customer; Total = [decimal] $o.total; Lines = @($o.lines).Count } }
$rows | Format-Table -AutoSize | Out-String -Width 120
"Sum of Total: " + ($rows | Measure-Object Total -Sum).Sum
$rows | Export-Csv naive.csv -NoTypeInformation
"`$? = $?"
Get-Content naive.csv
In PowerShell 7.6.6:
Id Customer Total Lines
-- -------- ----- -----
1001 Ana Example 120.500 1
1002 80.000 1
1003 35.000 1
1004 45.000 1
1005 Ben Sample 0.000 1
1006 Cleo Test 0.000 1
1007 Dan Demo 60.000 0
1008 Eva Sample 0.000 1
Sum of Total: 340.5
$? = True
"Id","Customer","Total","Lines"
"1001","Ana Example","120.5","1"
"1002",,"80","1"
"1003","","35","1"
"1004",,"45","1"
"1005","Ben Sample","0","1"
"1006","Cleo Test","0","1"
"1007","Dan Demo","60","0"
"1008","Eva Sample","0","1"
Windows PowerShell 5.1 printed the same values (the table columns are narrower) and the same CSV file. What the output shows:
- Nothing failed.
$?isTrueand the script ends with exit code 0. The sum, 340.5, cannot be trusted: it includes two totals that never arrived. - Three different cases look the same. Orders 1005 (
null), 1008 (an empty string) and 1006 (a real zero) all became"0".[decimal] $nulland[decimal] ''are both0in both versions, so the cast turns missing data into a value. - A missing property and a
nullare the same. Orders 1002 and 1004 give the same kind of CSV row,"1002",,"80","1"and"1004",,"45","1": an empty cell. An empty string, order 1003, is the only one that CSV writes differently (""), and nobody reading the cell can see it. - An empty list is a count of 0. Order 1007 has 0 lines and the report says so, as if the order never had any.
Why strict mode is not enough
The usual advice is Set-StrictMode -Version Latest, so that a missing property is an error. It helps with exactly one of the cases. The same loop, with strict mode and $ErrorActionPreference = 'Stop':
[Threading.Thread]::CurrentThread.CurrentCulture = 'en-US'; [Threading.Thread]::CurrentThread.CurrentUICulture = 'en-US'
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$r = Invoke-RestMethod http://127.0.0.1:8765/orders
foreach ($o in $r.data) {
"{0}: customer=[{1}] total=[{2}]" -f $o.id, $o.customer, $o.total
}
1001: customer=[Ana Example] total=[120.5]
1002: customer=[] total=[80]
1003: customer=[] total=[35]
The property 'customer' cannot be found on this object. Verify that the property exists.
Windows PowerShell 5.1 gave the same four lines (the last one inside its longer error block), and both versions ended with exit code 1. Orders 1002 and 1003 went through: null and an empty string are not missing properties, and the null total of order 1005 would have gone through too. The script also stops at the first missing property, so you learn about 1004 and not about 1008. And the protection lives in the code that reads the data: each new property you read is one more place where you have to remember what a missing value would mean.
Four states, and a zero that is a value
The check has to ask separate questions: is the property there, is it null, is it empty. In an object from ConvertFrom-Json or Invoke-RestMethod, $record.PSObject.Properties['customer'] answers the first one: it is $null when the property does not exist, and it is a property whose .Value may be $null when the API sent null. For each required property the check names one of these states:
- absent: the API did not send the property.
null: the API sentnull.- empty: an empty or blank string, or a list with no items. Blank strings (
" ") count as empty here, a choice you may not want. - zero: a number that is 0. For a number this is a value, not a problem: order 1006 passes. An empty string where a number is expected is reported (order 1008), and so is a text such as
"120.5".
The property lines shows why PowerShell hides the difference: a property sent as [] reads back as an empty array in both versions (Object[], count 0). It is not $null ($null -eq $e.lines is False), but if ($e.lines) is false, the same as for null.
The check
Save this as Test-ApiRecord.ps1. Test-ApiRecord checks one record against the list of required properties and their types and returns one line per problem. Test-ApiResponse checks every record and puts the record's id and its position in the list in front of each line:
# Test-ApiRecord.ps1: check that the required properties of an API record are really there.
# Tells absent, null, empty and zero apart. Windows PowerShell 5.1 and PowerShell 7.
# Dot-source it, then call Test-ApiRecord (one record) or Test-ApiResponse (a list of records).
function Get-PropertyState {
# absent | null | empty | zero | value. Works on objects from ConvertFrom-Json and on hashtables (-AsHashtable).
param($Record, [string] $Name)
if ($Record -is [System.Collections.IDictionary]) {
if (-not $Record.Contains($Name)) { return 'absent' }
$value = $Record[$Name]
}
else {
$property = $Record.PSObject.Properties[$Name]
if ($null -eq $property) { return 'absent' }
$value = $property.Value
}
if ($null -eq $value) { return 'null' }
if ($value -is [string]) { if ([string]::IsNullOrWhiteSpace($value)) { return 'empty' } else { return 'value' } }
if ($value -is [System.Collections.IList]) { if ($value.Count -eq 0) { return 'empty' } else { return 'value' } }
if ($value -is [ValueType] -and $value -isnot [bool] -and $value -eq 0) { return 'zero' }
return 'value'
}
function Test-ApiRecord {
# One line per problem: "<property> is absent", "is null", "is empty" or "is <type>, expected <type>".
# A 0 is a value for a number, so it is not a problem; an empty string or empty list never is a value.
param(
$Record,
[Parameter(Mandatory)] [System.Collections.IDictionary] $Required # name = 'string' | 'number' | 'array'
)
foreach ($name in $Required.Keys) {
$state = Get-PropertyState $Record $name
if ($state -in 'absent', 'null', 'empty') { "$name is $state"; continue }
if ($Record -is [System.Collections.IDictionary]) { $value = $Record[$name] } else { $value = $Record.PSObject.Properties[$name].Value }
$ok = switch ($Required[$name]) {
'string' { $value -is [string] -or $value -is [datetime] } # PowerShell 7 reads timestamp strings as dates
'number' { $value -is [ValueType] -and $value -isnot [bool] -and $value -isnot [datetime] }
'array' { $value -is [System.Collections.IList] }
}
if (-not $ok) { "$name is $($value.GetType().Name), expected $($Required[$name])" }
}
}
function Test-ApiResponse {
# Checks every record. Returns the problems as "record <id> (item n of N): <problem>".
param(
[Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Records,
[Parameter(Mandatory)] [System.Collections.IDictionary] $Required,
[string] $KeyName = 'id'
)
for ($i = 0; $i -lt $Records.Count; $i++) {
$key = if ((Get-PropertyState $Records[$i] $KeyName) -in 'value', 'zero') { "$KeyName=" + $(if ($Records[$i] -is [System.Collections.IDictionary]) { $Records[$i][$KeyName] } else { $Records[$i].$KeyName }) } else { "(no $KeyName)" }
foreach ($problem in Test-ApiRecord $Records[$i] $Required) {
"record $key (item $($i + 1) of $($Records.Count)): $problem"
}
}
}
The caller that reads the API and decides the exit code is a second file, check-orders.ps1. It uses three exit codes: 0 when every record is valid, 1 when some record is not, and 2 when the response cannot be used at all (HTTP error, no connection, or no data list):
# check-orders.ps1: read the orders from the API and stop when a required property is absent, null or empty.
# Exit codes: 0 all records valid, 1 invalid records, 2 the response cannot be used.
param([string] $Url = 'http://127.0.0.1:8765/orders')
. "$PSScriptRoot\Test-ApiRecord.ps1"
$required = [ordered]@{ id = 'number'; customer = 'string'; total = 'number'; currency = 'string'; lines = 'array' }
try { $response = Invoke-RestMethod $Url -TimeoutSec 20 }
catch { Write-Error "Could not read ${Url}: $($_.Exception.Message)"; exit 2 }
if ($response -isnot [pscustomobject] -or $null -eq $response.PSObject.Properties['data'] -or $response.data -isnot [System.Collections.IList]) {
Write-Error "The response from $Url has no 'data' list."; exit 2
}
$records = @($response.data)
if ($records.Count -eq 0) { Write-Error "The response from $Url has 0 records."; exit 1 }
$problems = @(Test-ApiResponse $records $required)
if ($problems.Count -gt 0) {
$problems | Select-Object -First 20 | ForEach-Object { Write-Host $_ }
Write-Error "$($problems.Count) problem(s) in $($records.Count) records."; exit 1
}
"OK: $($records.Count) records, all required properties present"
exit 0
- It asks the object, not the value. The state comes from what the object has (
PSObject.Properties, orContainsfor a hash table), and that is the only way to separate absent fromnull. - The required properties are an ordered list.
[ordered]@{ … }makes the problems come out in the same order every run. - No records is a failure.
Test-ApiResponseon an empty list returns nothing, which looks like success;check-orders.ps1treats 0 records as exit code 1, because an empty page is one more way for a report to look fine. - It stops before it writes. The script only reads and checks. Put your
Export-Csvor your upload after it, so a run with a problem writes nothing.
Run it
The test server, server.ps1. Start it in a second window with pwsh -File server.ps1. It serves the list above on /orders and two clean orders (1001 and 1006) on /orders-ok:
# Local test server: GET /orders returns eight invented orders with the defects; GET /orders-ok returns two clean ones.
param([int] $Port = 8765)
$bad = @'
{"data":[
{"id":1001,"customer":"Ana Example","total":120.5,"currency":"EUR","lines":[{"sku":"A-100","qty":2}]},
{"id":1002,"customer":null,"total":80,"currency":"EUR","lines":[{"sku":"B-200","qty":1}]},
{"id":1003,"customer":"","total":35,"currency":"EUR","lines":[{"sku":"A-100","qty":1}]},
{"id":1004,"total":45,"currency":"EUR","lines":[{"sku":"C-300","qty":3}]},
{"id":1005,"customer":"Ben Sample","total":null,"currency":"EUR","lines":[{"sku":"B-200","qty":1}]},
{"id":1006,"customer":"Cleo Test","total":0,"currency":"EUR","lines":[{"sku":"A-100","qty":1}]},
{"id":1007,"customer":"Dan Demo","total":60,"currency":"EUR","lines":[]},
{"id":1008,"customer":"Eva Sample","total":"","currency":"EUR"}
]}
'@
$ok = '{"data":[{"id":1001,"customer":"Ana Example","total":120.5,"currency":"EUR","lines":[{"sku":"A-100","qty":2}]},{"id":1006,"customer":"Cleo Test","total":0,"currency":"EUR","lines":[{"sku":"A-100","qty":1}]}]}'
$l = [System.Net.HttpListener]::new()
$l.Prefixes.Add("http://127.0.0.1:$Port/")
$l.Start()
"listening $Port"
while ($l.IsListening) {
$c = $l.GetContext()
$body = switch ($c.Request.Url.AbsolutePath) { '/orders' { $bad } '/orders-ok' { $ok } default { $null } }
if ($null -eq $body) { $c.Response.StatusCode = 404; $body = '{"error":"not found"}' }
$bytes = [Text.Encoding]::UTF8.GetBytes($body)
$c.Response.ContentType = 'application/json; charset=utf-8'
$c.Response.OutputStream.Write($bytes, 0, $bytes.Length)
$c.Response.Close()
}
The check against /orders, with the same lines in PowerShell 7.6.6 and in Windows PowerShell 5.1 (the final error line is longer in 5.1, which adds the script path and a category), and exit code 1 in both:
record id=1002 (item 2 of 8): customer is null
record id=1003 (item 3 of 8): customer is empty
record id=1004 (item 4 of 8): customer is absent
record id=1005 (item 5 of 8): total is null
record id=1007 (item 7 of 8): lines is empty
record id=1008 (item 8 of 8): total is empty
record id=1008 (item 8 of 8): lines is absent
Write-Error: 7 problem(s) in 8 records.
Seven problems in eight records. Orders 1001 and 1006 are not in the list: the zero is a value. Against /orders-ok the script printed OK: 2 records, all required properties present and exited with 0. Against a path the server does not have (/missing), it exited with 2; PowerShell 7 said Could not read http://127.0.0.1:8765/missing: Response status code does not indicate success: 404 (Not Found)., and Windows PowerShell 5.1 gave the same exit code with its own message text. With nothing listening on the port, the exit code was 2 too. In a scheduled job, let that code reach the task; the Task Scheduler guide linked above shows how.
Other shapes, and hash tables
A second list, run through Test-ApiResponse with customer, total and lines required, shows what the type check adds. In order 1 the customer is " " and the total is the text "120.5"; in order 2 the customer is 0, the total is true and lines is an object; orders 3 and 4 have a total of 0 and 0.0:
record id=1 (item 1 of 4): customer is empty
record id=1 (item 1 of 4): total is String, expected number
record id=2 (item 2 of 4): customer is Int64, expected string
record id=2 (item 2 of 4): total is Boolean, expected number
record id=2 (item 2 of 4): lines is PSCustomObject, expected array
Windows PowerShell 5.1 gave the same lines except for one type name: Int32 for the number 0 where PowerShell 7 said Int64. Orders 3 and 4 passed in both: a number that is 0 is a value.
PowerShell 7 can also read the JSON as hash tables. ConvertFrom-Json “Converts the JSON to a hash table object” with -AsHashtable, which “was introduced in PowerShell 6.0” (Microsoft Learn, ConvertFrom-Json), so Windows PowerShell 5.1 does not have it: in our run it stopped with A parameter cannot be found that matches parameter name 'AsHashtable'. With a hash table, $h['zzz'] is $null for a missing key just as for a null value, and $h.ContainsKey('zzz') is the question to ask (False for a missing key, True for a key whose value is null in our test). Test-ApiRecord handles both kinds of record; the hash-table run gave the same problems as above, with lines is OrderedHashtable for the object.
One difference to know about: hash-table keys keep their case (ContainsKey('customer') was False for a key sent as Customer), while the properties of an object from plain ConvertFrom-Json do not (PSObject.Properties['customer'] found Customer). If your API may change the case of a name, the two readers answer differently.
Limits
- One machine. Windows 11 Pro, PowerShell 7.6.6 and Windows PowerShell 5.1.26100.9444. We did not test PowerShell 7.0 to 7.5, Linux or macOS.
- A test server, not a real API. It sends exactly the defects above. We did not test paging, compression, a byte order mark, a response that is not JSON, or a server that answers 200 with an error body (the check exits with 2 only if the
datalist is missing). - Top level only. The check looks at the required properties of each record. It does not look inside nested objects or at the items of
lines. - You write the list of required properties. The check cannot know that
customermatters to your report, and it does not tell you about properties the API added. - Blank counts as empty, and 0 is always a value. A
totalof 0 passes even where your business says an order cannot be free, and a customer of" "fails. That is a rule of this check, not of PowerShell. - Numbers. “Number” means any numeric .NET type that is not
boolordatetime. We did not test numbers that are too large for a JSON number. - Dates. PowerShell 7 reads a string such as
"2026-10-07T08:30:00Z"as a date: “Beginning in PowerShell 6,ConvertTo-Jsonattempts to convert strings formatted as timestamps to DateTime values” (Microsoft Learn, ConvertFrom-Json; the sentence namesConvertTo-Json, but it is on theConvertFrom-Jsonpage and describes reading). Windows PowerShell 5.1 kept the text. The check accepts a date where it requires a string, so both versions give the same result. - It checks the shape, not the truth. A record with every property present and a wrong total passes.
Just want the scripts? The Scripts Pack · €12 has the code of this guide and the other guides ready to run, with examples and tests. The code on this page stays free.
The code on this page was written for this guide and tested on the versions above. The test scripts also set the culture and the UI culture to en-US, so that messages and numbers came out in English. The data is invented.