Shell Scripting Basics

Reviewed & published by Brayan K

By the end of this lesson you'll be able to turn a list of terminal commands into a reusable bash script — with variables, input, decisions, loops, and functions — so the computer does the repetitive work for you.

Part of the free Command Line course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

1. The Shebang & Variables

A script is just a text file full of commands. The first line is the shebang — #!/bin/bash — which tells the system to run the file with the bash interpreter. After that, you store values in variables: write name=value with no spaces around the =, and read it back with $name. Use ${name} braces when the variable name sits right next to other text.

#!/bin/bash
# The shebang (line 1) tells the system: run this file with /bin/bash.
# It MUST be the very first line, starting with #!  (no space before #).

# A variable = a named label for a value. Assign with name=value.
# CRITICAL: no spaces around the = sign.
name="Ada"
language="bash"
year=2026

# Read a variable back by putting $ in front of its name.
echo "Hello, $name!"

# Use ${VAR} braces when the name touches other text, so bash knows
# where the variable name ends.
echo "You are learning ${language} scripting."
echo "It is the year ${year}."

Running a script

There are two ways to run a saved script. Either make it executable once with chmod +x and run it directly, or hand the file to bash each time:

$ chmod +x script.sh    # make it executable (only needed once)
$ ./script.sh           # run it directly

# OR, with no chmod needed:
$ bash script.sh        # run it by handing the file to bash

Forgetting chmod +x is the classic "Permission denied" trip-up — see Common Errors below.

2. Command Substitution & Input

Command substitution — $(command) — runs a command and drops its output straight into your line. So today=$(date +%A) stores today's weekday in a variable. To get input from the person running the script, use read: it pauses, waits for them to type, and stores the result in a variable you name.

#!/bin/bash
# Command substitution: $(command) runs the command and drops its
# OUTPUT right into your line. The script below uses fixed values so
# the output is predictable, but the comments show the real commands.

today="Monday"                 # real version: today=$(date +%A)
file_count=3                   # real version: file_count=$(ls | wc -l)

echo "Today is ${today}."
echo "There are ${file_count} files here."

# 'read' pauses and stores what the user types into a variable.
# In an interactive shell this would prompt you; here we show the idea:
#   read -p "What is your name? " user_name
#   echo "Welcome, ${user_name}!"
user_name="Grace"              # pretend the user typed this
echo "Welcome, ${user_name}!"

3. Positional Arguments

Scripts become powerful when you pass them arguments — the extra words you type after the script name. Bash hands them to you as $1 (first), $2 (second), and so on. $# is the count of arguments, $@ is all of them as a list, and $0 is the script's own name. This is how one script can act on whatever you give it.

#!/bin/bash
# Positional arguments are the words you type AFTER the script name.
# Save this as greet.sh and run:   bash greet.sh Alice Bob Carol
#
#   $0  -> the script's own name        (greet.sh)
#   $1  -> the first argument           (Alice)
#   $2  -> the second argument          (Bob)
#   $#  -> how many arguments there are (3)
#   $@  -> ALL the arguments, as a list

echo "Script name: $0"
echo "First arg:   $1"
echo "Arg count:   $#"

# Loop over every argument with $@:
for person in "$@"; do
  echo "Hello, ${person}!"
done

4. Conditionals: if, test, [ ] and [[ ]]

Conditionals run code only when a test passes. The shape is if [ test ]; then ... fi — and you must close it with fi. Numbers use -gt -ge -lt -le -eq -ne; strings use = and !=; files use checks like -f (exists). [ ] is the classic test command; [[ ]] is the modern bash version that's safer with strings. Watch the spaces — you need one inside the brackets.

#!/bin/bash
# Conditionals run code only when a test is true.
# [ ] is the classic test command;  [[ ]] is the modern bash version
# (safer with strings and supports && and ||). Mind the spaces:
# you need a space INSIDE the brackets:  [ "$x" -gt 0 ]

score=85

# Numeric comparisons:  -gt -ge -lt -le -eq -ne
if [ "$score" -ge 90 ]; then
  echo "Grade: A"
elif [ "$score" -ge 80 ]; then
  echo "Grade: B"          # 85 is >= 80, so this branch runs
else
  echo "Grade: C"
fi

# String comparison uses = (equal) and != (not equal).
name="admin"
if [[ "$name" == "admin" ]]; then
  echo "Access granted"
fi

# File test: -f is true if the file exists and is a regular file.
if [ -f "/etc/hostname" ]; then
  echo "Host file found"
fi

5. Loops, Functions & Exit Codes

A for loop walks through a list; a while loop repeats as long as its test stays true. A function wraps commands under a name so you can reuse them — inside it, $1 is the first argument and local keeps a variable private. Finally, every command sets $?, its exit code: 0 means success and anything else means a failure.

#!/bin/bash
# Loops repeat work; functions package work so you can reuse it.

# for over an explicit list
echo "--- for loop ---"
for i in 1 2 3; do
  echo "Step $i"
done

# while repeats WHILE its test stays true
echo "--- while loop ---"
count=3
while [ "$count" -gt 0 ]; do
  echo "${count}..."
  count=$((count - 1))     # $(( )) does the arithmetic
done
echo "Liftoff!"

# A function. $1 is its first argument; 'local' keeps the var private.
greet() {
  local who=$1
  echo "Hi, ${who}!"
}
echo "--- function ---"
greet "Linus"

# Every command sets $? — its exit code. 0 means success, non-zero a failure.
ls /etc/hostname > /dev/null   # this succeeds
echo "Exit code: $?"          # 0

🎯 Your Turn: complete the for-loop

The script is almost done — fill in the two blanks marked ___ using the # 👉 hints, then run it and check the Output panel matches.

#!/bin/bash
# 🎯 YOUR TURN — finish the for-loop, then run it.

