daily-report.sh already works, but it is a rigid script: /var/log/veloz/app.log and /srv/veloz/data/shipments.csv are written literally in the middle of the body, and the error threshold you want to watch is nowhere to be found. The day operations moves the CSV to another directory you will have to search and replace across the whole file, and every occurrence you miss will be a silent failure. This lesson solves that problem and opens the door to everything else: variables are the mechanism by which a script stops being a list of commands and starts manipulating information.

Contents

  1. Assigning variables: the golden rule of = with no spaces
  2. Valid names and uppercase conventions
  3. Using variables: $var versus ${var}
  4. Bash has no types: everything is a string
  5. Command substitution with $(...)
  6. Constants with readonly and declare -r
  7. declare and its useful options
  8. Scope: shell variables versus environment variables
  9. Special shell variables
  10. unset and the refactor of daily-report.sh

  1. Assigning variables: the golden rule of = with no spaces

A variable is a name associated with a value. It is created by assigning something to it, and in Bash the syntax is unforgiving: city=Valencia, courier=alopez, threshold=50.

There can be no spaces around the =. This is the first rule everyone breaks, and it is worth understanding why it exists instead of memorizing it. Bash splits the line into words on spaces: if you write city = Valencia, it sees three words and concludes that you want to run the command city with the arguments = and Valencia, hence the message bash: city: command not found. The variant city =Valencia produces the same error, while city= Valencia tries to run Valencia with the variable city empty. Three ways to get it wrong, three different symptoms, one single rule: flush against the =, no exceptions.

If the value contains spaces, you have to quote it, because otherwise the second word would be interpreted as a command:

message="Report generated successfully"
long_path="/srv/veloz/data/archive 2026/shipments.csv"

Double quotes are the default choice and you will study them in depth in 03-06; for now, just remember that quoting never hurts.

  1. Valid names and uppercase conventions

A variable name may contain letters, digits and underscores, and cannot start with a digit. It allows no hyphens, dots or spaces: my-variable is not valid (Bash reads it as a subtraction), but my_variable is.

Beyond the rules of the language, there is a universal convention you should follow:

Style Used for Examples
UPPERCASE Script constants and environment variables LOG_PATH, ERROR_THRESHOLD, PATH, HOME
lowercase Internal script variables, temporaries, counters today, total_errors, city

The reason is practical: the system environment uses uppercase (PATH, HOME, USER, LANG), so using it for ordinary variables exposes you to clobbering a system one — a careless PATH=/tmp leaves the script unable to find any command. Reserve uppercase for what is genuinely a constant or an environment variable.

  1. Using variables: $var versus ${var}

To read the value you prefix a $: if city=Valencia, then echo "City: $city" prints City: Valencia. The braced form ${city} is equivalent... until it is not. The braces are mandatory when what follows the name could be mistaken for part of the name itself:

today=2026-08-03
echo "shipments_$today.csv"        # shipments_2026-08-03.csv  (the dot ends the name)
echo "report_$today_v2.txt"        # report_               ← WRONG!
echo "report_${today}_v2.txt"      # report_2026-08-03_v2.txt

The second line comes out mutilated because the underscore is a valid character in a variable name: Bash looked for a variable called today_v2, which does not exist, and substituted nothing for it. The braces mark where the name ends and remove the ambiguity.

There is a third reason: braces are the syntax of parameter expansion, the family that includes default values like ${var:-value} (03-06) and string manipulation like ${var^^} (04-04). That is why many teams adopt the rule of always writing ${var}.

  1. Bash has no types: everything is a string

This is an idea worth taking in properly, because it explains a lot of baffling behavior. In Bash every value is a text string. There are no integers, no booleans, no decimals.

In threshold=50, that 50 is the text "five, zero", not the number fifty. Direct consequences:

  • To compare as numbers you have to use specific operators (-eq, -gt, …) or (( )); comparing with = compares text, and "10" = "9" is false while 10 -gt 9 is true. That contrast is lesson 03-03.
  • To add up, a=$b+$c is not enough. You need arithmetic expansion $(( )), which you will see in 03-03 and in depth in 04-06.
  • There are no decimals. 50.5 is stored just fine as text, but any arithmetic operation with it will fail. For decimals you turn to bc or awk (04-06 and 06-01).
a=5; b=3
echo "$a+$b"        # 5+3   (text concatenation)
echo $(( a + b ))   # 8     (arithmetic expansion)

