Emergency server help: get in touch

Bulk Create AD Users from CSV: PowerShell Script in 7 Steps

A complete PowerShell workflow to create Active Directory users from a CSV file: a column template, input validation, unique sAMAccountName and UPN generation, OU placement, random initial passwords, group membership, logging, a -WhatIf dry run, updates with Set-ADUser and rollback.

Published Updated 12 min read

To bulk create AD users, a CSV file and a PowerShell script built on New-ADUser are faster and far more consistent than filling in the New Object wizard fifty times. This guide gives you a CSV template, a script that validates every row, generates unique logon names, places users in the right OU, sets a random initial password, adds group memberships and writes a log, plus a dry run with -WhatIf and a clean rollback.

Short answer: Save the new starters as a UTF-8 CSV, run Import-Csv, and call New-ADUser for each row with -SamAccountName, -UserPrincipalName, -Path, -AccountPassword, -Enabled $true and -ChangePasswordAtLogon $true. Run the script with -WhatIf first, then for real, and keep the result log for rollback.

Which method to use

MethodBest forProsCons
ADUC New Object wizardOne or two usersNo scriptingSlow, inconsistent attributes, no log
Copy an existing user in ADUCA few users with the same groupsCopies groups and some attributesCopies mistakes too; still manual
CSV + New-ADUser script (this guide)Onboarding batches, migrations, labsRepeatable, validated, logged, dry runNeeds a clean CSV and testing
HR-driven provisioning (Entra inbound provisioning, identity management tools)Continuous joiner/mover/leaverFully automatedLicensing and project effort

If you bulk create AD users more than a couple of times a year, the script approach pays for itself: every account gets the same attribute set, the same password rules and the same group logic, and the log shows exactly what happened.

Prerequisites

Before you bulk create AD users, check the following:

  • The ActiveDirectory PowerShell module (Install-WindowsFeature RSAT-AD-PowerShell on a server, or RSAT on Windows 11).
  • Rights to create user objects and reset passwords in the target OUs, plus Write Members on the groups you add. Domain Admins works, but a delegated account is better.
  • The UPN suffix you plan to use must exist in the forest. Check with (Get-ADForest).UPNSuffixes and the domain name from (Get-ADDomain).DNSRoot.
  • Target OUs created in advance. The script checks them but does not create them.
  • The domain password policy or fine-grained policy that applies to new users, so the generated password is long enough.

Step 1: Build the CSV template

The CSV is the contract between HR and IT when you bulk create AD users. Agree the column names once and keep them fixed; the script refers to them by header name, so the column order does not matter.

ColumnRequiredExampleNotes
FirstNameYesZoëAccents are kept in display names and removed from logon names
LastNameYesO’BrienApostrophes and spaces are removed from logon names
DepartmentYesFinanceUsed for reports and dynamic groups
TitleNoAccountant
OfficeNoLondon
EmployeeIDNo100245Useful as a stable key for later updates
EmailNozoe.obrien@contoso.comWrites the mail attribute only; it does not create a mailbox
ManagerNojsmithsAMAccountName of an existing user
OUNoOU=Finance,OU=Staff,DC=contoso,DC=comDistinguished name; blank means the default OU
GroupsNoGRP-Finance;GRP-VPNSemicolon-separated group names
SamAccountNameNozobrienBlank means the script generates one

A matching file, saved from Excel as CSV UTF-8 (Comma delimited):

FirstName,LastName,Department,Title,Office,EmployeeID,Email,Manager,OU,Groups,SamAccountName
Zoë,O'Brien,Finance,Accountant,London,100245,zoe.obrien@contoso.com,jsmith,"OU=Finance,OU=Staff,DC=contoso,DC=com",GRP-Finance;GRP-VPN,
Liam,Carter,IT,Engineer,Leeds,100246,liam.carter@contoso.com,,,GRP-IT,

Quote any value that contains a comma, such as an OU distinguished name. Save as UTF-8, or names with accents turn into question marks.

Step 2: Understand the naming rules

  • sAMAccountName (pre-Windows 2000 logon name) must be unique in the domain, 20 characters or fewer, and must not contain " / \ [ ] : ; | = , + * ? < >. The script also rejects spaces and @, which are legal but cause problems in scripts and applications.
  • userPrincipalName has the form prefix@suffix, must be unique across the forest, and the suffix must be the domain name or an alternative UPN suffix. For Microsoft 365 hybrid, match the UPN to the user’s e-mail address.
  • Name (the CN) must be unique within its OU. Two “John Smith” accounts can live in different OUs, but not in the same one; the script appends the logon name when that happens.

The script builds the logon name from the first initial and the last name (zobrien) and the UPN prefix as first.last, adds a number when a name is taken, and removes accents with Unicode normalisation.

Step 3: The script

