Shell Lesson 2 of 42

Variables, Quoting, Parameter Expansion & IFS — The One Lesson That Eliminates 80% of All Shell Bugs Forever

If lesson 1 was “the shell is a process,” this lesson is “the shell is a string-rewriting machine.” The shell takes a line of input, performs a tightly-specified sequence of substitutions and splittings on it, and then executes the result as a command. Every confusing shell bug — every single one of them — is the result of misunderstanding one specific step in that rewriting pipeline. Most often it’s the step called word splitting, which is governed by a variable called IFS that almost no one understands properly until they get burned by it twice.

This lesson is long. Read it slowly. Type every example. By the time you finish, you will write shell that handles spaces in filenames, embedded newlines, empty arguments, and all the other pathological inputs that destroy careless scripts in production.


1. Variable assignment: the rule that catches every beginner

In bash, you assign a variable like this:

NAME=value

That’s it. No spaces around the = sign. This is the single most common bug in every beginner’s first shell script:

NAME = "Alice"     # WRONG — bash tries to run a command called NAME with arguments = and "Alice"
NAME ="Alice"      # WRONG — same reason
NAME= "Alice"      # WRONG-ISH — sets NAME to empty string for the duration of running the command "Alice"
NAME="Alice"       # CORRECT

Why is the rule so strict? Because the shell parses commands first, and NAME = "Alice" looks exactly like command arg1 arg2 to the parser — the same way ls -la /tmp does. The shell can only tell you wanted an assignment if there’s no space before the =. The rule is: an assignment word is a token that begins with a valid variable name followed immediately by =.

The third form — NAME= "Alice" — is subtle and worth understanding. It’s actually valid syntax, but it doesn’t do what you think. It says: “set the environment variable NAME to the empty string only for the duration of the command "Alice", then run that command.” This is the same syntax you use to inject environment variables into one specific invocation:

LOG_LEVEL=debug ./run-tests.sh

That sets LOG_LEVEL=debug only inside the ./run-tests.sh process. The current shell’s LOG_LEVEL is unchanged. This is enormously useful and we’ll come back to it in lesson 7. But it is not what you wanted when you typed NAME= "Alice".

Reading a variable

Once you’ve assigned a variable, you read it by prefixing the name with $:

NAME="Alice"
echo $NAME       # Alice
echo "$NAME"     # Alice
echo ${NAME}     # Alice
echo "${NAME}"   # Alice — the canonical form

The first form (unquoted $NAME) is dangerous and we’ll discuss why in section 4. The braces in ${NAME} are required when you want to follow the variable name with a character that could otherwise extend it:

echo $NAME_suffix   # bash looks for a variable called NAME_suffix — empty
echo ${NAME}_suffix # bash expands NAME, then appends literal "_suffix" → Alice_suffix

This rule will save you hours of debugging once you internalise it. When in doubt, use braces. The cost is two characters; the benefit is unambiguous semantics.

Names you cannot use

Variable names must match [A-Za-z_][A-Za-z0-9_]*. They are case-sensitive (name and NAME are different variables). By long-standing convention:


2. The four kinds of quoting and exactly what each one does

This is the single most leveraged section of this lesson. There are four ways to quote in bash, and they do four different things. Confusing them is the single biggest source of shell bugs in the world. Memorise this table.

Form Variable expansion ($VAR) Command substitution ($(...)) Glob expansion (*, ?) Backslash escaping Embedded newlines Word splitting on result
'single quotes' NO — literal NO — literal NO — literal NO — literal YES — preserved NO
"double quotes" YES YES NO — literal YES (limited) YES — preserved NO
no quotes YES YES YES YES (treated as space) YES — splits on $IFS
`backticks` YES (deprecated form of $(...)) (this is command substitution, old-style) NO YES (extra-confusing rules) YES depends on context

Let’s walk each one with concrete examples.

Single quotes — “I mean exactly this, character for character”