Everything being text also means that an undeclared variable and an empty variable behave almost identically: both expand to nothing. That is convenient and dangerous at once, and hence the existence of ${var:?message} to demand that a variable has a value (03-06).

  1. Command substitution with $(...)

This is where variables become genuinely useful in operations. Command substitution runs a command and replaces the whole expression with its output:

today=$(date +%F)
echo "Report for $today"     # → Report for 2026-08-03

Bash runs date +%F, collects what it writes to stdout and stores it in today. Trailing newlines are removed automatically, which is very convenient. This turns any Module 2 pipeline into a value you can manipulate:

total_errors=$(grep -c ERROR /var/log/veloz/app.log)
total_shipments=$(tail -n +2 /srv/veloz/data/shipments.csv | wc -l)
issues=$(grep -c ',issue,' /srv/veloz/data/shipments.csv)

echo "Errors: $total_errors | Shipments: $total_shipments | Issues: $issues"
# → Errors: 37 | Shipments: 1246 | Issues: 118

There is an older form with backticks, `command`, that you will still see in legacy scripts. It is discouraged:

Aspect $(command) `command`
Nesting Direct: $(dirname $(which bash)) Requires escaping every level
Readability The parentheses pair up visually The two quotes are identical
Visual confusion None Mistaken for ' in many fonts
Internal escapes Natural Its own surprising rules

Always use $( ). A performance warning: every $(...) launches a subshell, that is, a new process (lesson 01-04). With three or four you will not notice, but inside a thousand-iteration loop you will; that is the kind of optimization 08-02 covers.

  1. Constants with readonly and declare -r

Some variables must never change: the script's base paths, the thresholds agreed with the business, the service name. Marking them as constants documents that intention and, on top of that, makes Bash enforce it.

readonly LOG_PATH="/var/log/veloz/app.log"
readonly ERROR_THRESHOLD=50
declare -r CSV_PATH="/srv/veloz/data/shipments.csv"   # equivalent form

If anything tries to modify them later, Bash refuses with bash: ERROR_THRESHOLD: readonly variable. Two nuances. That error does not stop the script on its own (except with set -e, lesson 05-03), but it leaves a trace and returns a nonzero code. And readonly is irreversible: it cannot be undone with unset in the current session. In a script that is what you want; in your interactive terminal, be careful before declaring constants lightly.

Golden rule from now on: all the constants go together at the top of the file, under the header. Whoever opens the script sees in ten seconds which paths it uses and which thresholds it applies, without reading the body.

  1. declare and its useful options

declare creates variables with attributes. The options that matter right now:

Option Effect Example
-r Read-only (constant) declare -r MAX=100
-i Treats the variable as an integer declare -i counter=0
-x Exports it to the environment (like export) declare -x VELOZ_ENV=production
-a / -A Indexed / associative array Covered in 04-03
-p Shows a variable's declaration declare -p ERROR_THRESHOLD

The -i attribute is a curious one: it makes assignments be evaluated arithmetically without needing $(( )). And declare -p is an excellent debugging tool, because it shows the value and the attributes:

declare -i counter=0;  counter=counter+5;  echo "$counter"  # → 5
no_attribute=0;  no_attribute=no_attribute+5;  echo "$no_attribute"  # → no_attribute+5
declare -p CSV_PATH   # → declare -r CSV_PATH="/srv/veloz/data/shipments.csv"

  1. Scope: shell variables versus environment variables

We pick up the distinction from lesson 01-04, now with practical consequences. A variable can live in two places:

  • Shell variable: exists only in the shell that created it. That is what you get with a normal assignment.
  • Environment variable: is copied to every child process. You get it with export.
city=Valencia              # shell variable
export VELOZ_ENV=production  # environment variable

bash -c 'echo "city=[$city] env=[$VELOZ_ENV]"'
# → city=[] env=[production]

The child shell does not see city because it was not exported. This is the number one cause of the question "why doesn't my script see the variable I defined earlier?": a script is a child process, and it only inherits what was exported.

Inheritance is also one-directional: if the child modifies VELOZ_ENV, the parent never finds out. It receives a copy, not a link. That is why a script cannot change your terminal's directory or variables, and that is why source exists (03-01).

Inspect the environment with env or printenv, and all the variables with set. To set an environment variable only for one specific invocation, you put it in front of the command: VELOZ_ENV=test daily-report.sh. In practice you export little: only what other programs need to read.

  1. Special shell variables

