daily-report.sh validates its environment and warns when something falls outside the normal range, but it still always does exactly the same thing: today, every city. If management asks you for last Monday's report, or only Bilbao's, you have to edit the file. That is precisely what separated an alias from a tool, and in this lesson we solve it. By the end, daily-report.sh --date 2026-07-28 --city Bilbao will work, there will be a --help explaining how to use it, and the script will know how to ask at the keyboard for whatever it was not told.

Contents

  1. Positional parameters, $#, $@ and $*
  2. The critical difference between "$@" and "$*"
  3. shift: consuming arguments
  4. Validating the number of arguments and the usage() function
  5. Named arguments: while + case
  6. getopts for short options
  7. Default values and environment variables
  8. Interactive input with read
  9. daily-report.sh with --date and --city

  1. Positional parameters, $#, $@ and $*

When you invoke a script with arguments, Bash leaves them in numbered variables called positional parameters. Invoking daily-report.sh Bilbao 2026-07-28 detailed, $0 contains the script's path, $1 is Bilbao, $2 is 2026-07-28, $3 is detailed and $4 onwards are empty. Inside the script you would use them as echo "City: $1 · Date: $2 · Mode: $3".

There is a trap from the tenth argument onwards: $10 does not mean "argument 10", but "$1 followed by a zero", because Bash only recognizes one digit. From the tenth on you have to use braces — ${10} — the same rule from 03-02 applied to numeric names. In practice, a script that receives ten positionals is badly designed; past three, the right move is to switch to named arguments (section 6).

$0 deserves a comment: it contains the name exactly as it was invoked. If you run ./daily-report.sh that is what it will hold, and if you call it via the PATH it will hold the full path. To show only the name in help messages you use name=$(basename "$0").

Three special variables complete the picture: $# is the number of arguments received (not counting $0), $@ is all the arguments as a list and $* is all of them as a single string. With echo "Received $# arguments: $@" inside the script, invoking daily-report.sh Bilbao 2026-07-28 prints Received 2 arguments: Bilbao 2026-07-28. $# is the key to validating: (( $# == 0 )) detects that nothing was passed, (( $# > 2 )) that there are too many arguments.

  1. The critical difference between "$@" and "$*"

Without quotes, $@ and $* behave the same. Inside double quotes they are radically different, and it is one of the differences that causes the most subtle bugs in Bash:

Form Expands to
"$@" "$1" "$2" "$3" — each argument, a separate word
"$*" "$1 $2 $3" — all joined into a single word (separator: the first character of IFS)

The classic demonstration uses a file with spaces in its name. Invoking ./demo.sh "shipments july.csv" Bilbao:

for arg in "$@"; do echo "[$arg]"; done   # → [shipments july.csv]  and  [Bilbao]
for arg in "$*"; do echo "[$arg]"; done   # → [shipments july.csv Bilbao]

The first loop goes around twice and the second only once. With "$@" two arguments are preserved and the name with a space stays whole. With "$*" everything melts into a single string, and the receiver can no longer tell where one argument ended and the next began.

Professional rule: always use "$@", quoted. It is the correct way to forward arguments to another command: grep -c ERROR "$@" passes each file as an independent argument, whereas grep -c ERROR "$*" hands it all the names glued together as if they were one, and fails.

"$*" has only one legitimate use: building a readable message, such as echo "Arguments received: $*". For everything else, "$@".

  1. shift: consuming arguments

shift discards $1 and shifts all the others one position: $2 becomes $1, $3 becomes $2, and $# decreases by one. Starting from three arguments:

shift;   echo "$# args: $@"      # 2 args: 2026-07-28 detailed
shift 2; echo "$# args: $@"      # 0 args:

shift N shifts N positions at once. It is the mechanism that lets you walk through the arguments consuming them one by one, and it is the basis of the loop in section 6. One important detail: shift fails (returns code 1) if there are no arguments left to shift, which serves as a stop condition.

  1. Validating the number of arguments and the usage() function

Every script that accepts arguments must be able to explain itself; the universal convention is a usage() function that prints the syntax:

