Bash Scripting
Arguments in Bash
Scripting Techniques
Bash Script Parameters
Linux Programming

Check number of arguments passed to a Bash script

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

In Bash, the special variable $# holds the count of positional arguments passed to a script or function. Checking this count near the top of your script is one of the simplest ways to fail fast, give users a clear usage message, and prevent downstream errors caused by missing input.

bash
1#!/usr/bin/env bash
2
3if [[ "$#" -ne 2 ]]; then
4    echo "Usage: $0 SOURCE DESTINATION" >&2
5    exit 1
6fi

That single guard is enough to turn a confusing runtime failure into an immediate, actionable error message. The rest of this article covers exact counts, minimum/maximum ranges, usage functions, the transition to getopts, and the quoting rules that keep your argument handling correct.

Checking for an Exact Number of Arguments

The most straightforward check is an exact match. If a script requires exactly three arguments, compare $# against 3 with -ne (not equal).

bash
1#!/usr/bin/env bash
2
3if [[ "$#" -ne 3 ]]; then
4    echo "Usage: $0 HOST PORT DATABASE" >&2
5    exit 1
6fi
7
8host="$1"
9port="$2"
10database="$3"
11
12printf 'Connecting to %s:%s/%s\n' "$host" "$port" "$database"

The -ne operator compares integers. Because $# is always an integer, this comparison is safe and idiomatic.

Checking Minimum and Maximum Counts

Scripts with optional arguments need range checks instead of exact matches.

Use -lt (less than) for a minimum.

bash
1if [[ "$#" -lt 2 ]]; then
2    echo "Usage: $0 INPUT OUTPUT [VERBOSE]" >&2
3    exit 1
4fi

Combine -lt and -gt for a full range.

bash
1if [[ "$#" -lt 2 || "$#" -gt 4 ]]; then
2    echo "Usage: $0 INPUT OUTPUT [FORMAT] [TIMEOUT]" >&2
3    exit 1
4fi

This pattern makes it clear which arguments are required and which are optional, directly in the validation logic.

Comparison Operators Reference

OperatorMeaningExample
-eqEqual to[[ "$#" -eq 3 ]]
-neNot equal to[[ "$#" -ne 3 ]]
-ltLess than[[ "$#" -lt 2 ]]
-leLess than or equal[[ "$#" -le 5 ]]
-gtGreater than[[ "$#" -gt 4 ]]
-geGreater than or equal[[ "$#" -ge 1 ]]

All of these work inside both [[ ]] (Bash-specific) and [ ] (POSIX-compatible) test brackets.

Writing a Usage Function

For scripts beyond a one-liner, extracting the validation message into a function keeps the top of the script clean and makes it easy to show the same message from multiple places.

bash
1#!/usr/bin/env bash
2
3usage() {
4    cat <<EOF >&2
5Usage: $0 INPUT OUTPUT [MODE]
6
7Arguments:
8  INPUT   Path to the source file
9  OUTPUT  Path to the destination file
10  MODE    Processing mode (default: fast)
11EOF
12    exit 1
13}
14
15[[ "$#" -lt 2 || "$#" -gt 3 ]] && usage
16
17input="$1"
18output="$2"
19mode="${3:-fast}"
20
21printf 'Processing %s -> %s (mode=%s)\n' "$input" "$output" "$mode"

The ${3:-fast} syntax assigns a default value when the third argument is not provided. This pairs naturally with a minimum-count check that allows the argument to be absent.

Default Values for Optional Arguments

Bash provides two forms of default assignment.

bash
1# Use "fast" if $3 is unset or empty
2mode="${3:-fast}"
3
4# Use "fast" only if $3 is unset (empty string is kept)
5mode="${3-fast}"

The :- form is almost always what you want, because an empty string passed as an argument is rarely intentional.

bash
1#!/usr/bin/env bash
2
3[[ "$#" -lt 1 ]] && { echo "Usage: $0 NAME [GREETING]" >&2; exit 1; }
4
5name="$1"
6greeting="${2:-Hello}"
7
8printf '%s, %s!\n' "$greeting" "$name"