Bash automatically defines variables that provide information about the context. The most useful right now:

Variable Contains
$? Exit code of the last command (01-04)
$$ PID of the current shell or script — ideal for unique temporary files
$0 The name the script was invoked with
$HOME, $USER, $HOSTNAME Home directory, user and machine name
$PWD / $OLDPWD Current / previous directory
$RANDOM Pseudorandom integer between 0 and 32767, different on each read
$SECONDS Seconds elapsed since the script started
$LINENO Current line number — very useful when debugging (05-03)
temp_file="/tmp/report-$$-$RANDOM.tmp"
echo "Running $0 as $USER on $HOSTNAME"
echo "Temp file: $temp_file · Elapsed: $SECONDS s"
Running /home/joan/veloz-ops/bin/daily-report.sh as joan on srv-veloz-01
Temp file: /tmp/report-48213-9174.tmp · Elapsed: 2 s

Combining $$ and $RANDOM is the classic trick for temporary names that will not collide if the script runs twice at once; in 05-01 we will replace it with mktemp, which also cleans up after itself.

Missing here are the argument variables — $1, $@, $# — which are the entire subject of lesson 03-05.

  1. unset and the refactor of daily-report.sh

unset city removes the variable entirely, leaving it undefined: echo "[$city]" will print []. Careful: unset does not work on constants declared with readonly, and it is not the same as assigning the empty string. For almost everything the result is the same, but ${var:?} and ${var+x} (03-06) do tell the two cases apart.

With all the pieces on the table, this is how daily-report.sh ends up:

#!/usr/bin/env bash
#
# daily-report.sh - Daily activity summary for Veloz Envíos
#
# Purpose    : Count the ERROR entries in app.log and group shipments by status.
# Author     : Joan Costa <[email protected]>   ·   Usage: daily-report.sh
# Exit codes : 0 success
#

# --- Constants --------------------------------------------------------
readonly LOG_PATH="/var/log/veloz/app.log"
readonly CSV_PATH="/srv/veloz/data/shipments.csv"
readonly REPORT_DIR="$HOME/veloz-ops/logs"
readonly ERROR_THRESHOLD=50
readonly SERVER="srv-veloz-01"

# --- Computed data ----------------------------------------------------
today=$(date +%F)
now=$(date +%T)
total_errors=$(grep -c ERROR "$LOG_PATH")
total_shipments=$(tail -n +2 "$CSV_PATH" | wc -l)

# --- Report -----------------------------------------------------------
echo "==================================================="
echo "  DAILY REPORT - VELOZ ENVIOS ($SERVER)"
echo "  Generated: ${today} ${now}"
echo "==================================================="
echo
echo "-- Errors in app.log --"
echo "Total: ${total_errors} (alert threshold: ${ERROR_THRESHOLD})"
echo
echo "-- Shipments by status (${total_shipments} in total) --"
tail -n +2 "$CSV_PATH" | cut -d, -f5 | sort | uniq -c | sort -rn
exit 0

Compare it with the 03-01 version and measure the improvement. The paths appear only once, grouped and visible in the first few lines: moving shipments.csv means changing one character. The threshold agreed with the business now exists as program data, with a name of its own, ready for lesson 03-04 to turn it into an automatic alert. The counts are computed once and reused. And the report shows date, time and server, information that is indispensable when someone reads it three weeks later.

Note two details that anticipate 03-06: variables are always used inside double quotes ("$CSV_PATH") and braces are used when the value is right up against other text. Adopt the habit right now.

Common Mistakes and Tips

  • Spaces around the =. city = Valencia tries to run the command city. It is the number one beginner's mistake.
  • Forgetting the $ when reading, or adding it when assigning. echo city prints the word; $city=Valencia is a syntax error. The $ is only for reading.
  • Names stuck to text without braces. $today_v2 looks for the variable today_v2. Use ${today}_v2.
  • Values with spaces and no quotes. msg=Daily report tries to run report with msg=Daily in the environment.
  • Using UPPERCASE for everything. Sooner or later you will clobber PATH, HOME or IFS, with consequences that are hard to diagnose.
  • Expecting a script to see the parent's variables. It only inherits what was exported; that is what export or source are for.
  • Trusting arithmetic with text. total=$a+$b produces the string 5+3. You need $(( )).

Exercises