Single-quoted strings are the most literal thing in shell. Nothing inside them is interpreted. Not $VAR, not $(date), not \n, not \\. The only character you cannot put inside single quotes is a single quote — there is no escape mechanism for it inside single quotes.

echo 'Hello, $USER, today is $(date)'
# Output: Hello, $USER, today is $(date)

Use single quotes for: literal regular expressions, literal awk/sed programs, JSON literals you want to preserve, anything you want to send through a pipe untouched.

If you need a literal apostrophe inside a single-quoted string, you have to close the single quote, escape the apostrophe, and reopen:

echo 'it'\''s a beautiful day'
# Output: it's a beautiful day

That’s 'it' then \' (a literal backslash-apostrophe outside any quotes, which shell interprets as an escaped apostrophe) then 's a beautiful day'. This pattern shows up constantly in shell scripts and once you’ve seen it twice you’ll recognise it forever.

Double quotes — “expand variables, but don’t split on whitespace”

Double quotes do interpolation — they expand $VAR, $(cmd), and ${var:-default} — but they suppress word splitting and globbing on the result. This is the mode you want 90% of the time.

NAME="Alice Smith"
echo "Hello, $NAME, today is $(date)"
# Output: Hello, Alice Smith, today is Mon Jun 22 14:30:00 UTC 2026

