Create Interactive Menus with Bash select

Tested on RHEL 10.2 (Coughlan)
Package bash 5.2.26-6.el10
Applies to Linux distributions with Bash installed
Privilege Normal user
Scope Bash select syntax, numbered menus on standard error, PS3 prompts, the selected variable and REPLY, invalid-choice validation, case branches, menus from arrays and globs, function arguments as menu items, quit with break, and blank-line or EOF behavior. Does not cover full case pattern reference, array CRUD, or interactive read option lists.
Related guides Bash shell script tutorial
Shell scripting course hub
Short on time?Beta Get the gist of this 11-minute read in seconds, then ask follow-up questions

Bash select creates a numbered terminal menu without manually printing and parsing every option. PS3 controls the prompt, the loop variable holds the selected item, and REPLY holds the input line the user entered.

Bash select is not part of POSIX sh. Run scripts with a Bash shebang and invoke Bash directly — see Bash vs sh before pointing /bin/sh at a menu script. Bash prints the numbered list once, then waits at the PS3 prompt; the list is printed again only when the user submits a blank line. The loop keeps going until you break, return, or hit EOF.

Need Use
Create menu select item in ...; do ...; done
Change prompt PS3='Choose: '
Selected text $item
Raw input / menu number $REPLY
Invalid choice [[ -z $item ]]
Exit menu break

Bash select Syntax

A select loop has the same shape as for, but Bash drives the menu and input for you:

text
select NAME [in WORDS]; do
  COMMANDS
done

When you name words after in, Bash numbers each word and waits for the user to type a number. Omit in WORDS and Bash uses the script's positional parameters ("$@") instead. The numbered menu goes to standard error so you can redirect normal script output without hiding the choices.


Build Your First Numbered Menu

Start with a deployment picker backed by an array:

bash
PS3='Choose deployment target (1-3): '
environments=(production staging development)
select env in "${environments[@]}"; do
  echo "Deploying to $env"
  break
done

Pipe 2 as the menu choice to run the same block non-interactively:

bash
printf '2\n' | bash -c 'PS3="Choose deployment target (1-3): "; environments=(production staging development); select env in "${environments[@]}"; do echo "Deploying to $env"; break; done'
output
1) production
2) staging
3) development
Choose deployment target (1-3): Deploying to staging

The menu lines and PS3 prompt appear on stderr; Deploying to staging is ordinary stdout from echo. Option 2 maps to staging, which lands in env.


Customize the Prompt with PS3

PS3 holds the prompt string Bash prints after the numbered list. The default is #? , which is easy to miss in a real script. Set it before select:

bash
PS3='Region code [1-3]: '
regions=(us-east eu-west ap-south)
select region in "${regions[@]}"; do
  echo "Selected region: $region"
  break
done

Run with choice 1 to confirm the custom prompt:

bash
printf '1\n' | bash -c 'PS3="Region code [1-3]: "; regions=(us-east eu-west ap-south); select region in "${regions[@]}"; do echo "Selected region: $region"; break; done'
output
1) us-east
2) eu-west
3) ap-south
Region code [1-3]: Selected region: us-east

Keep the prompt short and mention the valid range when the list is longer than a few items. Do not put spaces around = in the assignment: use PS3='Choose: ', not PS3= 'Choose: '.


Get the Selected Value and Menu Number with REPLY

After Enter, the loop variable gets the word that matches the number and REPLY gets the raw input line. If you need the numeric menu choice rather than the selected text, read $REPLY. Inspect both:

bash
printf '2\n' | bash -c 'PS3="Pick: "; select x in alpha beta gamma; do echo "x=$x REPLY=$REPLY"; break; done'
output
1) alpha
2) beta
3) gamma
Pick: x=beta REPLY=2

REPLY is the same variable Bash read uses when you omit a variable name. In select, it always holds what was typed, whether or not that number matched a menu item.


Validate Invalid Menu Choices

An out-of-range number or non-numeric text leaves the selected variable empty while REPLY still records the input. Bash prints no error. Wrap the check in Bash if else before you act on the selected word:

bash
PS3='Pick an environment: '
select env in production staging development; do
  if [[ -z $env ]]; then
    echo "Invalid choice: $REPLY (enter 1-3)" >&2
    continue
  fi
  echo "Using $env"
  break