Exercise 1 — Constants header. Write the constants block of a script city-summary.sh that will work with shipments.csv, write to ~/veloz-ops/logs, consider "many issues" to start at 30 and analyze Valencia by default. Use the correct uppercase convention and readonly, and add a non-constant variable with today's date in YYYY-MM-DD format.

Exercise 2 — Debugging a broken script. A colleague wrote this and it does not work. Identify the five errors and rewrite it.

#!/usr/bin/env bash
CSV_PATH = "/srv/veloz/data/shipments.csv"
total_shipments = `wc -l < $CSV_PATH`
output_file="$HOME/veloz-ops/logs/summary_$today_final.txt"
echo "Shipments: total_shipments" > $output_file

Exercise 3 — Report metrics. Extend the computed data block of daily-report.sh so that it stores in variables: the number of completed deliveries, the number of issues, the courier with the most shipments of the day and the name of the output file (which must include the date). Print all four with labels.

Solutions

Solution to Exercise 1

readonly CSV_PATH="/srv/veloz/data/shipments.csv"   # --- Constants ---
readonly REPORT_DIR="$HOME/veloz-ops/logs"
readonly ISSUE_THRESHOLD=30
readonly DEFAULT_CITY="Valencia"
today=$(date +%F)                                   # --- Computed data ---

The first four are constants, in uppercase and with readonly; today is lowercase and without readonly because it is a value computed on every run, not a design decision. Note that $HOME inside double quotes expands correctly, which makes the script valid for any user: writing /home/joan/... by hand would break it for everyone else.

Solution to Exercise 2

The five errors: (1) CSV_PATH = "..." has spaces around the =; (2) total_shipments = ... repeats the same mistake; (3) it uses backticks instead of $( ); (4) $today_final looks for a nonexistent variable — it needs ${today}_final — and besides, today is never defined; (5) echo "Shipments: total_shipments" prints the literal text because the $ is missing, and > $output_file goes unquoted.

#!/usr/bin/env bash
readonly CSV_PATH="/srv/veloz/data/shipments.csv"
today=$(date +%F)
total_shipments=$(tail -n +2 "$CSV_PATH" | wc -l)
output_file="$HOME/veloz-ops/logs/summary_${today}_final.txt"
echo "Shipments: $total_shipments" > "$output_file"

We have also added tail -n +2 so the CSV header is not counted, a content bug the original version was dragging along without anyone noticing.

Solution to Exercise 3

delivered=$(grep -c ',delivered,' "$CSV_PATH")
issues=$(grep -c ',issue,' "$CSV_PATH")
top_courier=$(tail -n +2 "$CSV_PATH" | cut -d, -f4 | sort | uniq -c \
              | sort -rn | head -1 | tr -s ' ' | cut -d' ' -f3)
output_file="${REPORT_DIR}/report-$(date +%F).txt"

echo "Delivered      : $delivered"    # → 981
echo "Issues         : $issues"       # → 118
echo "Top courier    : $top_courier"  # → mgarcia
echo "Output file    : $output_file"  # → ~/veloz-ops/logs/report-2026-08-03.txt

The top_courier line deserves an explanation. uniq -c produces lines with padding spaces at the start ( 412 mgarcia), so tr -s ' ' squeezes them into a single one and cut -d' ' -f3 takes the third field — the first is empty because of the leading space. It works, but it is also a warning: when a pipeline needs this many adjustments, the right tool is awk, and in 06-01 that very line will shrink to a far cleaner expression.

Conclusion

daily-report.sh is no longer a list of commands: it is a program with data. You know how to assign with no spaces around the = and why that rule exists; you name things following the convention of uppercase for constants and lowercase for the rest; you use ${var} when the braces are needed; you assume that in Bash everything is text; you capture the output of any pipeline with $( ); you protect paths and thresholds with readonly; you know declare and its attributes; you tell shell variables from environment variables; and you handle the special ones like $?, $$ or $SECONDS.

The script now has an ERROR_THRESHOLD=50 in its header that does nothing at all: it gets printed, but nobody compares it with anything. And it still takes for granted that app.log and shipments.csv exist and are readable; if operations renames the CSV, the script will not warn you: it will spit out a report full of zeros.

In lesson 03-03 you will learn to ask questions. You will see the shell's operators: how to chain commands with && and ||, how to check whether a file exists with [[ -f ... ]], and how to compare numbers — the classic -gt versus > — so you can answer the question that threshold has been waiting for since the beginning: are there more errors today than we can tolerate?

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