fruits="apple banana cherry"

# 1) Loop over each fruit in the list.
for fruit in ___; do          # 👉 replace ___ with  $fruits
  # 2) Print one line per fruit using the loop variable.
  echo "I like ___"           # 👉 replace ___ with  ${fruit}
done

# ✅ Expected output:
#    I like apple
#    I like banana
#    I like cherry

🎯 Your Turn: complete the if-test

Two blanks again: pick the right comparison operator, and the keyword that closes an if block. Then run it.

#!/bin/bash
# 🎯 YOUR TURN — fill in the if-test, then run it.

age=20

# 1) Test whether age is 18 OR MORE.  (hint: -ge means ">=")
if [ "$age" ___ 18 ]; then    # 👉 replace ___ with  -ge
  echo "You can vote"
# 2) Close the if block with the right keyword.
___                            # 👉 replace ___ with  fi

# ✅ Expected output:
#    You can vote

Pro Tips

Common Errors (and the fix)

📋 Quick Reference

ConceptSyntaxNotes
Shebang#!/bin/bashMust be line 1
Variablename="value"No spaces around =
Read it$name / ${name}Braces near other text
Substitutionx=$(date)Captures command output
Inputread -p "Q? " vStores typed text in v
Arguments$1 $@ $#First, all, count
Ifif [ cond ]; then … fiClose with fi
For loopfor x in list; do … doneClose with done
While loopwhile [ cond ]; do … doneRepeats while true
Functionf() { … }$1 = first arg
Arithmeticn=$((n + 1))Double parentheses
Exit code$?0 = success

Mini-Challenge: file-counter report

No blanks this time — just a brief and an outline. Write a small, genuinely useful script that takes a folder name as an argument, guards against being called with none, and reports how many items it holds. Check your output against the example in the comments.

#!/bin/bash
# 🎯 MINI-CHALLENGE: a tiny file-counter report
# Save as report.sh and run:  bash report.sh Documents
#
# 1. Read the folder name from the first argument ($1) into a variable.
# 2. If no argument was given ($# is 0), print "Usage: report.sh <folder>"
#    and stop the script with  exit 1  (a non-zero exit code = error).
# 3. Otherwise, use a for-loop over the items and a counter variable to
#    count them, then print:  "<folder> contains N item(s)".
#
# Hints:  count=$((count + 1))   |   for item in "$folder"/*; do ... done
#
# ✅ Example (folder has 4 items):  Documents contains 4 item(s)

# your code here

🎉 Lesson Complete!

Practice quiz

What is the shebang line #!/bin/bash for?

  • It tells the system to run the file with bash
  • It is a comment
  • It exits the script
  • It prints bash

Answer: It tells the system to run the file with bash. The shebang on line 1 tells the system which interpreter to use.

How do you correctly assign a variable in bash?

  • name = value
  • name=value
  • set name value
  • let name = value

Answer: name=value. Assignment must have no spaces around the = sign: name=value.

How do you read back the value of a variable named user?

  • @user
  • &user
  • $user
  • %user

Answer: $user. Put a $ in front of the name: $user or ${user}.

What does $(command) do?

  • Comments out the command
  • Skips the command
  • Defines a function
  • Runs the command and inserts its output

Answer: Runs the command and inserts its output. Command substitution runs the command and drops its output into the line.

What does $1 refer to inside a script?

  • The first positional argument
  • The script name
  • The number of arguments
  • The exit code

Answer: The first positional argument. $1 is the first argument; $0 is the script name.

Which keyword closes an if block in bash?

  • end
  • fi
  • done
  • endif

Answer: fi. An if block is closed with fi.

Which numeric test operator means greater-than-or-equal?

  • -gt
  • -eq
  • -ge
  • -le

Answer: -ge. -ge means >= ; -gt is strictly greater than.

Which keyword closes a for or while loop?

  • fi
  • stop
  • loop
  • done

Answer: done. for and while loops are closed with done.

What does $? hold after a command runs?

  • The exit code of the last command
  • The script name
  • The first argument
  • The current directory

Answer: The exit code of the last command. $? holds the exit code; 0 means success, non-zero means failure.

How do you do arithmetic like adding 1 to a counter in bash?

  • count = count + 1
  • $((count + 1))
  • math(count+1)
  • add count 1

Answer: $((count + 1)). $((...)) does arithmetic, e.g. count=$((count + 1)).

Continue this course

Frequently asked questions

Why does name = "Alice" fail but name="Alice" work?

Bash treats a space as the separator between a command and its arguments. With spaces, bash thinks 'name' is a command and tries to run it with the arguments '=' and 'Alice'. Variable assignment must have NO spaces around the equals sign: name="Alice".

What is the difference between $VAR and ${VAR}?

They do the same thing — read the variable's value. You need the braces ${VAR} only when the name touches other characters, so bash knows where the name ends. For example ${file}_backup works, but $file_backup looks for a variable called 'file_backup'.

When should I use [ ] versus [[ ]]?

[ ] (the test command) is POSIX and works in any shell. [[ ]] is a bash/zsh feature that is safer with strings, lets you use && and || inside, and supports pattern matching. In bash scripts, prefer [[ ]] for string and file tests; use [ ] when you need maximum portability.

What does $? mean, and what is an exit code?

$? holds the exit code of the command that just ran. By convention 0 means success and any non-zero value means a failure (1 is a generic error). Scripts use exit codes so other programs — and the shell itself — can tell whether a command worked.

Why do people say to always quote variables, like "$file"?

If a variable's value contains spaces (a filename like 'my notes.txt'), an unquoted $file splits into separate words and breaks your command. Wrapping it in double quotes — "$file" — keeps the whole value as one piece. Quoting variables prevents a whole class of subtle bugs.

Related lessons