done

Feed an out-of-range number followed by a valid one:

bash
printf '9\n2\n' | bash -c 'PS3="Pick an environment: "; select env in production staging development; do if [[ -z $env ]]; then echo "Invalid choice: $REPLY (enter 1-3)" >&2; continue; fi; echo "Using $env"; break; done'
output
1) production
2) staging
3) development
Pick an environment: Invalid choice: 9 (enter 1-3)
Using staging

continue skips the rest of the body and returns to the next PS3 prompt without exiting the script. That pattern is safer than assuming a non-empty variable after every select pass.


Use select with case

Branch on the selected word with Bash case:

bash
PS3='Action: '
select action in deploy status logs Quit; do
  case $action in
    deploy) echo "Running deploy..." ;;
    status) echo "Checking status..." ;;
    logs)   echo "Tailing logs..." ;;
    Quit)   echo "Bye."; break ;;
    *)      echo "Invalid"; continue ;;
  esac
  break
done

Choose status to see the matching branch run:

bash
printf '2\n' | bash -c 'PS3="Action: "; select action in deploy status logs Quit; do case $action in deploy) echo "Running deploy...";; status) echo "Checking status...";; logs) echo "Tailing logs...";; Quit) echo "Bye."; break;; *) echo "Invalid"; continue;; esac; break; done'
output
1) deploy
2) status
3) logs
4) Quit
Action: Checking status...

The * branch catches an empty $action when validation was skipped.


Build a Menu from an Array

"${array[@]}" expands to separate words even when items contain spaces:

bash
regions=("us east" "eu west" "ap south")
PS3='Choose region: '
select region in "${regions[@]}"; do
  echo "Region is: $region"
  break
done

Pick option 2 to confirm multi-word items stay intact:

bash
printf '2\n' | bash -c 'regions=("us east" "eu west" "ap south"); PS3="Choose region: "; select region in "${regions[@]}"; do echo "Region is: $region"; break; done'
output
1) us east
2) eu west
3) ap south
Choose region: Region is: eu west

Quote "${regions[@]}" every time; "${regions[*]}" can collapse entries. See split string into array to build the list from text.


Use Function Arguments as Menu Items

Without in WORDS, select reads from "$@" inside a Bash function:

bash
pick_one() {
  PS3='Choose: '
  select item; do
    echo "You picked: $item"
    break
  done
}

pick_one alpha beta gamma

Call it with a piped choice of 1:

bash
printf '1\n' | bash -c 'pick_one() { PS3="Choose: "; select item; do echo "You picked: $item"; break; done; }; pick_one alpha beta gamma'
output
1) alpha
2) beta
3) gamma
Choose: You picked: alpha

Menu items come from the call-site arguments instead of a hard-coded array inside the function.


Add a Quit Option with break

select does not exit after one successful pick. Add a quit item and use break when the user chooses it:

bash
PS3='Select (or choose Quit): '
select choice in start stop restart Quit; do
  case $choice in
    Quit)
      echo "Exiting."
      break
      ;;
    '')
      echo "Invalid selection." >&2
      continue
      ;;
    *)
      echo "Would run: $choice"
      break
      ;;
  esac
done

Send 4 to select Quit:

bash
printf '4\n' | bash -c 'PS3="Select (or choose Quit): "; select choice in start stop restart Quit; do case $choice in Quit) echo "Exiting."; break;; "") echo "Invalid selection." >&2; continue;; *) echo "Would run: $choice"; break;; esac; done'
output
1) start
2) stop
3) restart
4) Quit
Select (or choose Quit): Exiting.

Without break, select prompts for another choice after the loop body finishes. The numbered list is redisplayed when the user enters a blank line, not after every normal pick.


Select Files Safely

Build the file list with a Bash globbing pattern into an array instead of parsing ls output. Filenames with spaces stay one menu entry per file:

