Bulk Active Directory User Creation From CSV
A script that turns a spreadsheet of new starters into AD accounts only earns its keep if it tells you exactly which rows failed and why, instead of stopping cold on row 40 of 200 or silently skipping the ones that went wrong.
New-ADUser piped from Import-Csv handles the happy path in one line, but a bulk import that will actually run unattended against 50 or 500 rows needs per-row try/catch, duplicate detection before it ever calls New-ADUser, and a result object for every row so a partial failure never means guessing which accounts actually got created.
The AD module cmdlet that creates the account; -SamAccountName is effectively required.
Turns each CSV row into an object whose column headers become property names.
Lets you preview every account the script would create before it touches the directory.
What This Script Is For
Onboarding in bulk — a new site opening, a seasonal intake, a merger dumping a spreadsheet of new employees onto your desk — turns single-user account creation into something that has to run unattended against dozens or hundreds of rows. New-ADUser handles one account cleanly. The problem is what happens on row 40 when a SamAccountName is already taken, or the target OU string has a typo, or the CSV has a blank required field.
Think of the script as a foreman, not a machine: it does not just push every row through New-ADUser and hope. It checks each row before committing to it, records what happened whether that row succeeded or failed, and keeps going instead of stopping the whole batch because of one bad row.
Why Bother With Per-Row Error Handling?
The naive version of this script is a five-line foreach loop piping straight into New-ADUser. It works exactly once — on a CSV with no mistakes in it. Real onboarding spreadsheets always have at least one: a duplicate name, a missing OU, a password that fails the domain’s complexity policy.
A bare loop with no try/catch can abort mid-batch, leaving you unsure which rows actually ran.
Without a result object per row, there is nothing to hand to the requester showing what was created and what wasn’t.
Re-running a partially failed batch without checking for existing accounts first risks errors on every row that did succeed.
Why Active Directory Cares
New-ADUser requires SamAccountName in practice, even though the parameter itself is not flagged mandatory — omit it and the cmdlet fails outright. Get it wrong in a way that collides with an existing account, and AD returns an error that a bare loop will either crash on or silently swallow, depending on how carelessly the script was written.
Passwords add a second failure mode. -AccountPassword takes a SecureString, and per Microsoft’s own documentation, an account is created without a password — and left disabled — if no password is supplied or if the password fails the domain’s password policy. That means a script with no error handling can leave a trail of disabled accounts with no explanation of why, unless it checks and reports on that outcome explicitly.
Password column blank, so a plaintext password only ever has to exist for a moment in memory, not sit in a spreadsheet on a file share.
The CSV Format
The script expects one row per user with these columns. Password is optional — leave it blank to have the script generate one.
FirstName,LastName,SamAccountName,OUPath,Department,Title,EmailAddress,Password
Jane,Doe,jdoe,"OU=Sales,OU=Users,DC=corp,DC=example,DC=com",Sales,Account Manager,jane.doe@corp.example.com,
John,Smith,jsmith,"OU=IT,OU=Users,DC=corp,DC=example,DC=com",IT,Systems Engineer,john.smith@corp.example.com,
| Column | Required | Maps To |
|---|---|---|
FirstName | Yes | -GivenName |
LastName | Yes | -Surname |
SamAccountName | Yes | -SamAccountName and -Name |
OUPath | Yes | -Path (full distinguished name of the target OU) |
Department | No | -Department |
Title | No | -Title |
EmailAddress | No | -EmailAddress and used to build -UserPrincipalName |
Password | No | -AccountPassword; generated automatically if left blank |
The Script
The script validates every row before it calls New-ADUser: it checks for missing required fields and for an existing account with the same SamAccountName using Get-ADUser -Filter, so duplicates are caught before AD ever rejects them. Every row — success or failure — produces a result object, and the whole run supports -WhatIf because New-ADUser itself implements ShouldProcess.
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$CsvPath,
[Parameter(Mandatory = $false)]
[string]$UpnSuffix = "corp.example.com"
)
Set-StrictMode -Version Latest
Import-Module ActiveDirectory -ErrorAction Stop
function New-RandomPassword {
# Generates a 16-character password that satisfies typical AD complexity policy
$upper = 65..90 | Get-Random -Count 4 | ForEach-Object { [char]$_ }
$lower = 97..122 | Get-Random -Count 4 | ForEach-Object { [char]$_ }
$digits = 48..57 | Get-Random -Count 4 | ForEach-Object { [char]$_ }
$symbols = @('!','@','#','%','^','*') | Get-Random -Count 4
-join (($upper + $lower + $digits + $symbols) | Sort-Object { Get-Random })
}
if (-not (Test-Path -LiteralPath $CsvPath)) {
throw "CSV file not found: $CsvPath"
}
$rows = Import-Csv -LiteralPath $CsvPath
$results = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($row in $rows) {
$result = [pscustomobject]@{
SamAccountName = $row.SamAccountName
Status = 'Unknown'
Detail = ''
}
if ([string]::IsNullOrWhiteSpace($row.FirstName) -or
[string]::IsNullOrWhiteSpace($row.LastName) -or
[string]::IsNullOrWhiteSpace($row.SamAccountName) -or
[string]::IsNullOrWhiteSpace($row.OUPath)) {
$result.Status = 'Skipped'
$result.Detail = 'Missing a required field (FirstName, LastName, SamAccountName, or OUPath)'
$results.Add($result)
continue
}
$existing = Get-ADUser -Filter "SamAccountName -eq '$($row.SamAccountName)'" -ErrorAction SilentlyContinue
if ($existing) {
$result.Status = 'Skipped'
$result.Detail = 'An account with this SamAccountName already exists'
$results.Add($result)
continue
}
$plainPassword = if ([string]::IsNullOrWhiteSpace($row.Password)) { New-RandomPassword } else { $row.Password }
$securePassword = ConvertTo-SecureString -String $plainPassword -AsPlainText -Force
$displayName = "$($row.FirstName) $($row.LastName)"
$upn = if ($row.EmailAddress) { $row.EmailAddress } else { "$($row.SamAccountName)@$UpnSuffix" }
$newUserParams = @{
Name = $displayName
SamAccountName = $row.SamAccountName
UserPrincipalName = $upn
GivenName = $row.FirstName
Surname = $row.LastName
DisplayName = $displayName
Path = $row.OUPath
AccountPassword = $securePassword
Enabled = $true
ChangePasswordAtLogon = $true
PasswordNeverExpires = $false
}
if ($row.Department) { $newUserParams['Department'] = $row.Department }
if ($row.Title) { $newUserParams['Title'] = $row.Title }
if ($row.EmailAddress) { $newUserParams['EmailAddress'] = $row.EmailAddress }
if ($PSCmdlet.ShouldProcess($row.SamAccountName, 'Create AD user')) {
try {
New-ADUser @newUserParams -ErrorAction Stop
$result.Status = 'Created'
$result.Detail = if ([string]::IsNullOrWhiteSpace($row.Password)) { "Generated password: $plainPassword" } else { 'Password supplied via CSV' }
}
catch {
$result.Status = 'Failed'
$result.Detail = $_.Exception.Message
}
}
else {
$result.Status = 'Skipped'
$result.Detail = 'WhatIf - no account created'
}
$results.Add($result)
}
$results | Format-Table -AutoSize
$summary = $results | Group-Object Status | Select-Object Name, Count
Write-Output "`nSummary:"
$summary | Format-Table -AutoSize
-WhatIf first against the full CSV. Because New-ADUser supports ShouldProcess, that dry run walks the exact same validation and duplicate-detection logic without creating a single account, so the report tells you which rows would fail before any of them do.
Run it like this:
# Preview what the script would do without creating anything
.\New-BulkADUser.ps1 -CsvPath "C:\Onboarding\new-starters.csv" -WhatIf
# Run it for real once the WhatIf output looks correct
.\New-BulkADUser.ps1 -CsvPath "C:\Onboarding\new-starters.csv"
Reading the Results
| Status | Meaning |
|---|---|
Created | The account was created successfully. Check Detail for the generated password if one was not supplied. |
Skipped | The row was never sent to New-ADUser — either a required field was missing, the account already existed, or -WhatIf was used. |
Failed | New-ADUser was called and returned an error. Detail holds the exception message verbatim for troubleshooting. |
Troubleshooting Cheat Sheet
| Symptom | Likely Cause | Fix |
|---|---|---|
Every row status is Failed with an access-related message |
The account running the script lacks Create permission on the target OU. | Delegate user creation rights on the OU, or run the script under an account that already has them. |
Failed with a message mentioning the path or container |
The OUPath distinguished name in the CSV is malformed or does not exist. |
Verify the OU’s DN with Get-ADOrganizationalUnit -Filter * and correct the CSV value. |
| Account created but shows as disabled | The supplied or generated password failed the domain’s password policy, so AD created the account but left it disabled. | Reset the password with Set-ADAccountPassword, then enable the account with Enable-ADAccount. |
Row status is Skipped with “already exists” |
A previous partial run already created this account, or the CSV has a duplicate SamAccountName. |
This is expected on a re-run — it means the script correctly avoided a duplicate-account error. |
Import-Module ActiveDirectory fails at the top of the script |
The RSAT AD PowerShell module is not installed on the machine running the script. | Install it with Install-WindowsFeature RSAT-AD-PowerShell on a server, or via Windows Features on a client OS. |
Final Thoughts
A bulk-creation script’s real job is not creating accounts — New-ADUser already does that in one line. Its job is surviving contact with a spreadsheet that was never going to be perfectly clean, and telling you afterward, row by row, exactly what happened.
Treat -WhatIf as mandatory before every real run, not optional. On a batch of any real size, it is the cheapest possible check against a typo in an OU path turning into fifty failed rows instead of one caught early.
Created or an expected Skipped (duplicate, WhatIf), the batch is clean. Any Failed row carries the exact AD exception message needed to fix that specific account without re-running the whole file.
Next, it’s worth covering the companion script: finding and disabling stale computer accounts in Active Directory, using the same per-object result pattern for a clean, auditable offboarding pass.