The script below is what we use to bulk create AD users in production and lab domains. Save it as New-BulkADUsers.ps1. It creates everything on the PDC emulator, so group changes made seconds after user creation find the new object without waiting for replication.

[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)][string]$CsvPath,
    [string]$UpnSuffix    = 'contoso.com',
    [string]$DefaultOU    = 'OU=New Users,OU=Staff,DC=contoso,DC=com',
    [string]$LogFolder    = 'C:\Scripts\Logs',
    [string]$PasswordFile = 'C:\Scripts\Secure\initial-passwords.csv',
    [switch]$UpdateExisting
)
Import-Module ActiveDirectory -ErrorAction Stop
$stamp   = Get-Date -Format 'yyyyMMdd-HHmmss'
$server  = (Get-ADDomain).PDCEmulator
$results = New-Object System.Collections.Generic.List[object]
$secrets = New-Object System.Collections.Generic.List[object]
$planned = @{}
Start-Transcript -Path (Join-Path $LogFolder "bulk-users-$stamp.log")
$suffixes = @((Get-ADDomain).DNSRoot) + (Get-ADForest).UPNSuffixes
if ($suffixes -notcontains $UpnSuffix) { throw "UPN suffix $UpnSuffix is not registered in the forest" }

function ConvertTo-LoginName([string]$Text) {
    $plain = $Text.Normalize([Text.NormalizationForm]::FormD) -replace '\p{Mn}', ''
    ($plain -replace '[^a-zA-Z0-9-]', '').ToLower()
}
function New-RandomPassword([int]$Length = 16) {
    $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!#%*-_=+?'.ToCharArray()
    $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
    do {
        $bytes = New-Object byte[] $Length
        $rng.GetBytes($bytes)
        $pw = -join ($bytes | ForEach-Object { $chars[$_ % $chars.Length] })
    } until ($pw -cmatch '[A-Z]' -and $pw -cmatch '[a-z]' -and $pw -match '\d' -and $pw -match '[^a-zA-Z0-9]')
    $pw
}
function Test-Taken([string]$Attr, [string]$Value) {
    if ($planned.ContainsKey("$Attr|$Value")) { return $true }
    [bool](Get-ADUser -Filter "$Attr -eq '$Value'" -Server $server)
}

foreach ($row in (Import-Csv -Path $CsvPath -Encoding UTF8)) {
    $entry = [ordered]@{ FirstName = $row.FirstName; LastName = $row.LastName; Sam = ''; UPN = ''; Status = ''; Message = '' }
    try {
        foreach ($col in 'FirstName', 'LastName', 'Department') {
            if ([string]::IsNullOrWhiteSpace($row.$col)) { throw "Missing value in column $col" }
        }
        $first = $row.FirstName.Trim(); $last = $row.LastName.Trim()
        $ou = if ($row.OU) { $row.OU.Trim() } else { $DefaultOU }
        try { $null = Get-ADOrganizationalUnit -Identity $ou -Server $server }
        catch { throw "OU not found: $ou" }

        # sAMAccountName: from the CSV or generated
        if ($row.SamAccountName) { $sam = $row.SamAccountName.Trim() }
        else {
            $base = (ConvertTo-LoginName $first).Substring(0, 1) + (ConvertTo-LoginName $last)
            $base = $base.Substring(0, [Math]::Min(18, $base.Length))
            $sam = $base; $n = 1
            while (Test-Taken 'SamAccountName' $sam) { $n++; $sam = "$base$n" }
        }
        if ($sam.Length -gt 20 -or $sam -match '["/\\\[\]:;|=,+*?<>@ ]') { throw "Invalid sAMAccountName '$sam'" }
        $entry.Sam = $sam

        $existing = Get-ADUser -Filter "SamAccountName -eq '$sam'" -Server $server
        if ($existing) {
            if (-not $UpdateExisting) { $entry.Status = 'Skipped'; $entry.Message = 'Already exists'; continue }
            $set = @{ Identity = $existing; Server = $server; ErrorAction = 'Stop' }
            foreach ($f in 'Department', 'Title', 'Office', 'EmployeeID') { if ($row.$f) { $set[$f] = $row.$f.Trim() } }
            if ($PSCmdlet.ShouldProcess($sam, 'Update AD user')) { Set-ADUser @set; $entry.Status = 'Updated' }
            continue
        }

        # UPN: first.last@suffix, falling back to sam@suffix
        $upn = '{0}.{1}@{2}' -f (ConvertTo-LoginName $first), (ConvertTo-LoginName $last), $UpnSuffix
        if (Test-Taken 'UserPrincipalName' $upn) { $upn = "$sam@$UpnSuffix" }
        if (Test-Taken 'UserPrincipalName' $upn) { throw "UPN $upn already in use" }
        $entry.UPN = $upn

        # CN must be unique inside the OU
        $cn = "$first $last"
        $ldapCn = $cn -replace '\\', '\5c' -replace '\*', '\2a' -replace '\(', '\28' -replace '\)', '\29'
        if (Get-ADObject -LDAPFilter "(name=$ldapCn)" -SearchBase $ou -SearchScope OneLevel -Server $server) { $cn = "$cn ($sam)" }

        $password = New-RandomPassword
        $params = @{
            Name = $cn; GivenName = $first; Surname = $last; DisplayName = "$first $last"
            SamAccountName = $sam; UserPrincipalName = $upn; Path = $ou
            AccountPassword = (ConvertTo-SecureString $password -AsPlainText -Force)
            Enabled = $true; ChangePasswordAtLogon = $true
            Server = $server; ErrorAction = 'Stop'
        }
        foreach ($f in 'Department', 'Title', 'Office', 'EmployeeID') { if ($row.$f) { $params[$f] = $row.$f.Trim() } }
        if ($row.Email)   { $params.EmailAddress = $row.Email.Trim() }
        if ($row.Manager) { $params.Manager = (Get-ADUser -Identity $row.Manager.Trim() -Server $server).DistinguishedName }

        if ($PSCmdlet.ShouldProcess("$sam in $ou", 'Create AD user')) {
            New-ADUser @params
            $secrets.Add([pscustomobject]@{ Sam = $sam; UPN = $upn; InitialPassword = $password })
            $entry.Status = 'Created'
            foreach ($g in ($row.Groups -split ';' | Where-Object { $_.Trim() })) {
                try { Add-ADGroupMember -Identity $g.Trim() -Members $sam -Server $server -ErrorAction Stop }
                catch { $entry.Message += "Group '$($g.Trim())' failed: $($_.Exception.Message) " }
            }
        } else { $entry.Status = 'WhatIf' }
        $planned["SamAccountName|$sam"] = $true
        $planned["UserPrincipalName|$upn"] = $true
    }
    catch { $entry.Status = 'Failed'; $entry.Message = $_.Exception.Message }
    finally { $results.Add([pscustomobject]$entry) }
}

