Create Linux Users from a File with Bash

Tested on RHEL 10.2 (Coughlan) — disposable lab VM
Package bash-5.2.26-6.el10.x86_64
shadow-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
Short on time?Beta Get the gist of this 17-minute read in seconds, then ask follow-up questions

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:

text
username:full_name:groups:shell

Example file users.txt:

text
# onboarding batch — username:full_name:groups:shell
bulklab_alice:Alice Smith:bulklab_dev:/bin/bash
bulklab_bob:Bob Jones::/bin/bash
  • username — login name for useradd
  • full_name — GECOS comment (-c); spaces are allowed
  • groups — optional comma-separated supplementary groups; leave empty for the default primary group only
  • shell — 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:

bash
if [[ "$(id -u)" -ne 0 ]]; then
  echo "ERROR: run as root or with sudo" >&2
  exit 2
fi

Verify required commands before reading the file:

bash
for cmd in useradd getent chpasswd chage passwd; do
  command -v "${cmd}" >/dev/null || exit 2
done

useradd 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:

bash
useradd -m -s /bin/bash -c "Demo User" -G bulklab_dev bulklab_demo

Without -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:

bash
getent passwd bulklab_demo
output
bulklab_demo:x:1023:1025:Demo User:/home/bulklab_demo:/bin/bash

Locked status:

bash
passwd -S bulklab_demo
output
bulklab_demo L 2026-09-12 0 99999 7 -1

The 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:

bash
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:

bash
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
fi

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

bash
validate_username() {
  [[ "${1}" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]
}

This script requires the configured shell path to exist and be executable:

bash
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:

bash
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:

bash
if getent passwd "${username}" >/dev/null; then
  echo "skip existing: ${username}"
  continue
fi

Default 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:

bash
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:

bash
if [[ "${DRY_RUN}" -eq 1 ]]; then
  printf -v rendered '%q ' "${cmd[@]}"
  printf 'would run: %s\n' "${rendered% }"
  continue
fi

Initialize 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):

bash
./bulk-create-users.sh -f users.txt --dry-run
output
line 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=1

No 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:

bash
printf 'line %s: [%s] %s — %s\n' "${line_no}" "${status}" "${username}" "${detail}"

Statuses used in this script:

  • CREATED — new account added
  • SKIPPED — getent passwd found an existing user
  • FAILED — validation or useradd error on this row
  • DRY-RUN — planned action only

End with totals:

bash
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:

bash
if ! "${cmd[@]}"; then
  report_row "${line_no}" "FAILED" "useradd failed"
  failed=$((failed + 1))
  continue
fi

Increment 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:

bash
if [[ "${failed}" -gt 0 ]]; then
  exit 1
fi

In 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:

  1. pre-create the credentials file with umask 077 before processing any rows
  2. generate a unique secret per user (openssl rand -base64 18)
  3. pipe username:password to chpasswd on stdin
  4. write credentials only to that root-only file
  5. never print passwords to the terminal or cron log
  6. 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.

bash
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"
fi

Deliver ${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:

bash
chage -d 0 "${username}"

Verify the policy on a test account:

bash
chage -l bulklab_pwtest
output
Last password change					: password must be changed
Password expires					: password must be changed
Password inactive					: password must be changed
Account expires						: never

chage -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:

bash
getent passwd bulklab_alice bulklab_bob
output
bulklab_alice:x:1023:1025:Alice Smith:/home/bulklab_alice:/bin/bash
bulklab_bob:x:1024:1026:Bob Jones:/home/bulklab_bob:/bin/bash

Supplementary groups:

bash
id bulklab_alice
output
uid=1023(bulklab_alice) gid=1025(bulklab_alice) groups=1025(bulklab_alice),1023(bulklab_dev)

Homes exist:

bash
ls -ld /home/bulklab_alice /home/bulklab_bob
output
drwx------. 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_bob

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

bash
#!/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:

bash
groupadd bulklab_dev

Seed one account so the skip path is testable on the next run:

bash
useradd -m -s /bin/bash -c "Existing User" bulklab_existing

Valid batch file (same layout as the dry-run example):

text
# onboarding batch
bulklab_alice:Alice Smith:bulklab_dev:/bin/bash
bulklab_bob:Bob Jones::/bin/bash
bulklab_existing:Existing User::/bin/bash

Live run:

bash
./bulk-create-users.sh -f users.txt
output
line 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=0

Invalid rows file:

text
bad name:Bad Name::/bin/bash
bulklab_carol:Carol Lee:missing_grp:/bin/bash
bulklab_dan:Dan Lee::/bin/nosuchshell
too:many:colons:here:extra

Run the same script against the invalid file — expect FAILED rows and exit 1:

bash
./bulk-create-users.sh -f invalid.txt
output
line 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=0

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

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)