usage() { cat <<END
Usage: $(basename "$0") [OPTIONS]
Generates the daily activity report for Veloz Envíos.
  -d, --date YYYY-MM-DD    Date to analyze (default: today)
  -c, --city NAME          Filter by city (default: all)
  -v, --verbose            Show the error detail
  -h, --help               Show this help and exit
END
}

That cat <<END ... END construct is a here-document: a block of literal text sent to cat as if it were a file. It is far more comfortable than twenty echo calls in a row, and it is studied thoroughly in 05-05; here it is enough to copy the pattern. Function syntax belongs to 04-02, but you need it now.

With usage() defined, validating is immediate: (( $# > 6 )) && { echo "ERROR: too many arguments" >&2; usage >&2; exit 2; }. Notice two decisions. Help shown because of an error goes to stderr (usage >&2), because it is part of the diagnosis; but when the user explicitly asks for --help, the help goes to stdout and the script exits with code 0, because that is not an error. And the exit code for incorrect usage is 2, following the convention from lesson 03-01.

  1. Named arguments: while + case

Positional arguments have an obvious limit: you have to remember the order. daily-report.sh Bilbao 2026-07-28 forces you to know that the city comes first. Named arguments remove that problem and are the standard in any serious tool. The universal pattern in Bash combines a while loop with case:

while [[ $# -gt 0 ]]; do
    case "$1" in
        -d|--date)    report_date="$2"; shift 2 ;;
        -c|--city)    city="$2";        shift 2 ;;
        -v|--verbose) verbose="yes";    shift   ;;
        -h|--help)    usage; exit 0 ;;
        --)           shift; break ;;      # end of the options
        -*) echo "ERROR: unknown option '$1'" >&2; usage >&2; exit 2 ;;
        *)  echo "ERROR: unexpected argument '$1'" >&2; exit 2 ;;
    esac
done

Go through the block slowly, because you will write it many times:

  • while [[ $# -gt 0 ]] carries on while arguments remain; case "$1" looks at the current one, and each branch ends with ;;. The vertical bar in -d|--date) accepts the short or the long form.
  • shift 2 in options with a value consumes the option and its argument; a plain shift is enough for valueless flags like -v.
  • -- is the universal convention for saying "from here on there are no more options", indispensable when a value could start with a hyphen. -*) captures any unrecognized option and fails explicitly. Silently ignoring a misspelled option is one of the worst possible decisions: --ctiy Bilbao would produce a report for every city without saying a word. And *) catches the stray arguments you were not expecting.

case is studied formally in 04-05; here we use it as the practical tool it is.

A frequent flaw: if the user writes --date with no value, $2 will be empty and the script will carry on with an empty date. The defense is to check it in the branch itself, with [[ -n "${2:-}" ]] || { echo "ERROR: --date requires a value" >&2; exit 2; } before the assignment. That ${2:-} is expansion with a default value (03-06).

  1. getopts for short options

Bash ships a builtin dedicated to parsing short options, getopts, with a very compact syntax:

while getopts ":d:c:vh" option; do
    case "$option" in
        d) report_date="$OPTARG" ;;   c) city="$OPTARG" ;;
        v) verbose="yes"         ;;   h) usage; exit 0 ;;
        \?) echo "ERROR: unknown option -$OPTARG" >&2; exit 2 ;;
        :)  echo "ERROR: option -$OPTARG requires a value" >&2; exit 2 ;;
    esac
done
shift $((OPTIND - 1))

The pieces of that ":d:c:vh" string:

Element Meaning
d: The -d option requires a value (the colon goes after it)
v The -v option is a flag with no value
Leading : Silent mode: you handle the errors yourself with \? and :
$OPTARG / $OPTIND Value of the current option / index of the next argument

shift $((OPTIND - 1)) at the end discards all the processed options and leaves in $1, $2… the arguments that were not options.

Advantages of getopts: it groups flags (-vh is equivalent to -v -h), it accepts -d2026-07-28 written together, and it handles the errors for you. And its decisive limitation: it does not support long options; --date is impossible with getopts. The balance ends up like this: getopts wins on lines of code written and on flag grouping, while + case wins on the single point that really decides.

