You've been asked to pull a list of every user in Active Directory. So maybe it's for an audit. Maybe HR needs a clean export for onboarding. Maybe you're migrating to a new system and need to know what you're working with Practical, not theoretical..
Whatever the reason, you open PowerShell, type Get-ADUser, and... Plus, how do you filter? Also, wait. Which properties? Why does the CSV look weird when you open it in Excel?
Been there. Let's fix that Not complicated — just consistent..
What Is Exporting AD Users to CSV
At its core, this is about querying Active Directory for user objects and dumping the results into a format that doesn't require PowerShell to read. CSV — comma-separated values — is the universal language of spreadsheets, HR systems, audit tools, and that one colleague who still lives in Excel Still holds up..
PowerShell makes this straightforward if you know the cmdlets. Now, the main player is Get-ADUser from the Active Directory module. It talks to your domain controller, pulls user objects, and hands them back as .That's why nET objects. Then Export-Csv serializes them to disk That's the whole idea..
Simple in theory. In practice? There are decisions at every step.
The cmdlets you'll actually use
Get-ADUser— queries AD. Supports filters, LDAP queries, property selection, pagination.Select-Object— shapes the output. You rarely want every property.Export-Csv— writes the file. Handles encoding, delimiters, quoting.Import-Module ActiveDirectory— loads the module. Usually automatic in modern Windows, but worth knowing.
That's the toolkit. Everything else is how you combine them.
Why It Matters / Why People Care
You might think "I'll just run a quick command and be done." Then you open the CSV and see Microsoft.In real terms, activeDirectory. Management.ADPropertyValueCollection in the MemberOf column. Practically speaking, or the LastLogon timestamp looks like 133284720000000000. Or — my personal favorite — you get 1,000 users because you forgot about the default result limit Small thing, real impact..
Here's what goes wrong when you wing it:
- Incomplete data — default page size is 1,000 objects. Large orgs have more.
- Unreadable timestamps — AD stores time as 100-nanosecond intervals since Jan 1, 1601. Excel doesn't speak that language.
- Bloated exports — pulling 100+ properties per user when you need five. The CSV becomes unusable.
- Encoding nightmares — special characters in names (accents, umlauts, non-Latin scripts) turn into garbage without UTF-8.
- Nested properties —
Managercomes back as a DistinguishedName, not a friendly name.MemberOfis a collection, not a string.
Do it right once. Save yourself the cleanup later Most people skip this — try not to..
How It Works — Step by Step
Load the module and check your context
First, make sure the AD module is available. On a domain-joined machine with RSAT installed:
Import-Module ActiveDirectory
Run Get-ADDomain to confirm you're talking to the right domain. If you manage multiple domains or forests, specify -Server explicitly in every call. Don't rely on defaults Turns out it matters..
Pick the properties you actually need
Every property you request adds load on the DC and width to your CSV. Be intentional Easy to understand, harder to ignore..
Common useful properties:
SamAccountName— the logon name (pre-Windows 2000 style)UserPrincipalName— the modern logon format, usually email-likeDisplayName— what shows in Outlook and ADUCGivenName,Surname— first and last nameEmailAddress— primary SMTP addressEnabled— true/false, critical for auditsLastLogonDate— human-readable last logon (replicates, unlikeLastLogon)Created,Modified— timestampsDepartment,Title,Office,Company— org dataManager— returns a DN; you'll want to resolve itDistinguishedName— the full path, useful for joins
Use -Properties to request non-default attributes:
Get-ADUser -Filter * -Properties EmailAddress, Department, Title, Manager, LastLogonDate
Filter smart, not hard
-Filter * gets everyone. Including service accounts, disabled accounts, the built-in Administrator, and that test user you created in 2019.
Filter with purpose:
# Only enabled users
Get-ADUser -Filter {Enabled -eq $true} -Properties ...
# Exclude service accounts (common naming pattern)
Get-ADUser -Filter {Enabled -eq $true -and SamAccountName -notlike 'svc_*'} -Properties ...
# Specific OU only
Get-ADUser -Filter * -SearchBase "OU=Sales,OU=Users,DC=corp,DC=contoso,DC=com" -Properties ...
The -SearchBase parameter is your friend. In practice, faster. Here's the thing — it limits scope at the source instead of pulling everything and filtering client-side. Cleaner.
Handle the pagination trap
This is the one that bites everyone. Get-ADUser defaults to 1,000 objects per page. In real terms, if your org has 3,500 users, you get 1,000. No error. No warning. Consider this: just... missing data.
Fix it with -ResultPageSize and -ResultSetSize:
Get-ADUser -Filter * -Properties ... -ResultPageSize 5000 -ResultSetSize $null
-ResultSetSize $null means "no limit." -ResultPageSize controls how many objects come back per LDAP round-trip. Set it high enough to cover your environment in one go.
Resolve the Manager field
Manager returns a DistinguishedName like CN=Jane Doe,OU=Managers,DC=corp,DC=contoso,DC=com. Not helpful in a spreadsheet That's the whole idea..
Resolve it inline with a calculated property:
Get-ADUser -Filter {Enabled -eq $true} -Properties Manager, EmailAddress, Department |
Select-Object SamAccountName, DisplayName, EmailAddress, Department,
@{Name='Manager'; Expression={(Get-ADUser $_.Manager -ErrorAction SilentlyContinue).DisplayName}}
The @{Name='...Even so, '; Expression={... Think about it: inside the script block, $_ is the current user. So naturally, }} syntax creates a custom column. We feed their Manager DN to Get-ADUser and pull the DisplayName Worth keeping that in mind..
Yes, this makes an extra query per user. Practically speaking, a bit. But the alternative — exporting DNs and resolving later — is messy. For 5,000 users, that's 5,000 extra LDAP calls. For one-off exports, the inline approach is fine. Slow? For recurring automation, cache the manager lookup in a hashtable.
Convert timestamps to something readable
LastLogonDate is already a DateTime object. Created and Modified are too. But LastLogon and LastLogonTimestamp
When you pull LastLogon and LastLogonTimestamp from AD you’ll notice they aren’t the friendly DateTime objects you see with LastLogonDate. Both are stored as large integers that represent the number of 100‑nanosecond intervals since January 1 1601 (UTC) – the Windows FileTime format. If you dump them raw into a CSV you’ll get values like 132537600000000000, which are useless for reporting.
Turning FileTime into readable dates
The safest way is to test whether the attribute contains a non‑zero value before converting; a value of 0 means “never logged on” (or, for LastLogonTimestamp, that the attribute hasn’t been replicated yet) Not complicated — just consistent..
Get-ADUser -Filter {Enabled -eq $true} `
-Properties SamAccountName, DisplayName, EmailAddress, Department,
Manager, LastLogon, LastLogonTimestamp |
Select-Object SamAccountName,
DisplayName,
EmailAddress,
Department,
@{Name='Manager';Expression={(Get-ADUser $_.Manager -ErrorAction SilentlyContinue).DisplayName}},
@{Name='LastLogon';Expression={
if ($_.LastLogon -gt 0) {
[datetime]::FromFileTimeUtc($_.LastLogon)
} else {
$null
}
}},
@{Name='LastLogonTimestamp';Expression={
if ($_.LastLogonTimestamp -gt 0) {
[datetime]::FromFileTimeUtc($_.LastLogonTimestamp)
} else {
$null
}
}} |
Export-Csv -Path .\UsersReport.csv -NoTypeInformation -Encoding UTF8
Why the extra checks?
LastLogonis not replicated; each domain controller holds its own copy. If you query a DC that hasn’t seen the user’s logon recently, the value may be stale or zero.LastLogonTimestampis replicated, but it updates only every 14 days by default (to reduce replication traffic). It’s therefore an approximate timestamp, good enough for trend analysis but not for pinpoint‑accurate audits.
If you need the most accurate last logon across the forest, you can query every DC and take the maximum value – but that’s a heavier operation and usually unnecessary for routine reporting Worth knowing..
Handling other date‑related attributes
Attributes such as whenCreated, whenChanged, pwdLastSet, and accountExpires also arrive as large integers (or, in the case of whenCreated/whenChanged, already as [datetime] objects when you request them via -Properties). A uniform helper function keeps the script tidy:
function Convert-FileTime {
param([long]$FileTime)
if ($FileTime -gt 0) { [datetime]::FromFileTimeUtc($FileTime) } else { $null }
}
Then reuse it:
Select-Object ...,
@{Name='PwdLastSet';Expression={Convert-FileTime $_.pwdLastSet}},
@{Name='AccountExpires';Expression={Convert-FileTime $_.accountExpires}}
Exporting large result sets without hitting the pipeline limit
Even with -ResultPageSize $null and -ResultSetSize $null, PowerShell will still buffer objects in memory before sending them to Export-Csv. For environments with > 50 k accounts, consider streaming directly to disk:
$csvPath = .\UsersReport.csv
# Write header once
"SamAccountName,DisplayName,EmailAddress,Department,Manager,LastLogon,LastLogon
### Streamlining Export for Massive OU Trees
When the OU scope expands beyond a few thousand objects, the default pipeline buffering can become a bottleneck. Instead of letting PowerShell accumulate a full collection in RAM, you can pipe the output straight to a stream‑writer. The following pattern writes each line as soon as it is materialised, keeping memory usage constant no matter how many users are discovered:
```powershell
$csvPath = 'C:\Reports\AllUsers.csv'
$header = 'SamAccountName,DisplayName,EmailAddress,Department,Manager,LastLogon,LastLogonTimestamp'
$header | Out-File -FilePath $csvPath -Encoding UTF8
Get-ADUser -SearchBase 'OU=Sales,DC=contoso,DC=com' -Filter * `
-Properties SamAccountName,DisplayName,Mail,Department,Manager,LastLogon,LastLogonTimestamp `
-ResultPageSize $null -ResultSetSize $null |
ForEach-Object {
$sam = $_.Which means displayName : ''
$last = if ($_. Even so, (Get-ADUser $_. Also, manager -match '^CN=') ? \n',' '
$mgr = ($_.LastLogon -gt 0) { [datetime]::FromFileTimeUtc($_.Department -replace '\r?Day to day, lastLogon) } else { '' }
$lastT = if ($_. In real terms, \n',' '
$dept = $_. Manager -ErrorAction SilentlyContinue).SamAccountName
$disp = $_.Mail -replace '\r?DisplayName
$mail = $_.LastLogonTimestamp -gt 0) { [datetime]::FromFileTimeUtc($_.
"$sam,$disp,$mail,$dept,$mgr,$last,$lastT" |
Add-Content -Path $csvPath -Encoding UTF8
}
Why this works:
Out-Filecreates the file and writes the header in a single operation.Add-Contentappends each formatted row immediately, so the pipeline never holds more than one object at a time.- The
-Encoding UTF8flag guarantees that non‑ASCII characters in names or addresses survive the round‑trip.
If you prefer a more compact representation, you can switch to CSV compression by wrapping the file stream with System.csv.Day to day, compression. The overhead is negligible, yet the resulting .Worth adding: iO. GZipStream. gz file can be shipped to remote analysts without consuming excessive storage.
Parallelising the Query for Forest‑Wide Scans
Once you need to harvest data from every domain controller or from multiple OUs simultaneously, leveraging PowerShell’s parallel capabilities can shave minutes off the runtime. Starting with PowerShell 7, ForEach-Object -Parallel offers a straightforward way to distribute the workload:
$searchBases = @(
'OU=Finance,DC=contoso,DC=com',
'OU=HR,DC=contoso,DC=com',
'OU=Engineering,DC=contoso,DC=com'
)
$searchBases | ForEach-Object -Parallel {
param($base)
Get-ADUser -SearchBase $base -Filter * `
-Properties SamAccountName,DisplayName,Mail,Department,Manager,LastLogon,LastLogonTimestamp `
-ResultPageSize $null -ResultSetSize $null |
ForEach-Object {
$sam = $_.Practically speaking, samAccountName
$disp = $_. That said, displayName
$mail = $_. So naturally, mail -replace '\r? \n',' '
$dept = $_.Department -replace '\r?\n',' '
$mgr = ($_.And manager -match '^CN=') ? Here's the thing — (Get-ADUser $_. Day to day, manager -ErrorAction SilentlyContinue). DisplayName : ''
$last = if ($_.LastLogon -gt 0) { [datetime]::FromFileTimeUtc($_.LastLogon) } else { '' }
$lastT = if ($_.LastLogonTimestamp -gt 0) { [datetime]::FromFileTimeUtc($_.
[pscustomobject]@{
SamAccountName = $sam
DisplayName = $disp
EmailAddress = $mail
Department = $dept
Manager = $mgr
LastLogon = $last
LastLogonTimestamp = $lastT
}
}
} -ThrottleLimit
### Adding Resilience — Error Handling and Logging
Even the most efficient pipeline can be derailed by an occasional hiccup: a locked‑out account, a transient LDAP timeout, or a missing attribute. Embedding solid error handling ensures the script continues processing the rest of the forest without aborting midway.
```powershell
try {
$users = Get-ADUser -LDAPFilter $ldapFilter -SearchBase $searchBase `
-Properties SamAccountName,DisplayName,Mail,Department,Manager,LastLogon,LastLogonTimestamp `
-ResultPageSize $null -ResultSetSize $null -ErrorAction Stop
}
catch {
$msg = "[$(Get-Date -Format o)] ERROR: $($_.Exception.Message) (SearchBase: $searchBase)"
Write-Error $msg
$msg | Out-File -FilePath $logPath -Append -Encoding UTF8
return # skip this OU but keep the loop alive
}
Each successful retrieval is followed by a concise audit entry that records the OU distinguished name, the number of objects fetched, and the elapsed milliseconds. Such a log not only satisfies auditors but also provides a quick diagnostic hook when performance deviates from expectations.
$duration = (Get-Date) - $scriptStart
"$searchBase processed $($users.Count) entries in $($duration.TotalSeconds) s" |
Out-File -FilePath $logPath -Append -Encoding UTF8
Deploying the Collector at Scale
When the script must run against dozens of domains or across multiple sites, packaging it as a scheduled task on a dedicated collector host eliminates manual intervention. A typical deployment sequence looks like this:
- Copy the script to
C:\Scripts\Export-ADUsers.ps1on the collector machine. - Create a credential file (
C:\Scripts\svcADExport.pscredential) that contains a service account with read‑only rights to the entire forest. - Register a Windows Task that invokes PowerShell with the
-ExecutionPolicy Bypassflag and points to the script, passing the path to the credential file as a parameter. - Configure rotation of the output CSV (or its gzipped counterpart) by using a simple retention policy that deletes files older than 30 days.
A sample task definition that can be imported via schtasks.exe:
schtasks /Create /TN "ADUserExport" /TR "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Scripts\Export-ADUsers.ps1 -CredPath C:\Scripts\svcADExport.pscredential" /SC DAILY /ST 02:00 /RU "SYSTEM" /RL HIGHEST
The early‑morning window minimizes interference with production workloads while ensuring that the latest logon timestamps are captured Simple as that..
Tuning for Large‑Scale Environments
A few pragmatic adjustments can push the export speed beyond the baseline figures mentioned earlier:
| Technique | Effect | Typical Gain |
|---|---|---|
Increase ResultPageSize to a value that matches the underlying LDAP server’s paging limit (often 1000) |
Reduces round‑trips | 15‑25 % |
Pre‑filter with (&(objectCategory=person)(userAccountControl=512)) |
Cuts the dataset to active user objects only | 30‑40 % fewer LDAP queries |
use Get-ADUser -Server <DC> per DC and merge results locally |
Parallelizes across multiple domain controllers | Near‑linear scaling when many DCs exist |
Write directly to a compressed stream ([IO.Compression.GZipStream]) instead of post‑processing |
Cuts disk I/O by ~50 % for text‑heavy exports | 40‑60 % reduction in file size, faster writes |
A concrete example of the compression step:
$stream = [IO.File]::OpenWrite("$csvPath.gz")
$gzip = New-Object IO.Compression.GZipStream($stream, [IO.Compression.CompressionMode]::Compress)
$users | ConvertTo-Csv -NoTypeInformation | Set-Content -Encoding UTF8 -Path $gzip
$gzip.Close()
$stream.Close()
Security and Compliance Considerations
Exporting identity data carries an implicit responsibility. The following checklist helps keep the operation within policy boundaries:
-
Least‑privilege principle – the
-
Least‑privilege principle – the service account should be scoped to read‑only access on the specific OU(s) that contain the target users, and never granted domain‑wide admin rights. Use
distinguishedNameconstraints in the AD query ((distinguishedName=CN=...,DC=...,DC=...)) to further limit the blast radius. -
**Credential file protection – store the
.pscredentialfile in a secure location with restrictive NTFS permissions (Read/Modify for SYSTEM and the service account only). For extra hardening, encrypt the file with Windows DPAPI‑PROTECT‑DATA or a hardware security module (HSM) and automate decryption within the script using the appropriate key. -
**Script integrity – sign the PowerShell script with an Authenticode signature and enforce a “AllSigned” execution policy on the collector host. This prevents accidental execution of tampered scripts and provides a clear chain of custody for the export process.
-
**Task Scheduler security – configure the scheduled task to run under the dedicated service account, disable “Run with highest privileges” unless absolutely required, and enable “Run whether user is logged on or not” only if the early‑morning window is essential. Record the task’s security descriptor so auditors can verify the account’s rights That alone is useful..
-
**Audit logging – enable Advanced Audit Policy categories such as “Directory Service Access,” “Logon/Logoff,” and “Process Creation” on the collector. Forward the Task Scheduler and PowerShell action logs to a SIEM or a dedicated log analytics workspace to detect anomalies such as repeated failures or unexpected credential usage Took long enough..
-
**Data classification and labeling – tag exported CSV (or gzip) files with appropriate sensitivity labels (e.g., PII, confidential) using Azure Information Protection or an equivalent labeling solution. Ensure the labeling is applied automatically by the script so that every export carries its compliance metadata.
-
**Transport encryption – if the export files are moved out of the collector (e.g., to a central repository or cloud storage), use encrypted channels such as SFTP, FTPS, or TLS‑based APIs. For on‑prem transfers, consider encrypting the file payload with AES‑256 before transmission, and store the encryption key in a secure vault Easy to understand, harder to ignore..
-
**Monitoring and alerting – set up Windows Event Log subscriptions or a monitoring platform (e.g., System Center Operations Manager, Nagios) to raise alerts on missed runs, script errors, or unusual file‑system activity on the export directory. Include metrics such as execution duration, record count, and file size in the alert payload.
-
**Periodic review – conduct quarterly reviews of the service account’s permissions, rotate the credential file on a schedule
-
Periodic review – conduct quarterly reviews of the service account’s permissions, rotate the credential file on a schedule aligned with organizational password policies, and re-validate the script against updated compliance requirements. Document each review in a centralized audit trail to demonstrate due diligence during internal and external assessments.
Conclusion
Securing an automated Active Directory export process requires more than just functional scripting; it demands a layered approach that encompasses identity, access, data protection, and continuous monitoring. By implementing least-privilege delegation through constrained endpoints, protecting credentials with encryption and strict file permissions, enforcing script integrity via code signing, and maintaining comprehensive audit logs, organizations can significantly reduce the risk of compromise. Additionally, integrating data classification, encrypted transport mechanisms, and proactive alerting ensures that sensitive information remains protected both at rest and in motion. Which means regular reviews and updates to this framework further reinforce its resilience against evolving threats. When these practices are consistently applied, the export workflow becomes not only operationally reliable but also compliant with security standards and regulatory expectations.