bash
(
  shopt -s nullglob
  config_dir=./configs
  configs=("$config_dir"/*.conf)

  ((${#configs[@]})) || {
    echo 'No .conf files found.' >&2
    exit 1
  }

  PS3='Config file: '
  select cfg in "${configs[@]}"; do
    [[ -n $cfg ]] || { echo 'Invalid.' >&2; continue; }
    echo "Editing $cfg"
    break
  done
)

Create a configs directory with two sample files:

bash
mkdir -p configs && printf 'port=8080\n' > configs/app.conf && printf 'debug=1\n' > configs/dev.conf

Pipe 1 to pick the first .conf file from the glob:

bash
printf '1\n' | bash -c '( shopt -s nullglob; config_dir=./configs; configs=("$config_dir"/*.conf); ((${#configs[@]})) || { echo "No .conf files found." >&2; exit 1; }; PS3="Config file: "; select cfg in "${configs[@]}"; do [[ -n $cfg ]] || { echo "Invalid." >&2; continue; }; echo "Editing $cfg"; break; done )'
output
1) ./configs/app.conf
2) ./configs/dev.conf
Config file: Editing ./configs/app.conf

The subshell enables nullglob only for this block, so a caller's existing shell options stay unchanged. With nullglob, an unmatched glob yields an empty array and the script exits before showing an empty picker. Because the menu runs in a subshell, variables set inside it do not persist after the closing ). Perform the action that uses $cfg inside the block, or preserve and restore the nullglob setting instead if you need the selected value afterward.


What Happens on Blank Input and EOF?

An empty line reprints the menu. The selected variable stays empty and REPLY is blank:

bash
printf '\n1\n' | bash -c 'PS3="Pick: "; select x in a b; do echo "x=${x:-empty}"; break; done' 2>&1
output
1) a
2) b
Pick: 1) a
2) b
Pick: x=a

The menu block appears twice because the blank line triggered a redraw. EOF (Ctrl+D or closed input) ends the loop with exit status 1 — Bash exit codes explains how to read $? when input ends before a valid choice:

bash
printf '' | bash -c 'PS3="Pick: "; select x in a b; do echo "x=$x"; done; echo "exit=$?"'
output
1) a
2) b
Pick: exit=1

Check $? after the loop if you must distinguish a deliberate quit from unexpected EOF on a pipe.


When select Is a Good Fit—and When It Is Not

select fits terminal-only admin scripts and quick wrappers where a numbered list is enough.

Good fit Poor fit
Pick one item from a short list you control Arrow-key TUIs or GUI dialogs
Operator runs the script in an interactive terminal Non-interactive CI or cron jobs
You want stderr menus separate from stdout data Remote APIs or web front ends

For cron or CI, pass the choice through Bash getopts flags or environment variables instead of a menu.


Summary

Bash select prints a numbered menu to stderr, reads a line into REPLY, and sets the loop variable to the matching word. Set PS3 for a clear prompt, validate with [[ -z $var ]] when the number is wrong, and branch with case once you have a real selection.

The loop runs again after every pick unless you break or return; the next pass returns to the PS3 prompt without reprinting the numbered list unless the user enters a blank line. Add a Quit item, build lists from "${array[@]}" or function "$@", and load filenames from globs instead of ls. EOF exits with status 1. Reserve select for interactive terminals and pass arguments directly when no human is at the keyboard.


References


Frequently Asked Questions

What is REPLY in Bash select?

REPLY is a Bash variable that stores the raw input line entered at the PS3 prompt. It holds the text whether or not that input matched a menu number. The loop variable gets the selected word only when the number is valid.

Why does select keep looping?

select is designed as a loop, not a one-shot question. After each selection, Bash runs the do block and returns to the PS3 prompt. The numbered list is printed again only when the user submits a blank line. Use break or return inside the body when you are done, or add a Quit option that breaks out.

How do I handle invalid input?

Test whether the loop variable is empty after each selection. An out-of-range number or non-numeric text leaves the variable unset while REPLY still records what was typed. Print an error, then continue to skip the rest of the body and return to the PS3 prompt.

Why do I get select: not found?

select is not POSIX sh syntax. Use a Bash shebang such as #!/usr/bin/env bash and run the script directly or with bash script.sh. Running sh script.sh can invoke a different shell that does not support select.

How do I change the prompt?

Assign the PS3 variable before the select statement. Bash prints the value of PS3 after the numbered list. You can include hints such as the valid numeric range in that string.

Can menu items contain spaces?

Yes, when each item is a separate element in an array and you expand with "${array[@]}". Bash treats one array element as one menu line even if the text contains spaces. Use "${array[@]}" so each element stays a separate menu item. Avoid "${array[*]}" here because it expands the array as a single word.

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)