Criterion for choosing: if you want long options — and in an operations tool you do, because --city reads by itself and -c has to be remembered — use while + case. That is what we will do in daily-report.sh.

  1. Default values and environment variables

A good script works with no arguments, applying sensible values. The idiomatic approach is to assign them before processing the arguments, so that whatever does not arrive on the command line keeps its initial value with no need for extra conditionals. And those values can in turn come from environment variables, picking up what we saw in 03-02: it is the natural route for persistent configuration you do not want to type every time.

report_date=$(date +%F)          # default, today
city="${VELOZ_CITY:-all}"        # the environment variable if it exists, otherwise "all"

export VELOZ_CITY=Bilbao
daily-report.sh                  # already filters by Bilbao, with no arguments
daily-report.sh --city Sevilla   # the argument overrides the environment

The precedence convention, from highest to lowest priority, is: command-line argument > environment variable > configuration file > default value. Since the argument loop runs after these assignments, that hierarchy comes for free from the correct order of the lines. The ${var:-value} expansion you see here is explained thoroughly in 03-06.

This is the role of ~/veloz-ops/etc/veloz-ops.conf (02-03): a file loaded with source that fixes the team's usual values, on top of which each invocation can impose its own.

  1. Interactive input with read

When a piece of data is missing and the script is running interactively, you can ask the user with read -r -p "Which city do you want to analyze? " city. Its essential options:

Option Effect
-p "text" Shows a prompt with no newline
-r Does not interpret the backslash as an escape
-s Silent mode: does not display what is typed (passwords)
-t N / -n N Waits N seconds and gives up / reads N characters without waiting for Enter
-a array Stores the words in an array (04-03)

Always use -r. Without it, if the user types a Windows path like C:\data\shipments.csv, Bash will eat the backslashes and store C:datashipments.csv. There is no case in operations where you want that behavior.

That said: a script meant for cron cannot ask anything, because there is nobody on the other side and it would hang forever. That is why you have to check whether the input is a terminal before asking:

read -rsp "API password: " api_key; echo    # -s hides it; echo adds the newline
read -rt 10 -p "Continue? [y/N] " answer || { echo "No answer"; exit 1; }

if [[ -t 0 ]]; then read -r -p "Which city? " city
else city="all"           # no terminal: fall back to the default, without blocking
fi

[[ -t 0 ]] is true if descriptor 0 (stdin) is connected to a terminal. It is the check that keeps a script from hanging at dawn waiting for an answer that will never come.

The same idea lets you read from stdin when there are no arguments, the way the classic Unix filters do: if (( $# == 0 )) && [[ ! -t 0 ]]; then cities=$(cat); fi picks up through the pipe what did not arrive on the command line, so that echo Bilbao | daily-report.sh works just like passing the argument. Reading files line by line with while read is covered in 04-01.

  1. daily-report.sh with --date and --city

Putting it all together. The usage() function is abbreviated here because you already saw it in full in section 4:

#!/usr/bin/env bash
# daily-report.sh - Daily activity summary for Veloz Envíos
# Usage : daily-report.sh [--date YYYY-MM-DD] [--city NAME] [-v] [-h]
# Codes : 0 success | 2 incorrect usage | 3 unreadable log | 4 invalid CSV
readonly LOG_PATH="/var/log/veloz/app.log"
readonly CSV_PATH="/srv/veloz/data/shipments.csv"
readonly ERROR_THRESHOLD="${VELOZ_THRESHOLD:-50}"
usage() { echo "Usage: $(basename "$0") [-d YYYY-MM-DD] [-c CITY] [-v] [-h]"; }

# --- Default values and argument processing ---------------------------
report_date=$(date +%F)
city="${VELOZ_CITY:-all}"
verbose="no"
while [[ $# -gt 0 ]]; do
    case "$1" in
        -d|--date)    report_date="$2"; shift 2 ;;
        -c|--city)    city="$2";        shift 2 ;;
        -v|--verbose) verbose="yes";    shift   ;;
        -h|--help)    usage; exit 0 ;;
        *) echo "ERROR: unknown option '$1'" >&2; usage >&2; exit 2 ;;
    esac