Inside double quotes, the only characters with special meaning are $, `, \, and ". To get a literal $, write \$. To get a literal backtick, write \`. To get a literal ", write \". To get a literal \, write \\. Every other backslash is preserved as-is, which is sometimes surprising:

echo "a\nb"     # Output: a\nb  (the \n is NOT a newline — bash echo doesn't interpret \n)
printf "a\nb\n" # Output: a<newline>b   (printf does)
echo -e "a\nb"  # Output: a<newline>b   (with -e flag, but echo behaviour varies between systems)

The lesson here: never use echo -e or backslash escapes in echo for portability — use printf instead. This will save you when your script runs under dash (Debian’s /bin/sh) which does not support -e.

No quotes — the dangerous default

When you write $VAR without quotes, the shell expands it and then performs word splitting and pathname expansion (globbing) on the result. This is the source of most catastrophic shell bugs.

FILES="report 1.txt report 2.txt"
rm $FILES                # rm sees FOUR arguments: report  1.txt  report  2.txt
                         # — all four files get rm'd, even though we only meant two
rm "$FILES"              # rm sees ONE argument: "report 1.txt report 2.txt"
                         # — fails because no single file has that name
                         # (still wrong, but at least no data loss)

The classic real-world disaster is:

DIR="/tmp/cache/build *"     # accidentally trailing space and glob
rm -rf $DIR                  # expands to: rm -rf /tmp/cache/build *
                             # — the * matches every file in current directory
                             # — every one of them gets recursively deleted

This bug, almost word for word, has destroyed production systems. The mitigation is to always quote variable expansions unless you have a specific, deliberate reason not to. We’ll come back to this in section 5 with the strict-mode preamble.

Backticks — the old syntax for command substitution

Backticks (`command`) do command substitution — they run the command and substitute its output. This is the old POSIX syntax and you’ll see it in legacy scripts:

TODAY=`date +%Y-%m-%d`

The modern form is $(...):

TODAY=$(date +%Y-%m-%d)

Always prefer $(...) over backticks. Backticks have weird escaping rules (you have to double-escape backslashes inside them, and they don’t nest cleanly), they look identical to single quotes in many fonts, and the parsing rules are a nightmare. The only reason to ever use backticks is if you’re writing for a shell so old it doesn’t support $(...) — and that shell does not exist on any system you will work on this decade.


3. Parameter expansion — the Swiss Army knife of shell

This is where shell becomes a real text-processing language. Bash supports a rich set of parameter expansion operators that let you transform variables inline without invoking external tools like sed or awk. They are dramatically faster (no fork, no exec) and they’re guaranteed to be available wherever bash is, so use them aggressively.

Default values

${VAR:-default}   # if VAR is unset or empty, expand to "default"; do NOT assign
${VAR:=default}   # if VAR is unset or empty, expand to "default" AND assign default to VAR
${VAR:+alt}       # if VAR is set and non-empty, expand to "alt"; otherwise expand to empty
${VAR:?error}     # if VAR is unset or empty, print "error" to stderr and EXIT THE SHELL

The :- form is your best friend for command-line argument defaulting:

LOG_FILE="${1:-/var/log/myapp.log}"
TIMEOUT="${TIMEOUT:-30}"

The := form is rarely used because it modifies the variable, which is usually surprising.

The :+ form is excellent for “include this flag only if the variable is set”:

EXTRA_FLAG="${VERBOSE:+-v}"
my-tool $EXTRA_FLAG ./input    # only adds -v when VERBOSE is set

The :? form is critical for hard-required variables in production scripts:

DATABASE_URL="${DATABASE_URL:?DATABASE_URL must be set}"

If DATABASE_URL is unset, bash prints bash: DATABASE_URL: DATABASE_URL must be set and exits — immediately, before your script tries to do anything with an empty DSN. Use this at the top of any script that depends on environment variables.

Note the colon: ${VAR-default} (without the colon) expands to the default only if VAR is unset. With the colon, it expands the default if VAR is unset or set-but-empty. Almost always you want the colon form.

Prefix and suffix stripping

These are used constantly for filename manipulation:

FILE="/var/log/myapp/access.log.2026-06-22.gz"

${FILE#*/}        # strip shortest match of "*/" from start: "var/log/myapp/access.log.2026-06-22.gz"
${FILE##*/}       # strip longest match of "*/" from start: "access.log.2026-06-22.gz" (basename!)
${FILE%/*}        # strip shortest match of "/*" from end: "/var/log/myapp" (dirname!)
${FILE%%/*}       # strip longest match of "/*" from end: "" (everything after the first /)

${FILE%.gz}       # strip literal ".gz" from end: "/var/log/myapp/access.log.2026-06-22"
${FILE%.*}        # strip "." then anything from end: "/var/log/myapp/access.log.2026-06-22"
${FILE##*.}       # strip everything up to the last ".": "gz" (extension!)

These four operators (#, ##, %, %%) are the single most useful set of expansions in bash. Memorise them by the rule:

You will use these dozens of times per day once they’re in your reflexes.

Pattern replacement

PATH_VAR="/usr/local/bin:/usr/bin:/bin"

${PATH_VAR/bin/sbin}     # replace FIRST occurrence: "/usr/local/sbin:/usr/bin:/bin"
${PATH_VAR//bin/sbin}    # replace ALL occurrences: "/usr/local/sbin:/usr/sbin:/sbin"
${PATH_VAR/#\/usr/X}     # replace match at the START only (anchored): "X/local/bin:/usr/bin:/bin"
${PATH_VAR/%bin/X}       # replace match at the END only (anchored): "/usr/local/bin:/usr/bin:/X"

The / form does one replacement; // does all. The /# and /% forms anchor to start and end respectively.

Case modification (bash 4+)

NAME="alice smith"
echo "${NAME^}"          # "Alice smith" — uppercase first character
echo "${NAME^^}"         # "ALICE SMITH" — uppercase all
echo "${NAME,}"          # "alice smith" — lowercase first character (no-op here)
echo "${NAME,,}"         # "alice smith" — lowercase all
echo "${NAME~}"          # toggle case of first character
echo "${NAME~~}"         # toggle case of all

You can also use a pattern: ${NAME^^[aeiou]} would uppercase only vowels.

Length and substring

STR="Hello, World!"
echo "${#STR}"           # 13 — length in characters

echo "${STR:7}"          # "World!" — substring from index 7 to end
echo "${STR:7:5}"        # "World" — substring from index 7, 5 characters long
echo "${STR: -6}"        # "World!" — last 6 characters (note the SPACE before -6, required)
echo "${STR: -6:5}"      # "World" — last 6 chars, take 5

The leading space in ${STR: -6} is required — without it, ${STR:-6} would mean “default to 6 if STR is unset,” which is the entirely different operator we covered above.

Indirect expansion

NAME="USER"
echo "${!NAME}"          # expands USER — equivalent to ${USER}, prints "alice"

Useful when you have the name of a variable in another variable. Used sparingly; abused frequently.

Length-based and array operations

We’ll cover arrays in lesson 6, but for completeness:

ARR=(one two three four)
echo "${#ARR[@]}"        # 4 — number of elements
echo "${ARR[@]:1:2}"     # "two three" — slice from index 1, 2 elements
echo "${!ARR[@]}"        # "0 1 2 3" — list of indices

4. IFS, word splitting, and the most subtle bug in shell

Now we get to the deepest part of this lesson — the part nobody really teaches and the part that, once you understand, will make you a different shell programmer.

When the shell expands an unquoted $VAR, it doesn’t just substitute the value. It performs word splitting on the result, breaking the value into multiple tokens. The character (or characters) used to split is held in a special variable called IFS — Internal Field Separator.

By default, IFS is set to space, tab, and newline (in that order). You can see it with:

printf '%q\n' "$IFS"
# Output: $' \t\n'

Here’s what word splitting does, mechanically:

IFS=$' \t\n'             # default
GREETING="hello world"
my-tool $GREETING        # bash expands: hello world (two arguments)
                         # my-tool sees: argv = ["my-tool", "hello", "world"]
my-tool "$GREETING"      # bash expands: "hello world" (one argument, quoted = no splitting)
                         # my-tool sees: argv = ["my-tool", "hello world"]

This is why quoting matters. Word splitting only happens on unquoted expansions. As soon as you wrap the expansion in double quotes, splitting is suppressed and you get exactly one argument no matter what’s in the variable.

The reason this is the source of bugs is that the value of IFS itself is also a string, and its contents matter. If you ever change IFS, you change how every subsequent unquoted expansion behaves until you change it back.

A real example: parsing CSV without an external tool

Suppose you have a comma-separated string and you want to iterate its fields:

LINE="alice,30,engineer"

# Naive — doesn't work
for field in $LINE; do
  echo "$field"
done
# Output: alice,30,engineer    (one line — IFS is whitespace, no split happens)

# Correct — temporarily change IFS to comma
IFS=',' read -ra FIELDS <<< "$LINE"
for field in "${FIELDS[@]}"; do
  echo "$field"
done
# Output:
# alice
# 30
# engineer

The IFS=',' read -ra FIELDS <<< "$LINE" is one of the most useful idioms in bash. It says: “set IFS to comma for this one command only, run read with -r (raw, don’t process backslashes) and -a FIELDS (read into array FIELDS), and feed it the value of LINE as input via a here-string.” After the command finishes, IFS reverts to its previous value. Lesson 6 covers arrays and read in depth.

The classic for f in $(ls) antipattern

Every beginner writes this once:

for f in $(ls); do
  rm "$f"
done

This works until you have a filename with a space in it. Then ls outputs My Document.pdf and word splitting on whitespace gives you two “files”: My and Document.pdf. Both deletions fail. Worse, if you have a filename with a glob character (*, ?), pathname expansion happens after word splitting and you get a different file.

The right way is:

for f in *; do
  [ -e "$f" ] || continue        # handle the no-match case (lesson 11)
  rm -- "$f"
done

Or use find -print0 and xargs -0 for a fully NUL-separated pipeline (lesson 11 again). The general principle: never parse the output of ls in scripts. Use globs or find -print0.

"$@" vs $@ vs "$*" vs $*

These four forms look almost identical and behave very differently. They expand the positional parameters of a script (the arguments to the script).

Suppose your script is called with: ./myscript "hello world" foo bar. Then:

Form Expands to When you’d use it
$@ hello world foo bar (4 tokens after split) Almost never. Subject to word splitting.
"$@" "hello world" "foo" "bar" (3 args) Almost always. Forwards args correctly.
$* hello world foo bar (4 tokens after split) Almost never. Same problem as $@.
"$*" "hello world foo bar" (1 arg, IFS-joined) Logging, generating a single-string summary.

The rule: when forwarding arguments to another command, always use "$@":

#!/bin/bash
exec my-real-binary "$@"     # correct — preserves argument boundaries
exec my-real-binary $@       # WRONG — splits arguments on IFS
exec my-real-binary "$*"     # WRONG — joins all arguments into one string

This is the single most common bug in script wrappers, init scripts, and entrypoint.sh files in Docker images.


5. The strict-mode preamble — start every script with this

After everything above, you should be convinced that bash’s defaults are dangerous. The good news: you can opt into stricter behaviour with a three-line preamble at the top of every script. Memorise it:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

Let’s break it down.

#!/usr/bin/env bash

The shebang. Says “interpret this script with whatever bash is on the user’s PATH.” Better than #!/bin/bash because on some systems (notably newer macOS, BSDs, and stripped containers) bash is not at /bin/bash. We covered this in lesson 1.

set -e — exit on error

Without -e, bash continues executing after a command fails. So this:

cd /var/important
rm -rf *

If cd fails (the directory doesn’t exist), without -e the rm -rf * runs in your current directory and deletes everything. With -e, the script exits immediately when cd fails.

set -e is not a panacea — it has surprising exceptions (it doesn’t trigger inside if conditions, inside &&/|| chains, inside subshells in some bash versions, etc.) — but it dramatically reduces the blast radius of bugs. Use it.

set -u — treat unset variables as errors

set -u
echo "Hello, $NMAE"      # typo — exits with: bash: NMAE: unbound variable

Without -u, $NMAE silently expands to empty string and your script keeps going. With -u, you catch typos immediately. Combine with the ${VAR:?} and ${VAR:-default} patterns above for variables that may legitimately be unset.

set -o pipefail — fail if any pipe stage fails

By default, the exit code of a pipeline is the exit code of the last command. So this succeeds even though curl failed:

curl https://example.invalid | grep important
echo $?                   # 1 (because grep found nothing) — but the real failure was curl

With pipefail, the pipeline returns the exit code of the leftmost command that failed (or zero if none did):

set -o pipefail
curl https://example.invalid | grep important
echo $?                   # 6 (curl's "couldn't resolve host")

This is essential for any script that uses pipes. Lesson 8 covers pipes and pipefail in depth.

IFS=$'\n\t'

Sets IFS to newline and tab only — removes space. This means unquoted expansions still split on lines and tabs (useful for parsing tab-separated data and line-oriented output), but no longer split on spaces. If you forget to quote a variable that contains spaces, your script breaks loudly instead of silently.

This is controversial. Some shell experts argue against it because it changes the behaviour of every command that depends on default IFS. The pragmatic answer: if you write quoting-correct shell from the start, the IFS change has no effect on correct code, and it acts as a guard rail against incorrect code. Use it on new scripts; be cautious about adding it to old scripts that may not be quoting-correct.

Putting it together

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

# Required environment
DATABASE_URL="${DATABASE_URL:?DATABASE_URL must be set}"
LOG_LEVEL="${LOG_LEVEL:-info}"

# Real script starts here
echo "Connecting to database..."
psql "$DATABASE_URL" -c 'SELECT 1' >/dev/null
echo "OK"

This is the floor. Every shell script you ship in production should start with at least this much. Lesson 10 (signal handling and trap) adds a fifth and sixth line that handle Ctrl+C and cleanup; lesson 8 (pipes) discusses when pipefail interacts strangely with grep and head.


6. Command substitution and arithmetic expansion

Two more expansions to cover, then we’re done.

Command substitution: $(...)

Run a command and substitute its output:

TODAY=$(date +%Y-%m-%d)
COUNT=$(grep -c ERROR /var/log/app.log)
HOSTNAME_SHORT=$(hostname -s)

The output has its trailing newlines stripped (only the trailing ones — newlines in the middle are preserved). This is mostly what you want.

Command substitution can be nested:

LATEST_LOG=$(ls -t "$(find /var/log -name '*.log' -type f)" | head -1)

Inside double quotes, command substitution still happens:

echo "Today is $(date +%A)"

Inside single quotes, it does not — you get the literal string $(date +%A).

Arithmetic expansion: $((...))

COUNT=5
TOTAL=$((COUNT * 2 + 3))
echo "$TOTAL"            # 13

Inside $((...)), you can omit the $ on variable names — $((COUNT)) and $((COUNT * 2)) both work. The arithmetic is integer-only — there is no floating-point in pure bash. For floating-point you need bc, awk, or python (lesson 12 covers awk).

You can do all the usual operators: + - * / % ** & | ^ ~ << >> && || ! == != < > <= >=. Bitwise and logical work as in C. The expression returns 0 if true, 1 if false (this is the opposite of arithmetic — be careful when using $((expr)) as an exit-status proxy).

Increment and decrement work too:

i=0
while ((i < 10)); do
  echo "$i"
  ((i++))
done

The ((...)) form (no $) is the arithmetic command — it evaluates the expression and returns 0 if non-zero, 1 if zero. We’ll use this throughout the loops lesson (lesson 4).

Process substitution: <(...) and >(...)

Bonus — bash supports a fourth substitution form that beginners rarely see but that is enormously useful:

diff <(ls /var/log) <(ls /backup/var/log)

<(cmd) expands to a filename (typically /dev/fd/63) that the calling command can read from, and bash arranges for cmd to write to it. This lets you pass command output where a file is expected. We’ll come back to this in lesson 8.


7. export, local, readonly, declare — the lifecycle modifiers

Variables come with attributes. The four most important attribute-setting commands:

export

Marks a variable for inheritance by child processes. Without export, a variable is “local to the current shell” — child processes (like commands you run) don’t see it.

NAME="Alice"             # local to this shell
my-tool                  # my-tool does NOT see NAME

export NAME="Alice"      # exported
my-tool                  # my-tool DOES see NAME in its environment

You can also export an already-set variable:

NAME="Alice"
export NAME              # now exported

Or do the assignment inline:

export NAME="Alice"

local

Used inside functions only. Restricts the scope of a variable to the function:

greet() {
  local name="$1"        # this `name` does not leak outside greet()
  echo "Hello, $name"
}

Without local, every function variable is global, and you’ll have functions stomping on each other’s state. Always use local for function variables. We’ll cover this thoroughly in lesson 5 (functions).

readonly

Marks a variable as immutable for the rest of the script:

readonly MAX_RETRIES=3
MAX_RETRIES=5            # error: MAX_RETRIES: readonly variable

Useful for constants and sentinel values.

declare / typeset

declare is the general form. It can set attributes:

declare -i COUNT=0       # integer (arithmetic)
declare -a FRUITS=()     # indexed array (lesson 6)
declare -A USER=()       # associative array (lesson 6)
declare -r MAX=10        # readonly
declare -x EXPORTED=     # exported (same as export)
declare -l LOWER=        # auto-lowercase on assignment
declare -u UPPER=        # auto-uppercase on assignment

typeset is an old synonym for declare — they’re identical in bash. Use declare in new code.


8. The 14 quoting and parameter-expansion idioms you should know cold

This list is what separates a shell beginner from a shell professional. If any of these don’t make sense, re-read the relevant section above.

# 1. Default value
PORT="${PORT:-8080}"

# 2. Required value (exit if unset)
DATABASE_URL="${DATABASE_URL:?DATABASE_URL must be set}"

# 3. Conditional flag
EXTRA="${VERBOSE:+--verbose}"

# 4. Basename and dirname without forking
FILE="/var/log/myapp/access.log"
BASE="${FILE##*/}"               # access.log
DIR="${FILE%/*}"                 # /var/log/myapp
EXT="${FILE##*.}"                # log
NAME="${BASE%.*}"                # access

# 5. Replace all in path
PATH_NEW="${PATH//bin/sbin}"

# 6. Lowercase / uppercase
LOWER="${INPUT,,}"
UPPER="${INPUT^^}"

# 7. Length
LEN="${#STR}"

# 8. Substring
FIRST_8="${TOKEN:0:8}"

# 9. Strict-mode preamble
set -euo pipefail
IFS=$'\n\t'

# 10. Always quote variable expansions
mv "$src" "$dst"

# 11. Always use "$@" to forward arguments
exec real-binary "$@"

# 12. Always use $(...) over backticks
NOW=$(date +%s)

# 13. Read CSV with IFS
IFS=',' read -ra FIELDS <<< "$LINE"

# 14. Trap-friendly cleanup variable
TMPDIR="$(mktemp -d)"
trap 'rm -rf -- "$TMPDIR"' EXIT

The trap form in (14) we’ll cover thoroughly in lesson 10. The mktemp -d returns a path to a fresh, empty directory in /tmp whose name is unguessable, and the trap on EXIT ensures it gets cleaned up no matter how the script exits. This is the right pattern for any temporary file usage in shell.


9. Putting it all together: a real, robust script

Here’s a script that exercises everything above. Read it, type it out, and run it. Every line is a deliberate use of one of the principles in this lesson.

#!/usr/bin/env bash
# Usage: ./greet.sh [USER_NAME] [GREETING]
# Greets USER_NAME (default: $USER) with GREETING (default: "Hello").
# Logs the greeting to ./greet.log with a timestamp.
set -euo pipefail
IFS=$'\n\t'

# Defaults via parameter expansion
USER_NAME="${1:-${USER:-stranger}}"
GREETING="${2:-Hello}"

# Required environment with a sensible default
LOG_FILE="${GREET_LOG:-./greet.log}"

# Validate USER_NAME contains only safe characters
if [[ ! "$USER_NAME" =~ ^[A-Za-z][A-Za-z0-9._-]*$ ]]; then
  printf 'Error: invalid USER_NAME: %q\n' "$USER_NAME" >&2
  exit 2
fi

# Compose the greeting (string concatenation is just adjacency)
MESSAGE="${GREETING}, ${USER_NAME}!"

# Print to stdout (quoted, to suppress IFS / glob)
printf '%s\n' "$MESSAGE"

# Log with timestamp; >> appends, never truncates
TIMESTAMP="$(date '+%Y-%m-%dT%H:%M:%S%z')"
printf '[%s] %s\n' "$TIMESTAMP" "$MESSAGE" >> "$LOG_FILE"

# Exit explicitly with success
exit 0

Things to notice:

This is the standard you should hold yourself to in every shell script you ever write.


10. What you must internalise before lesson 3

This was the longest lesson in the course because everything depends on it. Before you move on, make sure you can answer all of these without thinking:

If any of those felt fuzzy, re-read the relevant section. Lesson 3 (conditionals and exit codes) builds directly on this, and lesson 4 (loops and substitution) builds on both. Get this foundation right and you’ll write production-grade shell from week one.


What’s next

Lesson 3 covers conditionals (if, [, [[, test), exit codes, the difference between &&/|| chaining and if blocks, regex matching with [[ =~ ]], and the precise semantics of 0 = success, non-zero = failure that drives every control-flow decision in shell. Bring everything you learned here — every if condition is fundamentally a quoting and word-splitting decision.

shellbashquotingparameter-expansionifsvariablesword-splittingglobbingstrict-modefundamentalslinuxposix
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments