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

Source: https://srvscripts.com/guides/bulk-create-ad-users-csv/
Updated: 2026-10-03
Publisher: srvScripts (https://srvscripts.com/)

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.

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.

## Which method to use

| Method | Best for | Pros | Cons |
| --- | --- | --- | --- |
| ADUC New Object wizard | One or two users | No scripting | Slow, inconsistent attributes, no log |
| Copy an existing user in ADUC | A few users with the same groups | Copies groups and some attributes | Copies mistakes too; still manual |
| CSV + New-ADUser script (this guide) | Onboarding batches, migrations, labs | Repeatable, validated, logged, dry run | Needs a clean CSV and testing |
| HR-driven provisioning (Entra inbound provisioning, identity management tools) | Continuous joiner/mover/leaver | Fully automated | Licensing 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.

| Column | Required | Example | Notes |
| --- | --- | --- | --- |
| FirstName | Yes | Zoë | Accents are kept in display names and removed from logon names |
| LastName | Yes | O’Brien | Apostrophes and spaces are removed from logon names |
| Department | Yes | Finance | Used for reports and dynamic groups |
| Title | No | Accountant |  |
| Office | No | London |  |
| EmployeeID | No | 100245 | Useful as a stable key for later updates |
| Email | No | zoe.obrien@contoso.com | Writes the mail attribute only; it does not create a mailbox |
| Manager | No | jsmith | sAMAccountName of an existing user |
| OU | No | OU=Finance,OU=Staff,DC=contoso,DC=com | Distinguished name; blank means the default OU |
| Groups | No | GRP-Finance;GRP-VPN | Semicolon-separated group names |
| SamAccountName | No | zobrien | Blank 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 symptom | Cause | Fix |
| --- | --- | --- |
| “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 run | Rerun: 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 CSV | Quote the DN; test it with Get-ADOrganizationalUnit |
| “Access is denied” | Missing rights on the OU or group | Delegate create/reset rights on the OU and Write Members on the groups |
| Accents shown as ? | CSV saved as ANSI | Save as CSV UTF-8 and keep -Encoding UTF8 |
| Group add fails right after creation | Group change sent to a different DC | Keep -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

**Official documentation:** [New-ADUser (ActiveDirectory module)](https://learn.microsoft.com/en-us/powershell/module/activedirectory/new-aduser), [SAM-Account-Name attribute](https://learn.microsoft.com/en-us/windows/win32/adschema/a-samaccountname).

**Related guides:** [Get-ADUser PowerShell examples](/guides/get-aduser-powershell-examples/) · [Add a UPN suffix in AD (Microsoft 365 hybrid)](/guides/active-directory-upn-suffix/) · [Enable and use the Active Directory Recycle Bin to restore deleted objects](/guides/enable-active-directory-recycle-bin/).

## 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.