done

# --- Validation -------------------------------------------------------
[[ "$report_date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] \
    || { echo "ERROR: date '$report_date' is not in YYYY-MM-DD format" >&2; exit 2; }
[[ -r "$LOG_PATH" && -s "$CSV_PATH" ]] || { echo "ERROR: no data" >&2; exit 3; }

# --- Report -----------------------------------------------------------
echo "VELOZ ENVIOS REPORT  ·  date: $report_date  ·  city: $city"
total_errors=$(grep -c "^$report_date .*ERROR" "$LOG_PATH")
echo "Errors for the day: $total_errors (threshold: $ERROR_THRESHOLD)"
[[ $verbose == "yes" ]] && grep "^$report_date .*ERROR" "$LOG_PATH" | tail -5

lines=$(grep ",$report_date," "$CSV_PATH")
[[ $city != "all" ]] && lines=$(echo "$lines" | grep ",$city,")
echo "-- Shipments by status --"
echo "$lines" | cut -d, -f5 | sort | uniq -c | sort -rn
exit 0
$ daily-report.sh --city Bilbao --date 2026-07-28 -v
VELOZ ENVIOS REPORT  ·  date: 2026-07-28  ·  city: Bilbao
Errors for the day: 12 (threshold: 50)
2026-07-28 18:22:41 [ERROR] failed to geocode address in Bilbao
-- Shipments by status --
     84 delivered
     11 issue

The =~ in the date validation is the regular-expression operator of [[ ]], which is studied in 05-04; here it checks that the date has four digits, a hyphen, two digits, a hyphen and two digits. It is a check of form, not of the calendar: 2026-13-45 would pass it, and refining that is a job for later. Notice too how the threshold and the city can come from the environment with ${VELOZ_THRESHOLD:-50} while an explicit --city always wins, because the loop runs afterwards: the precedence from section 7 comes for free from the correct order of the lines.

Common Mistakes and Tips

  • Writing $10 expecting the tenth argument. It is $1 followed by 0. Use ${10}.
  • Using $@ or $* unquoted, or confusing them. Without quotes, names with spaces are split; with them, "$*" melts everything into a single string. Always "$@".
  • Forgetting shift 2 on options with a value. The value will be processed as if it were another option, with bewildering results.
  • Ignoring unknown options. A typo like --ctiy must abort with code 2, never slip by unnoticed.
  • Using read without -r. The backslashes the user types disappear.
  • Asking with read in a cron script. It will hang indefinitely. Check first with [[ -t 0 ]].
  • Sending --help to stderr or exiting with a code ≠ 0. Asking for help is not an error: stdout and exit 0.

Exercises

Exercise 1 — Demonstrating "$@" versus "$*". Write a script count-args.sh that prints how many elements it sees when walking "$@" and how many when walking "$*", and invoke it with ./count-args.sh "shipments july.csv" Bilbao 2026-07-28. Explain the result.

Exercise 2 — A script with full options. Create ~/veloz-ops/bin/find-shipments.sh accepting --courier NAME, --status STATUS, --limit N (default 20) and --help, validating that the status is one of delivered, in_transit or issue, and showing the corresponding lines of shipments.csv. It must exit with code 2 on any incorrect usage.

Exercise 3 — Interactive but automatable. Modify the city fragment so that, if --city was not passed, it asks at the keyboard only if there is a terminal, with a maximum of 15 seconds, and uses all if there is no terminal or the time runs out.

Solutions

Solution to Exercise 1

n=0; for a in "$@"; do (( n++ )); done; echo "With \"\$@\": $n elements"
m=0; for a in "$*"; do (( m++ )); done; echo "With \"\$*\": $m elements"

The output is With "$@": 3 elements and With "$*": 1 element. "$@" preserves the structure: three arguments, and the first keeps its internal space intact. "$*" concatenates them into a single string using the first character of IFS (a space by default), and with that the boundary between arguments is irretrievably lost. That is why forwarding with "$*" to another command is a mistake: grep ERROR "$*" would look for a file whose full name was "shipments july.csv Bilbao 2026-07-28".

Solution to Exercise 2

#!/usr/bin/env bash
readonly CSV_PATH="/srv/veloz/data/shipments.csv"
usage() { echo "Usage: $(basename "$0") [--courier N] [--status S] [--limit N]"; }
courier=""; status=""; limit=20

while [[ $# -gt 0 ]]; do            # the same pattern from section 5
    case "$1" in
        --courier) courier="$2"; shift 2 ;;
        --status)  status="$2";  shift 2 ;;
        --limit)   limit="$2";   shift 2 ;;
        -h|--help) usage; exit 0 ;;
        *) echo "ERROR: unknown option '$1'" >&2; usage >&2; exit 2 ;;
    esac