$results | Export-Csv -Path (Join-Path $LogFolder "bulk-users-$stamp.csv") -NoTypeInformation -Encoding UTF8
if ($secrets.Count) { $secrets | Export-Csv -Path $PasswordFile -NoTypeInformation -Encoding UTF8 }
$results | Group-Object Status | Select-Object Name, Count
Stop-Transcript

How the script works

  • Validation first. Each row fails on its own with a clear message (missing column, unknown OU, invalid logon name, duplicate UPN) instead of stopping the whole batch.
  • Unique names. Test-Taken checks the directory and the names already planned in this run, so two rows for “ZoĂ« O’Brien” become zobrien and zobrien2, even in a dry run.
  • Passwords. RandomNumberGenerator produces a 16-character password with upper case, lower case, a digit and a symbol. Ambiguous characters such as O, 0, l and 1 are left out so passwords can be read aloud.
  • Change at logon. -ChangePasswordAtLogon $true sets pwdLastSet to 0, so users must choose their own password at first sign-in.
  • Logging. The transcript records every command and error; the results CSV lists each row with Created, Updated, Skipped, WhatIf or Failed.

Step 4: Run a dry run with -WhatIf

.\New-BulkADUsers.ps1 -CsvPath C:\Scripts\starters.csv -WhatIf
Import-Csv (Get-ChildItem C:\Scripts\Logs\bulk-users-*.csv | Sort-Object LastWriteTime | Select-Object -Last 1).FullName |
    Format-Table Sam, UPN, Status, Message -AutoSize

Because the script declares SupportsShouldProcess, -WhatIf prints “What if: Performing the operation “Create AD user”” for each row and changes nothing. Fix every Failed row in the CSV, run the dry run again, and only then run it without -WhatIf. A dry run is the cheapest way to bulk create AD users without surprises.

Step 5: Create the users and hand over passwords

.\New-BulkADUsers.ps1 -CsvPath C:\Scripts\starters.csv -UpnSuffix contoso.com

The password file contains plain-text initial passwords. Keep C:\Scripts\Secure readable only by the onboarding team, pass each password to the line manager over a separate channel, and delete the file once accounts are handed over. Never e-mail initial passwords to the new user’s own mailbox: they cannot read it until they can sign in.

Step 6: Update existing users with Set-ADUser

Run the same script with -UpdateExisting to refresh department, title, office and employee ID for rows whose logon name already exists. For one-off corrections, a short loop is enough:

Import-Csv C:\Scripts\changes.csv -Encoding UTF8 | ForEach-Object {
    $set = @{ Identity = $_.SamAccountName; ErrorAction = 'Stop' }
    if ($_.Title)      { $set.Title = $_.Title }
    if ($_.Department) { $set.Department = $_.Department }
    if ($_.Manager)    { $set.Manager = $_.Manager }
    Set-ADUser @set -WhatIf
}
# Clear a value or set an attribute that has no parameter
Set-ADUser -Identity zobrien -Clear title
Set-ADUser -Identity zobrien -Replace @{ extensionAttribute1 = 'Contractor' }