Running ./greet.sh Alice prints Hello, Alice! while ./greet.sh Alice Hi prints Hi, Alice!.

Transitioning to getopts for Flags and Options

$# handles positional arguments well. Once a script needs named flags like -v, -o FILE, or --help, raw argument counting is no longer sufficient. The getopts built-in parses flags and their values.

bash
1#!/usr/bin/env bash
2
3verbose=false
4output=""
5
6usage() {
7    echo "Usage: $0 -i INPUT -o OUTPUT [-v]" >&2
8    exit 1
9}
10
11while getopts ':vi:o:' opt; do
12    case "$opt" in
13        v) verbose=true ;;
14        i) input="$OPTARG" ;;
15        o) output="$OPTARG" ;;
16        *) usage ;;
17    esac
18done
19
20# Validate required options after parsing
21[[ -z "$input" || -z "$output" ]] && usage
22
23if $verbose; then
24    printf 'Input: %s\nOutput: %s\n' "$input" "$output"
25fi

The important insight is that argument counting and argument parsing are complementary tasks. For pure positional scripts, $# is sufficient. For flag-based scripts, getopts handles the structure while you validate required options separately after parsing.

Checking Arguments Inside Functions

$# works inside functions too, where it counts the arguments passed to that function, not the script.

bash
1#!/usr/bin/env bash
2
3deploy() {
4    if [[ "$#" -ne 2 ]]; then
5        echo "deploy requires exactly 2 arguments: ENVIRONMENT VERSION" >&2
6        return 1
7    fi
8
9    local env="$1"
10    local version="$2"
11    printf 'Deploying version %s to %s\n' "$version" "$env"
12}
13
14deploy "$@"

Using "$@" to forward all script arguments to the function preserves quoting and spacing. This pattern keeps validation close to the code that uses the arguments.

Why Quoting Always Matters

Even in scripts that only check argument count, always quote positional parameters when you use them. Unquoted parameters are subject to word splitting and pathname expansion, which can change the meaning of input.

bash
1# Wrong: word splitting can break this
2printf 'first argument: %s\n' $1
3
4# Correct: quoting preserves the original value
5printf 'first argument: %s\n' "$1"

A filename like my report.pdf passed as a single argument will be split into two words without quotes, causing subtle and hard-to-trace bugs.

Exit Codes and Automation

Returning a nonzero exit code on incorrect usage is not just convention. It lets calling scripts, CI pipelines, and orchestration tools detect that the command failed for a predictable reason.

Exit CodeCommon Meaning
0Success
1General error or bad usage
2Misuse of shell built-in (Bash convention)
126Command found but not executable
127Command not found

Using exit 1 for bad usage is the standard practice. Some tools use exit 2 to distinguish usage errors from runtime errors, but the key requirement is consistency within your project.

Common Pitfalls

Using $* or $@ when you only need the count. These expand to the argument values. $# gives the count directly without any expansion or word splitting risk.

Sending usage messages to stdout instead of stderr. Error and usage messages should go to stderr with >&2. This keeps stdout clean for downstream piping and makes automation logs easier to interpret.

Relying only on $# when the script uses flags. A script called with -v -o output.txt input.txt has $# equal to 4, but the meaningful structure is two flags and one positional argument. Use getopts for flag-based interfaces.

Forgetting to quote $# inside [[ ]]. While [[ ]] does not perform word splitting, quoting $# is still good practice for consistency and to avoid surprises if the code is later moved to [ ].

Not using set -euo pipefail at the top of scripts. This combination catches unset variables (-u), pipeline failures (-o pipefail), and general errors (-e). Pair it with argument validation for robust scripts.

Summary

  • Use $# to check how many positional arguments were passed to a Bash script or function.
  • Validate exact, minimum, or range counts near the top of the script with -ne, -lt, and -gt.
  • Extract usage messages into a function for anything beyond a trivial script.
  • Use ${N:-default} to assign default values for optional positional arguments.
  • Transition to getopts when the script accepts flags instead of purely positional arguments.
  • Always quote positional parameters and send error messages to stderr.

Course illustration
Course illustration

All Rights Reserved.