| Tested on | RHEL 10.2 (Coughlan) — disposable lab VM |
|---|---|
| Package | bash-5.2.26-6.el10.x86_64shadow-utils-4.15.0-11.el10.x86_64 (useradd, chpasswd)glibc-common-2.39-128.el10_2.x86_64 (getent) |
| Applies to | Linux distributions with Bash installed |
| Privilege | root or sudo — useradd and chpasswd require elevated rights |
| Scope | Colon-delimited bulk onboarding with useradd, per-row validation, idempotent skip for existing accounts, --dry-run, continue-on-row-error reporting, locked accounts by default, and optional unique random passwords with forced first-login change. Does not update existing users, parse RFC CSV with quoted commas, or roll back accounts that existed before the run. |
| Related guides | How to create a user in Linux Shell scripting course hub |
Bulk user creation needs more than a loop around useradd. A useful import script should reject malformed rows, verify groups and shells, skip accounts that already exist, support a dry run, and report which records succeeded or failed.
The script below reads a simple colon-delimited file and creates locked accounts by default. An optional password mode generates a unique initial credential and forces a change at first login.
Define the Bulk-User Input Format
Use one record per line with a fixed colon delimiter:
username:full_name:groups:shellExample file users.txt:
# onboarding batch — username:full_name:groups:shell
bulklab_alice:Alice Smith:bulklab_dev:/bin/bash
bulklab_bob:Bob Jones::/bin/bashusername— login name foruseraddfull_name— GECOS comment (-c); spaces are allowedgroups— optional comma-separated supplementary groups; leave empty for the default primary group onlyshell— executable login shell path
This file uses a simple colon-separated format; it is not a full CSV parser. If your source file has quoted commas, embedded newlines, or other CSV edge cases, Bash IFS parsing is not robust enough — import through python -c with the csv module, mlr, or another real CSV tool, then emit this colon format for the shell script.
Prerequisites and Safety Model
Run the script on a disposable VM first. Bulk account creation touches /etc/passwd, /etc/shadow, and home directories.
The script should refuse to run without elevated rights:
if [[ "$(id -u)" -ne 0 ]]; then
echo "ERROR: run as root or with sudo" >&2
exit 2
fiVerify required commands before reading the file:
for cmd in useradd getent chpasswd chage passwd; do
command -v "${cmd}" >/dev/null || exit 2
doneuseradd comes from shadow-utils and is the low-level account tool on RHEL, Rocky, AlmaLinux, and most server images. Debian and Ubuntu also ship useradd, but desktop tutorials often show adduser — an interactive helper with different defaults. For scripted bulk work, useradd stays predictable; see the useradd command reference for flags.
The script follows four safety rules:
- existing users are skipped, not modified
- invalid rows are reported and the run continues
- no plaintext passwords on stdout or in logs
- no deletion of accounts that existed before this run
Bash Script vs newusers
newusers is the batch account tool included with shadow-utils. It reads seven-field, passwd-style records and can create new accounts or modify accounts that already exist. That behavior may be exactly what you want for a controlled bulk import.
This article uses useradd one row at a time because the goal is different: existing users must be skipped, each row must be validated first, and dry-run and per-row status output are required. If your input is already passwd-compatible and newusers semantics fit your workflow, start with man newusers before writing a custom loop.
Create One User Correctly
A minimal locked account for bulklab_demo with home directory, shell, comment, and supplementary group:
useradd -m -s /bin/bash -c "Demo User" -G bulklab_dev bulklab_demoWithout -p or a later password-setting command, useradd creates the account with a locked password. In /etc/shadow the password field begins with !; passwd -S username reports L for the locked-password state. That is the recommended default when an administrator will provision credentials later through your identity system.
Check the account:
getent passwd bulklab_demobulklab_demo:x:1023:1025:Demo User:/home/bulklab_demo:/bin/bashLocked status:
passwd -S bulklab_demobulklab_demo L 2026-09-12 0 99999 7 -1The L status means the account cannot authenticate with a password until an administrator sets one. A locked password does not necessarily block other authentication methods such as SSH keys.
Read the File Line by Line
Never use for user in $(cat file). Word splitting breaks on spaces, and command substitution strips newlines unpredictably.
Read with a while loop so each physical line stays intact. Increment a line counter on every read so error messages point at the real source line:
line_no=0
while IFS= read -r line || [[ -n "${line}" ]]; do
line_no=$((line_no + 1))
line="${line//$'\r'/}"
[[ -z "${line//[[:space:]]/}" ]] && continue
[[ "${line}" =~ ^[[:space:]]*# ]] && continue
# parse and process "${line}" at physical line "${line_no}"
done <"${INPUT_FILE}"IFS= prevents trimming leading or trailing whitespace from the line. read -r keeps backslashes literal. Strip CR for files edited on Windows. Skip blank lines and # comments.
Validate Each Record
Parse exactly four colon-separated fields. Pad the line when reading so an empty groups column does not collapse:
IFS=: read -r username full_name supp_groups login_shell extra <<< "${line}::::"
if [[ -z "${username}" || -z "${login_shell}" || "${extra}" != ":::" ]]; then
echo "invalid field count"
exit 1
fiUse a variable name other than GROUPS for supplementary groups — Bash reserves GROUPS as a read-only array of the current process group IDs.
Linux useradd accepts a broader range of account names, so the regex below is an intentionally conservative policy for this script: lowercase letters, digits, _ and -, with a maximum length of 32 characters. Adjust the policy if your environment already has different account-naming rules.
validate_username() {
[[ "${1}" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]
}This script requires the configured shell path to exist and be executable:
validate_shell() {
[[ -n "${1}" && -x "${1}" ]]
}If your environment also requires login shells to appear in /etc/shells, add that policy check separately.
Normalize supplementary groups once, then validate and pass the same string to useradd -G:
normalize_supp_groups() {
local raw="$1" g result=""
local -a parts
SUPP_GROUPS=""
[[ -z "${raw}" ]] && return 0
IFS=',' read -ra parts <<< "${raw}"
for g in "${parts[@]}"; do
g="${g// /}"
[[ -n "${g}" ]] || continue
[[ -n "${result}" ]] && result+=","
result+="${g}"
done
SUPP_GROUPS="${result}"
}dev, ops becomes dev,ops before getent group checks and before -G. Quote every field when expanding into useradd arguments.
Skip Existing Users Safely
Check for an existing account before calling useradd so rerunning the same file is safe:
if getent passwd "${username}" >/dev/null; then
echo "skip existing: ${username}"
continue
fiDefault behavior is skip. Updating GECOS, groups, or shells for accounts that already exist is a different script with explicit --update semantics. This article does not mutate existing users.
Re-running the same input file should increase the skipped count, not produce useradd: user exists errors.
Create Home Directory, Shell and Groups
Build the useradd command as an array so dry-run mode can print the exact invocation:
cmd=(useradd -m -s "${login_shell}" -c "${full_name}")
if [[ -n "${supp_groups}" ]]; then
cmd+=(-G "${supp_groups}")
fi
cmd+=("${username}")
"${cmd[@]}"-m creates the user's home directory and populates it from the configured skeleton directory (normally /etc/skel). With typical defaults the home is /home/username, but administrators can change the base directory. Combine -s, -c, and -G in one call so partial state is less likely if a later step fails.
Add --dry-run
Dry-run should exercise validation and printing without calling useradd, writing credentials, or truncating output files:
if [[ "${DRY_RUN}" -eq 1 ]]; then
printf -v rendered '%q ' "${cmd[@]}"
printf 'would run: %s\n' "${rendered% }"
continue
fiInitialize the credentials file only for a live random-password run, after require_root and require_commands, and refuse to overwrite an existing path.
Run a rehearsal on the lab file (note physical line numbers — line 1 is the comment):
./bulk-create-users.sh -f users.txt --dry-runline 2: [DRY-RUN] bulklab_alice — would run: useradd -m -s /bin/bash -c Alice\ Smith -G bulklab_dev bulklab_alice
line 3: [DRY-RUN] bulklab_bob — would run: useradd -m -s /bin/bash -c Bob\ Jones bulklab_bob
line 4: [SKIPPED] bulklab_existing — account already exists
Summary: created=0 skipped=1 failed=0 dry_run=1No accounts, password files, or credential paths are modified while dry_run=1.
Report Per-User Status
Emit one line per input row with the physical line number:
printf 'line %s: [%s] %s — %s\n' "${line_no}" "${status}" "${username}" "${detail}"Statuses used in this script:
CREATED— new account addedSKIPPED—getent passwdfound an existing userFAILED— validation oruseradderror on this rowDRY-RUN— planned action only
End with totals:
printf '\nSummary: created=%s skipped=%s failed=%s dry_run=%s\n' \
"${created}" "${skipped}" "${failed}" "${dry_run}"That summary line is easy to scrape from cron mail.
Handle Partial Failure
Choose continue-on-row-error for onboarding files where one bad row should not block the rest. With set -euo pipefail, a bare "${cmd[@]}" or chpasswd pipeline still aborts the whole script on failure. Wrap each mutating step explicitly:
if ! "${cmd[@]}"; then
report_row "${line_no}" "FAILED" "useradd failed"
failed=$((failed + 1))
continue
fiIncrement failed, print the row result, and move on. Count a row as CREATED only after every step required by that password mode succeeds.
Return a non-zero exit when any row failed so automation still notices:
if [[ "${failed}" -gt 0 ]]; then
exit 1
fiIn this script, exit 1 means the batch finished but one or more rows failed. Exit 2 is reserved for problems that prevent the batch from running correctly, such as invalid options, a missing input file, or a missing required command. See Bash exit codes for how wrappers can interpret those values.
Do not delete accounts that existed before the run as a “rollback”. Only report what this invocation created, skipped, or failed.
Choose a Password/Account-Lock Strategy
Default (--password-mode locked): create the account without setting a password. The account remains locked until an administrator runs passwd or your provisioning system sets credentials.
If the onboarding workflow requires an initial password, use --password-mode random:
- pre-create the credentials file with
umask 077before processing any rows - generate a unique secret per user (
openssl rand -base64 18) - pipe
username:passwordtochpasswdon stdin - write credentials only to that root-only file
- never print passwords to the terminal or cron log
- never generate one password once and reuse it for every account
Random-password mode has several steps: create the account, set its password, force a password change, then save the generated credential. A failure after useradd means the Linux account may already exist even though onboarding did not finish.
In that case, mark the row FAILED and attempt to lock the password with passwd -l. Do not automatically delete the account; report the partial state so an administrator can inspect it.
if ! printf '%s:%s\n' "${username}" "${pw}" | chpasswd; then
passwd -l "${username}" >/dev/null 2>&1 || true
report_row "${line_no}" "FAILED" "account created but password setup failed; attempted password lock"
fiDeliver ${CREDENTIALS_FILE} through your vault or secure handoff channel. Delete it after distribution.
Force Password Change if Initial Credentials Are Issued
When you issue a random initial password, force rotation at first login:
chage -d 0 "${username}"Verify the policy on a test account:
chage -l bulklab_pwtestLast password change : password must be changed
Password expires : password must be changed
Password inactive : password must be changed
Account expires : neverchage -d 0 username sets the stored last-password-change value to 0, which forces a password change at the next login.
Verify Created Accounts
After a live run (not dry-run), confirm passwd entries and group membership:
getent passwd bulklab_alice bulklab_bobbulklab_alice:x:1023:1025:Alice Smith:/home/bulklab_alice:/bin/bash
bulklab_bob:x:1024:1026:Bob Jones:/home/bulklab_bob:/bin/bashSupplementary groups:
id bulklab_aliceuid=1023(bulklab_alice) gid=1025(bulklab_alice) groups=1025(bulklab_alice),1023(bulklab_dev)Homes exist:
ls -ld /home/bulklab_alice /home/bulklab_bobdrwx------. 3 bulklab_alice bulklab_alice 78 Sep 12 11:58 /home/bulklab_alice
drwx------. 3 bulklab_bob bulklab_bob 78 Sep 12 11:58 /home/bulklab_bobComplete Script
The full script combines option parsing, validation, dry-run, locked or random password modes, and per-row reporting. Save it as bulk-create-users.sh, mark it executable, and run on a test VM before production onboarding.
#!/usr/bin/env bash
set -euo pipefail
INPUT_FILE=""
DRY_RUN=0
PASSWORD_MODE="locked"
CREDENTIALS_FILE=""
EXIT_OK=0
EXIT_PROBLEM=1
EXIT_CONFIG=2
CREATED=0
SKIPPED=0
FAILED=0
usage() {
cat <<'EOF'
Usage: bulk-create-users.sh -f FILE [--dry-run] [--password-mode locked|random]
[--credentials-file PATH]
Input format (one user per line, colon-separated):
username:full_name:groups:shell
EOF
}
log() { printf '%s\n' "$*" >&2; }
die() { log "ERROR: $*"; exit "${EXIT_CONFIG}"; }
require_root() {
[[ "$(id -u)" -eq 0 ]] || die "run as root or with sudo"
}
require_commands() {
local cmd
for cmd in useradd getent chpasswd chage passwd; do
command -v "${cmd}" >/dev/null 2>&1 || die "required command not found: ${cmd}"
done
}
init_credentials_file() {
if [[ "${PASSWORD_MODE}" != random || "${DRY_RUN}" -eq 1 ]]; then
return 0
fi
[[ -n "${CREDENTIALS_FILE}" ]] || die "--credentials-file is required with --password-mode random"
[[ ! -e "${CREDENTIALS_FILE}" ]] || die "credentials file already exists: ${CREDENTIALS_FILE}"
(umask 077; : >"${CREDENTIALS_FILE}") || die "cannot create credentials file: ${CREDENTIALS_FILE}"
}
validate_username() {
[[ "${1}" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]
}
validate_shell() {
[[ -n "${1}" && -x "${1}" ]]
}
validate_groups() {
local groups_csv="$1" g
[[ -z "${groups_csv}" ]] && return 0
IFS=',' read -ra group_list <<< "${groups_csv}"
for g in "${group_list[@]}"; do
[[ -n "${g}" ]] || continue
getent group "${g}" >/dev/null 2>&1 || return 1
done
}
normalize_supp_groups() {
local raw="$1" g result=""
local -a parts
SUPP_GROUPS=""
[[ -z "${raw}" ]] && return 0
IFS=',' read -ra parts <<< "${raw}"
for g in "${parts[@]}"; do
g="${g// /}"
[[ -n "${g}" ]] || continue
[[ -n "${result}" ]] && result+=","
result+="${g}"
done
SUPP_GROUPS="${result}"
}
parse_row() {
local line="$1" username full_name supp_groups login_shell extra
IFS=: read -r username full_name supp_groups login_shell extra <<< "${line}::::"
if [[ -z "${username}" || -z "${login_shell}" || "${extra}" != ":::" ]]; then
return 1
fi
USERNAME="${username}"
FULL_NAME="${full_name}"
normalize_supp_groups "${supp_groups}"
LOGIN_SHELL="${login_shell}"
}
report_row() {
local line_no="$1" status="$2" detail="$3"
printf 'line %s: [%s] %s — %s\n' "${line_no}" "${status}" "${USERNAME:-?}" "${detail}"
}
render_dry_run() {
local -a cmd=("$@")
local rendered
printf -v rendered '%q ' "${cmd[@]}"
printf '%s' "${rendered% }"
}
create_user_locked() {
local -a cmd=(useradd -m -s "${LOGIN_SHELL}" -c "${FULL_NAME}")
[[ -n "${SUPP_GROUPS}" ]] && cmd+=(-G "${SUPP_GROUPS}")
cmd+=("${USERNAME}")
if [[ "${DRY_RUN}" -eq 1 ]]; then
report_row "${LINE_NO}" "DRY-RUN" "would run: $(render_dry_run "${cmd[@]}")"
return 0
fi
if ! "${cmd[@]}"; then
report_row "${LINE_NO}" "FAILED" "useradd failed"
FAILED=$((FAILED + 1))
return 0
fi
report_row "${LINE_NO}" "CREATED" "locked account, home created"
CREATED=$((CREATED + 1))
}
create_user_random_password() {
local pw rendered
local -a cmd=(useradd -m -s "${LOGIN_SHELL}" -c "${FULL_NAME}")
[[ -n "${SUPP_GROUPS}" ]] && cmd+=(-G "${SUPP_GROUPS}")
cmd+=("${USERNAME}")
if [[ "${DRY_RUN}" -eq 1 ]]; then
rendered="$(render_dry_run "${cmd[@]}")"
report_row "${LINE_NO}" "DRY-RUN" "would run: ${rendered}; would set unique password and chage -d 0"
return 0
fi
pw="$(openssl rand -base64 18)"
if ! "${cmd[@]}"; then
report_row "${LINE_NO}" "FAILED" "useradd failed"
FAILED=$((FAILED + 1))
return 0
fi
if ! printf '%s:%s\n' "${USERNAME}" "${pw}" | chpasswd; then
passwd -l "${USERNAME}" >/dev/null 2>&1 || true
report_row "${LINE_NO}" "FAILED" "account created but password setup failed; attempted password lock"
FAILED=$((FAILED + 1))
return 0
fi
if ! chage -d 0 "${USERNAME}"; then
passwd -l "${USERNAME}" >/dev/null 2>&1 || true
report_row "${LINE_NO}" "FAILED" "password set but chage failed; attempted password lock"
FAILED=$((FAILED + 1))
return 0
fi
if ! printf '%s\t%s\n' "${USERNAME}" "${pw}" >>"${CREDENTIALS_FILE}"; then
passwd -l "${USERNAME}" >/dev/null 2>&1 || true
report_row "${LINE_NO}" "FAILED" "password set but credentials file write failed; attempted password lock"
FAILED=$((FAILED + 1))
return 0
fi
report_row "${LINE_NO}" "CREATED" "password set; first login must change password"
CREATED=$((CREATED + 1))
}
process_row() {
local line="$1" line_no="$2"
LINE_NO="${line_no}"
USERNAME=""; FULL_NAME=""; SUPP_GROUPS=""; LOGIN_SHELL=""
if ! parse_row "${line}"; then
report_row "${line_no}" "FAILED" "expected username:full_name:groups:shell"
FAILED=$((FAILED + 1)); return 0
fi
if ! validate_username "${USERNAME}"; then
report_row "${line_no}" "FAILED" "username failed policy check"
FAILED=$((FAILED + 1)); return 0
fi
if ! validate_shell "${LOGIN_SHELL}"; then
report_row "${line_no}" "FAILED" "shell not executable: ${LOGIN_SHELL}"
FAILED=$((FAILED + 1)); return 0
fi
if ! validate_groups "${SUPP_GROUPS}"; then
report_row "${line_no}" "FAILED" "missing supplementary group: ${SUPP_GROUPS}"
FAILED=$((FAILED + 1)); return 0
fi
if getent passwd "${USERNAME}" >/dev/null 2>&1; then
report_row "${line_no}" "SKIPPED" "account already exists"
SKIPPED=$((SKIPPED + 1)); return 0
fi
case "${PASSWORD_MODE}" in
locked) create_user_locked ;;
random) create_user_random_password ;;
*) die "unsupported PASSWORD_MODE: ${PASSWORD_MODE}" ;;
esac
}
while [[ $# -gt 0 ]]; do
case "$1" in
-f)
[[ $# -ge 2 ]] || die "-f requires a file path"
INPUT_FILE=$2
shift 2
;;
--dry-run) DRY_RUN=1; shift ;;
--password-mode)
[[ $# -ge 2 ]] || die "--password-mode requires locked or random"
PASSWORD_MODE=$2
shift 2
;;
--credentials-file)
[[ $# -ge 2 ]] || die "--credentials-file requires a path"
CREDENTIALS_FILE=$2
shift 2
;;
-h|--help) usage; exit 0 ;;
*) die "unknown option: $1" ;;
esac
done
[[ -n "${INPUT_FILE}" ]] || die "input file required (-f)"
[[ -f "${INPUT_FILE}" ]] || die "input file not found: ${INPUT_FILE}"
case "${PASSWORD_MODE}" in locked|random) ;; *) die "--password-mode must be locked or random" ;; esac
if [[ "${PASSWORD_MODE}" == random ]]; then
[[ -n "${CREDENTIALS_FILE}" ]] || die "--credentials-file is required with --password-mode random"
command -v openssl >/dev/null 2>&1 || die "openssl is required for --password-mode random"
fi
require_root
require_commands
init_credentials_file
line_no=0
while IFS= read -r line || [[ -n "${line}" ]]; do
line_no=$((line_no + 1))
line="${line//$'\r'/}"
[[ -z "${line//[[:space:]]/}" ]] && continue
[[ "${line}" =~ ^[[:space:]]*# ]] && continue
process_row "${line}" "${line_no}"
done <"${INPUT_FILE}"
printf '\nSummary: created=%s skipped=%s failed=%s dry_run=%s\n' \
"${CREATED}" "${SKIPPED}" "${FAILED}" "${DRY_RUN}"
[[ "${FAILED}" -gt 0 ]] && exit "${EXIT_PROBLEM}"
exit "${EXIT_OK}"Test Valid and Invalid Input Rows
Create the supplementary group and one pre-existing user for skip testing:
groupadd bulklab_devSeed one account so the skip path is testable on the next run:
useradd -m -s /bin/bash -c "Existing User" bulklab_existingValid batch file (same layout as the dry-run example):
# onboarding batch
bulklab_alice:Alice Smith:bulklab_dev:/bin/bash
bulklab_bob:Bob Jones::/bin/bash
bulklab_existing:Existing User::/bin/bashLive run:
./bulk-create-users.sh -f users.txtline 2: [CREATED] bulklab_alice — locked account, home created
line 3: [CREATED] bulklab_bob — locked account, home created
line 4: [SKIPPED] bulklab_existing — account already exists
Summary: created=2 skipped=1 failed=0 dry_run=0Invalid rows file:
bad name:Bad Name::/bin/bash
bulklab_carol:Carol Lee:missing_grp:/bin/bash
bulklab_dan:Dan Lee::/bin/nosuchshell
too:many:colons:here:extraRun the same script against the invalid file — expect FAILED rows and exit 1:
./bulk-create-users.sh -f invalid.txtline 1: [FAILED] bad name — username failed policy check
line 2: [FAILED] bulklab_carol — missing supplementary group: missing_grp
line 3: [FAILED] bulklab_dan — shell not executable: /bin/nosuchshell
line 4: [FAILED] ? — expected username:full_name:groups:shell
Summary: created=0 skipped=0 failed=4 dry_run=0The script exits 1 when failed is greater than zero. Valid rows in the same file would still be processed before the summary — add bulklab_eve:Eve Lee:bulklab_dev:/bin/bash to a mixed file to confirm partial success alongside failures.
Avoid Dangerous Bulk-User Patterns
| Pattern | Risk | Safer approach |
|---|---|---|
for u in $(cat users.txt) |
word splitting, lost blank lines | while IFS= read -r line |
one shared password=... for all users |
one leak compromises everyone | locked accounts or unique openssl rand per row |
calling passwd separately for every generated password |
less portable for batch automation; plaintext can leak through tracing or careless logging | feed username:password to chpasswd via stdin with tracing disabled |
re-run without getent check |
useradd errors or duplicate UIDs |
skip existing users explicitly |
silent useradd in a loop |
no audit trail | per-line status and summary totals |
| deleting “rollback” users | removes legitimate old accounts | report only; never delete pre-existing users |
Treat any credentials file like a secret. Do not commit it to git, do not mail it unencrypted, and remove it after handoff.
Summary
This Bash bulk user script reads colon-delimited records (username:full_name:groups:shell), validates names against a documented policy, checks shells and groups with getent, and skips accounts that already exist. Dry-run mode prints the exact useradd command list without changing the system.
The default path creates locked accounts — no shared initial password and nothing sensitive on stdout. When you must issue credentials, generate a unique random password per user, load it through chpasswd, store it in a mode-600 file, and run chage -d 0 so first login forces a change. The script continues past bad rows, reports line numbers, and exits 1 when any row fails so automation can still alert operators.
Run it on a disposable VM, verify with getent passwd and id, then schedule or hand the input file to your onboarding pipeline.
References
- newusers(8) — shadow-utils batch create/update from passwd-format files
- useradd(8) — account creation flags
- chpasswd(8) — bulk password updates on stdin
- chage(1) — password ageing and forced change
- getent(1) — NSS lookups for passwd and group
- passwd(5) — account and shadow field layout
Frequently Asked Questions
Should existing users be skipped or updated?
Skip by default. Use getent passwd before useradd and report SKIPPED when the name already exists. Updating GECOS fields, groups, or shells for existing accounts is a separate tool with explicit update semantics; mixing update logic into a create-only script hides accidental changes.
Can I parse CSV with quoted commas in Bash?
Not reliably with IFS and read alone. RFC-style CSV can contain quoted commas, embedded quotes, and multiline fields. Export to the simple colon format this script expects, or parse with Python csv, mlr, or another dedicated parser, then call useradd from Bash with validated fields.
How do I create accounts without a password?
Call useradd without -p and without chpasswd. On shadow-utils systems the account is locked until an administrator sets a password. That is the default --password-mode locked path in this script.
How do I force first-login password change?
After setting an initial password with chpasswd, run chage -d 0 username. The next successful authentication requires choosing a new password. Do this only when you intentionally issue a temporary credential.
Why not use for user in $(cat file)?
Command substitution strips line boundaries and shell word splitting breaks on spaces. A while IFS= read -r line loop preserves each record, supports comments and blank lines, and pairs naturally with per-line validation and error reporting.