Only non-empty CSV fields are applied, so a blank cell never wipes an existing value by accident. Remove -WhatIf once the output looks right.

Run it with a delegated account

Avoid running onboarding scripts as a Domain Admin. Create a group such as ADM-Onboarding, delegate “Create, delete, and manage user accounts” on the staff OUs with the Delegation of Control Wizard, and grant the group Write Members on the groups new starters join. The script then runs with exactly the rights it needs, and a mistake in the CSV cannot touch admin accounts or servers.

Step 7: Verify the result

$today = (Get-Date).Date
Get-ADUser -Filter 'whenCreated -ge $today' -Properties whenCreated, Department, MemberOf, pwdLastSet |
    Select-Object Name, SamAccountName, UserPrincipalName, Department, Enabled, pwdLastSet,
        @{ n = 'Groups'; e = { ($_.MemberOf | ForEach-Object { ($_ -split ',')[0] -replace '^CN=' }) -join '; ' } }

Every new account should show Enabled = True, pwdLastSet = 0 and the expected groups. Sign in once with a test account from the batch to confirm the forced password change works, and, in a hybrid setup, check that the user appears in Microsoft Entra ID after the next synchronisation cycle.

Troubleshooting

Error or symptomCauseFix
“The password does not meet the length, complexity, or history requirement”Generated password shorter than the policy (a PSO can require more)Raise -Length in New-RandomPassword; check the account, which may exist disabled, then reset its password and enable it or delete it and rerun
“The specified account already exists”Same CN in the OU, or a duplicate sAMAccountName/UPN created by someone else during the runRerun: the script picks a free name on the next pass
“Directory object not found” / “OU not found”Typo in the OU DN or an unquoted comma in the CSVQuote the DN; test it with Get-ADOrganizationalUnit
“Access is denied”Missing rights on the OU or groupDelegate create/reset rights on the OU and Write Members on the groups
Accents shown as ?CSV saved as ANSISave as CSV UTF-8 and keep -Encoding UTF8
Group add fails right after creationGroup change sent to a different DCKeep -Server $server on every cmdlet

Roll back or clean up

The results CSV is your undo list. To remove only the accounts created in one run:

Import-Csv C:\Scripts\Logs\bulk-users-20260930-101500.csv |
    Where-Object Status -eq 'Created' |
    ForEach-Object { Remove-ADUser -Identity $_.Sam -Confirm:$false -WhatIf }

Remove -WhatIf after checking the list. If the Active Directory Recycle Bin is enabled, deleted users can be restored with their group memberships intact. Finally, delete the initial password file. With the template, dry run, log and rollback in place, you can bulk create AD users for every intake with the same predictable result, and reuse the script to bulk create AD users in a lab before a migration.

Bulk create AD users at a glance

Bulk Create AD Users from CSV summary card: Save the new starters as a UTF-8 CSV, run Import-Csv, and call New-ADUser for each row with -SamAccountName…
In short: Save the new starters as a UTF-8 CSV, run Import-Csv, and call New-ADUser for each row with -SamAccountName, -UserPrincipalName, -Path, -AccountPassword, -Enabled $true and -ChangePasswordAtLogon $true.

Official documentation: New-ADUser (ActiveDirectory module), SAM-Account-Name attribute.

Related guides: Get-ADUser PowerShell examples · Add a UPN suffix in AD (Microsoft 365 hybrid) · Enable and use the Active Directory Recycle Bin to restore deleted objects.

Frequently asked questions

Can I bulk create AD users without PowerShell?

The built-in alternatives are copying a template user in Active Directory Users and Computers or using csvde, which cannot set passwords. PowerShell with New-ADUser is the practical way to create many enabled users with passwords and groups.

Why is my new user disabled after New-ADUser?

New-ADUser creates accounts disabled unless you pass -Enabled $true with a password that meets the policy. If the password fails the policy, the account can still be created without a password, so check it and set the password with Set-ADAccountPassword.

What is the maximum length of a sAMAccountName?

The sAMAccountName must be 20 characters or fewer and must not contain quotes, slashes, backslashes, square brackets, colons, semicolons, pipes, equals signs, commas, plus signs, asterisks, question marks or angle brackets. The UPN can be longer and is what users normally sign in with.

How do I test a bulk import without creating accounts?

Run the script with -WhatIf. Because it uses SupportsShouldProcess, it validates every row and logs what it would create, but makes no changes.

Free website test

Is your website set up right?

Check SSL, security headers, redirects, robots.txt, sitemap, llms.txt and security.txt in one test. It takes about 30 seconds.