Creating Active Directory Users with PowerShell: Safe CSV Import Pattern
Last reviewed: June 2026
Scope
Creating users from a CSV is a five-line script. Doing it safely, idempotently, and reversibly in production is a tool. This article builds that tool: a two-stage onboarding pipeline that validates the import before it touches the directory, previews exactly what it will do, creates accounts idempotently with secure passwords, captures rollback evidence, and ships with Pester tests. The same engineering patterns from Production-Ready AD Automation apply here — advanced functions, ShouldProcess, fail-closed error handling, structured logging — pointed at onboarding instead of cleanup.
The risk is the input file, not the cmdlet
New-ADUser is not dangerous. Trusting a CSV is. The batches that cause incidents are mundane: a trailing blank row that creates a malformed account, a Department column that routes new starters into the wrong OU so they inherit the wrong GPOs, a duplicate SamAccountName that fails halfway and leaves the batch half-applied, or a UPN suffix that doesn't match a verified domain so Microsoft Entra ID sync rejects the accounts the next morning. Every one of those is invisible in a happy-path run and obvious in a validation pass. So the tool is built around a hard rule: validate everything, change nothing — then create only what validation approved.
That split — a read-only Test-AdUserImport and a state-changing New-AdUserFromImport — is what makes the onboarding reviewable. A change reviewer runs the validation stage, reads the plan, and only then pipes the approved rows into creation.
Stage 1: validate the import (read-only)
function Test-AdUserImport {
<#
.SYNOPSIS
Validates an onboarding CSV against AD and returns a per-row plan. Makes no changes.
.EXAMPLE
Test-AdUserImport -CsvPath .\new-hires.csv -DefaultUpnSuffix contoso.com | Format-Table
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[ValidateScript({ Test-Path $_ -PathType Leaf })]
[string]$CsvPath,
[Parameter(Mandatory)]
[string]$DefaultUpnSuffix,
[string]$Server
)
$rows = Import-Csv -Path $CsvPath
if (-not $rows) { throw "CSV '$CsvPath' contained no rows." }
$required = 'GivenName', 'Surname', 'SamAccountName', 'TargetOu'
$missing = $required | Where-Object { $_ -notin $rows[0].PSObject.Properties.Name }
if ($missing) { throw "CSV is missing required column(s): $($missing -join ', ')" }
$common = @{}
if ($PSBoundParameters.ContainsKey('Server')) { $common.Server = $Server }
$seen = @{}
foreach ($row in $rows) {
$sam = "$($row.SamAccountName)".Trim()
$upn = if ($row.UserPrincipalName) { "$($row.UserPrincipalName)".Trim() } else { "$sam@$DefaultUpnSuffix" }
$issues = [System.Collections.Generic.List[string]]::new()
if ([string]::IsNullOrWhiteSpace($sam)) { $issues.Add('Blank SamAccountName') }
if ($sam.Length -gt 20) { $issues.Add('SamAccountName exceeds 20 characters') }
if ($seen.ContainsKey($sam)) { $issues.Add('Duplicate SamAccountName within the file') } else { $seen[$sam] = $true }
if (-not (Get-ADOrganizationalUnit -Identity $row.TargetOu @common -ErrorAction SilentlyContinue)) {
$issues.Add("Target OU not found: $($row.TargetOu)")
}
$exists = [bool](Get-ADUser -Filter "SamAccountName -eq '$sam'" @common -ErrorAction SilentlyContinue)
$plan =
if ($exists) { 'Skip-Exists' }
elseif ($issues.Count) { 'Blocked' }
else { 'Create' }
[pscustomobject]@{
SamAccountName = $sam
UserPrincipalName = $upn
GivenName = $row.GivenName
Surname = $row.Surname
TargetOu = $row.TargetOu
Groups = if ($row.Groups) { ($row.Groups -split ';').Trim() } else { @() }
Plan = $plan
Issues = $issues -join '; '
}
}
}
The function never calls a change cmdlet. It checks the CSV schema, confirms each target OU exists, detects duplicates both within the file and against the directory, and assigns every row a Plan: Create, Skip-Exists (the account is already there — this is what makes re-running safe), or Blocked (something is wrong, with the reason in Issues). The output is a table a human can read and approve before anything happens.
Stage 2: create the approved rows (state-changing, gated)
This stage consumes the plan from the pipeline and acts only on Create rows. It generates a secure password, creates the account idempotently, adds group memberships, logs every action, and writes a rollback file of exactly what it created.
function New-RandomPassword {
[OutputType([securestring])]
param([ValidateRange(12, 128)][int]$Length = 20)
$bytes = [byte[]]::new($Length)
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
$alphabet = [char[]](48..57 + 65..90 + 97..122 + @(33, 35, 36, 37, 38, 42, 64))
$plain = -join ($bytes | ForEach-Object { $alphabet[$_ % $alphabet.Length] })
ConvertTo-SecureString -String $plain -AsPlainText -Force
}
function New-AdUserFromImport {
<#
.SYNOPSIS
Creates approved users from a validated import plan, with rollback and logging.
.EXAMPLE
Test-AdUserImport -CsvPath .\new-hires.csv -DefaultUpnSuffix contoso.com |
New-AdUserFromImport -RollbackPath .\rb.csv -LogPath .\log.csv -WhatIf
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[pscustomobject]$ImportRow,
[Parameter(Mandatory)]
[string]$RollbackPath,
[Parameter(Mandatory)]
[string]$LogPath,
[string]$Server
)
begin {
$common = @{}
if ($PSBoundParameters.ContainsKey('Server')) { $common.Server = $Server }
$created = [System.Collections.Generic.List[object]]::new()
}
process {
if ($ImportRow.Plan -ne 'Create') {
Write-OperationLog -Action 'Create' -Target $ImportRow.SamAccountName -Result 'Skipped' -Detail $ImportRow.Plan -LogPath $LogPath
return
}
try {
if ($PSCmdlet.ShouldProcess($ImportRow.SamAccountName, 'Create Active Directory user')) {
$attrs = @{
Name = "$($ImportRow.GivenName) $($ImportRow.Surname)"
GivenName = $ImportRow.GivenName
Surname = $ImportRow.Surname
SamAccountName = $ImportRow.SamAccountName
UserPrincipalName = $ImportRow.UserPrincipalName
Path = $ImportRow.TargetOu
AccountPassword = New-RandomPassword
Enabled = $true
ChangePasswordAtLogon = $true
}
New-ADUser @attrs @common -ErrorAction Stop
foreach ($group in $ImportRow.Groups) {
Add-ADGroupMember -Identity $group -Members $ImportRow.SamAccountName @common -ErrorAction Stop
}
$created.Add([pscustomobject]@{
SamAccountName = $ImportRow.SamAccountName
UserPrincipalName = $ImportRow.UserPrincipalName
TargetOu = $ImportRow.TargetOu
CreatedAt = (Get-Date).ToString('o')
})
Write-OperationLog -Action 'Create' -Target $ImportRow.SamAccountName -Result 'Success' -LogPath $LogPath
}
else {
Write-OperationLog -Action 'Create' -Target $ImportRow.SamAccountName -Result 'WhatIf' -LogPath $LogPath
}
}
catch {
Write-OperationLog -Action 'Create' -Target $ImportRow.SamAccountName -Result 'Failure' -Detail $_.Exception.Message -LogPath $LogPath
Write-Error "Failed to create $($ImportRow.SamAccountName): $($_.Exception.Message)"
}
}
end {
if ($created.Count) {
$created | Export-Csv -Path $RollbackPath -NoTypeInformation -Encoding UTF8
}
}
}
Write-OperationLog is the same structured-logging helper from the AD automation article — one object-and-CSV record per action. The idempotency is the quiet but important property: because Stage 1 marks already-existing accounts as Skip-Exists and Stage 2 acts only on Create, you can run the same batch twice — after a partial failure, say — and it will create only what is missing, never duplicate or error on what already exists. The rollback file lists exactly the accounts this run created, so reversing a bad batch is a precise operation, not a guess.
Passwords: never in the CSV, never in the log
This is the security decision that matters most, and the one most onboarding scripts get wrong. The password is generated as a SecureString at the moment of creation, with ChangePasswordAtLogon set so the temporary value is replaced on first sign-in. It is never read from the CSV and never written to the log or the rollback file. Working under GDPR makes the reasoning concrete: a CSV of usernames and temporary passwords sitting in a shared folder is exactly the artifact that turns an onboarding task into a data-protection incident. If the temporary password must be delivered to a human, do it through a channel your security owner has approved, treat any password-bearing output as sensitive data with a short retention and a named owner, and never email the file. (Production note: confirm the generated password satisfies your domain's complexity policy, or use a generator that guarantees the required character categories.)
A CSV schema that survives contact with HR
GivenName,Surname,SamAccountName,UserPrincipalName,TargetOu,Groups
Alex,Example,aexample,alex.example@contoso.com,"OU=Staff,DC=contoso,DC=com","GG-IT-Users;GG-M365-E3"
UserPrincipalName is optional — Stage 1 builds one from the SamAccountName and the default suffix if it is blank — and Groups is a semicolon-separated list. Everything else is required, and Stage 1 rejects the file if a required column is missing.
Prove it with Pester
Because validation and creation are separate and the AD cmdlets are mockable, the behaviour that matters is testable without a directory.
BeforeAll {
. $PSScriptRoot\AdOnboarding.ps1
}
Describe 'Test-AdUserImport' {
BeforeAll {
Mock Get-ADOrganizationalUnit { $true } # OU exists
Mock Get-ADUser { $null } # nobody exists yet
$csv = Join-Path $TestDrive 'in.csv'
@'
GivenName,Surname,SamAccountName,TargetOu
Alex,Example,aexample,OU=Staff,DC=contoso,DC=com
Sam,Sample,aexample,OU=Staff,DC=contoso,DC=com
'@ | Set-Content $csv
}
It 'plans a clean new row as Create' {
(Test-AdUserImport -CsvPath $csv -DefaultUpnSuffix contoso.com)[0].Plan | Should -Be 'Create'
}
It 'blocks an in-file duplicate SamAccountName' {
(Test-AdUserImport -CsvPath $csv -DefaultUpnSuffix contoso.com)[1].Plan | Should -Be 'Blocked'
}
It 'marks an account that already exists as Skip-Exists (idempotent)' {
Mock Get-ADUser { [pscustomobject]@{ SamAccountName = 'aexample' } }
(Test-AdUserImport -CsvPath $csv -DefaultUpnSuffix contoso.com)[0].Plan | Should -Be 'Skip-Exists'
}
}
Describe 'New-AdUserFromImport' {
BeforeAll {
Mock New-ADUser {}
Mock Add-ADGroupMember {}
Mock Export-Csv {}
}
BeforeEach {
$script:create = [pscustomobject]@{ SamAccountName = 'aexample'; UserPrincipalName = 'aexample@contoso.com'; GivenName = 'Alex'; Surname = 'Example'; TargetOu = 'OU=Staff,DC=contoso,DC=com'; Groups = @(); Plan = 'Create' }
}
It 'creates nothing under -WhatIf' {
$script:create | New-AdUserFromImport -RollbackPath 'TestDrive:\rb.csv' -LogPath 'TestDrive:\log.csv' -WhatIf
Should -Invoke New-ADUser -Times 0
}
It 'creates a Create-planned row when confirmed' {
$script:create | New-AdUserFromImport -RollbackPath 'TestDrive:\rb.csv' -LogPath 'TestDrive:\log.csv' -Confirm:$false
Should -Invoke New-ADUser -Times 1
}
It 'skips a non-Create row without calling New-ADUser' {
$script:create.Plan = 'Blocked'
$script:create | New-AdUserFromImport -RollbackPath 'TestDrive:\rb.csv' -LogPath 'TestDrive:\log.csv' -Confirm:$false
Should -Invoke New-ADUser -Times 0
}
}
The duplicate, idempotency, and -WhatIf tests encode the three properties that keep onboarding safe: the same file can't create the same account twice, an account that already exists is left alone, and a preview run changes nothing.
Production checklist
- Run Stage 1 and have a human approve the plan before Stage 2.
- Confirm the CSV source is trusted and the columns are validated.
- Keep creation idempotent so a re-run after a partial failure is safe.
- Generate passwords as
SecureString; never write them to the CSV or log. - Capture a rollback file of exactly what was created.
- Log every action — success, skip,
-WhatIf, and failure — as structured data. - Run the Pester tests in CI on every change to the module.
- Execute under a least-privilege account with rights scoped to the target OUs.
Final recommendation
Treat CSV onboarding as a controlled, idempotent, two-stage process, not a single Import-Csv | New-ADUser pipeline. Validate first and change nothing; create only what validation approved; keep passwords out of files; and capture rollback evidence as you go. If the tool can't produce a readable plan, run twice without harm, and pass a test proving -WhatIf makes no change, it isn't ready for an onboarding batch — and onboarding batches are exactly when everyone is in a hurry.