done
case "$status" in
    delivered|in_transit|issue|"") ;;           # valid, or no filter
    *) echo "ERROR: status '$status' is not valid" >&2; exit 2 ;;
esac
[[ "$limit" =~ ^[0-9]+$ ]] || { echo "ERROR: --limit is not a number" >&2; exit 2; }
result=$(tail -n +2 "$CSV_PATH")
[[ -n $courier ]] && result=$(echo "$result" | grep ",$courier,")
[[ -n $status ]]  && result=$(echo "$result" | grep ",$status,")
echo "$result" | head -n "$limit"

Three decisions worth commenting on. The status validation uses case with a branch that includes "", because an empty string means "no filter" and must be considered valid. The --limit is validated as a number before being handed to head, which would fail with an unclear message if it received text. And the filters are applied cumulatively on a variable, which allows combining them or using them separately without duplicating code.

Solution to Exercise 3

if [[ -z $city ]]; then
    [[ -t 0 ]] && { read -rt 15 -p "Which city? [all] " city || city=""; }
    city="${city:-all}"
fi

The logic fits three protections into four lines. [[ -t 0 ]] stops the script from asking when cron launches it, avoiding an eternal block. -t 15 limits the wait even when there is a terminal, in case the operator wanders off for coffee. And ${city:-all} covers all three paths that leave the variable empty at once: no terminal, time ran out, or the user pressing Enter without typing anything. Note the || city="" after the read: when the time expires, read returns a nonzero code and could leave junk in the variable, so we empty it explicitly.

Conclusion

daily-report.sh is now a command-line tool like the ones the system ships. You know how to read positional parameters, including the ${10} trap; to count with $# and walk with "$@", understanding why "$*" destroys the separation between arguments; to consume them with shift; to document the usage with a usage() function and a here-document; to process long options with the while + case pattern, failing on the unknown instead of ignoring it; you know getopts, its OPTARG, its OPTIND and its limitation with long options; you assign default values; you ask with read -r without hanging cron jobs thanks to [[ -t 0 ]]; and you accept configuration from environment variables with the correct precedence.

One loose end remains, and it is the most important in the module. Look at the lines you have written today: "$1", "$@", "$report_date". Why are some quoted and others not? What would happen if someone ran daily-report.sh --city "San Sebastián", or if $city arrived empty in the middle of a [[ ]]? You have written ${2:-} and "${VELOZ_THRESHOLD:-50}" without a full explanation.

That is lesson 03-06, which closes the module where it had to be closed: the exact order in which Bash expands a line, what each type of quote does, why word splitting happens after substituting the variables, the complete family of ${var:-def}, ${var:?} and ${var:+}, the role of IFS, the disaster of for f in $(ls) and when printf is better than echo. It is the lesson that turns scripts that work almost always into scripts that work always.

Bash Programming Course

Module 1: Introduction to Bash

Module 2: Basic Bash Commands

Module 3: Scripting Fundamentals

Module 4: Intermediate Scripting

Module 5: Advanced Scripting Techniques

Module 6: Working with External Tools

Module 7: Automation and Scheduling

Module 8: Best Practices and Optimization

Module 9: Real-World Projects

© Copyright 2026. All rights reserved