A first script

A shell script is a text file that contains shell commands, run from top to bottom by a shell such as bash. Anything you can type at the prompt can go in a script, so a script is the natural next step once you catch yourself typing the same commands again: it saves time, and it does the job the same way every time. This page uses bash, the default shell on most Linux distributions (see The Shell and the Terminal).

cat > hello.sh <<'EOF'
#!/bin/bash
# hello.sh - a first script
echo "Hello from a script"
echo "Arguments given: $#"
EOF

chmod +x hello.sh
./hello.sh
bash hello.sh one two
Hello from a script
Arguments given: 0
Hello from a script
Arguments given: 2

The examples on this page create their script files with cat > file <<'EOF' (a here document, see Pipes, Redirection and Filters) so that each one runs on its own. In practice you write the script in an editor such as nano or vim. Lines that start with # are comments.

This page covers the basics of bash scripts. Bash & PowerShell Scripting goes further, with PowerShell side by side, error handling, JSON and APIs, and scripts that are safe to schedule.

The shebang line

The first line, #!/bin/bash, is the shebang: the characters #! followed by the path of the interpreter that should run the file. When you start a script as a program, the kernel reads this line and runs /bin/bash hello.sh for you. Without it, the file runs in whatever shell happens to start it, which may not be bash, and started directly by systemd or another program that does not go through a shell, it fails with "Exec format error".

  • #!/bin/bash names the path directly. Bash lives there on practically every Linux distribution.
  • #!/usr/bin/env bash asks env to find bash through PATH. It is more portable, for example to macOS or FreeBSD, where a newer bash is installed elsewhere, but it runs whichever bash comes first in the user's PATH.
  • #!/bin/sh asks for a plain POSIX shell (POSIX, the Portable Operating System Interface, is the standard that defines what Unix-like shells must support). On Debian and Ubuntu that is dash, which does not understand bash features such as [[ ]] and arrays. Use it only for scripts written for sh.

Making it executable and running it

There are two ways to run a script, and they need different permissions:

Command Needs What happens
./hello.sh Read and execute permission (chmod +x) The kernel reads the shebang and starts that interpreter
bash hello.sh Read permission only Bash runs the file itself; the shebang is just a comment
source hello.sh or . hello.sh Read permission only Runs the commands in your current shell, so variables and cd stay in effect afterward

The ./ is needed because the shell looks for commands only in the directories listed in PATH, and the current directory is deliberately not one of them. Forgetting chmod +x gives this:

$ ./hello.sh
bash: ./hello.sh: Permission denied

chmod +x adds execute permission for everyone; use chmod u+x or chmod 750 if only you or your group should run it (see Users, Groups and Permissions in Practice). Scripts you want to run from anywhere go in ~/bin or ~/.local/bin (in PATH on Debian and Ubuntu once the directory exists) or, for all users, /usr/local/bin.

Variables and quoting

A shell variable is a name that holds a piece of text. You assign it with name=value and read it with $name or ${name}. The braces are needed when letters follow directly, as in ${city}-Centraal below.

city="Utrecht"
stations=4
echo "$city has $stations train stations"
echo "Main one: ${city}-Centraal"

cat > oops.sh <<'EOF'
#!/bin/bash
city = "Utrecht"
EOF
bash oops.sh
echo "exit status $?"
Utrecht has 4 train stations
Main one: Utrecht-Centraal
oops.sh: line 2: city: command not found
exit status 127

Variables are text: stations=4 stores the character "4", and arithmetic happens only where you ask for it (below). By convention, your own variables use lowercase names, so they do not collide with environment variables such as PATH and HOME. A shell variable exists only in the current shell; export turns it into an environment variable that child processes inherit:

color="blue"
bash -c 'echo "Child process sees: [$color]"'
export color
bash -c 'echo "Child process sees: [$color]"'
Child process sees: []
Child process sees: [blue]

Quoting

Quoting tells the shell which characters to take literally. It matters because, after replacing an unquoted $variable with its value, the shell splits that value at spaces and expands wildcards in it. A file name with a space becomes two arguments:

file="annual report.txt"
touch "$file"

ls $file
echo "---"
ls "$file"
ls: cannot access 'annual': No such file or directory
ls: cannot access 'report.txt': No such file or directory
---
annual report.txt

The three kinds of quoting:

price=12
echo "Total: $price euro"
echo 'Total: $price euro'
echo "Literal dollar: \$price"
Total: 12 euro
Total: $price euro
Literal dollar: $price
  • Double quotes "…" keep the text together as one argument but still expand $variables, $(commands) and $((arithmetic)).
  • Single quotes '…' take everything literally; nothing inside is expanded.
  • A backslash \ takes the next single character literally.

The rule that prevents most script bugs: always write "$var", unless you know exactly why you want the splitting.

Command substitution and arithmetic

Command substitution, $(command), runs a command and puts its output in place, with trailing newlines removed. You will also see the older backtick form `command`, which is harder to read and to nest. Arithmetic expansion, $(( … )), calculates with whole numbers; inside it, variables need no $.

printf '%s\n' Amsterdam Rotterdam Utrecht > cities.txt

count=$(wc -l < cities.txt)
first=$(head -n 1 cities.txt)
echo "cities.txt has $count lines; the first is $first"

echo "7 * 6 = $(( 7 * 6 ))"
echo "7 / 2 = $(( 7 / 2 )), remainder $(( 7 % 2 ))"
total=$(( count * 250 ))
echo "Total: $total"
cities.txt has 3 lines; the first is Amsterdam
7 * 6 = 42
7 / 2 = 3, remainder 1
Total: 750

Bash arithmetic has no decimals: 7 / 2 is 3. For fractions, use a tool such as awk or bc, or write the script in Python instead.

Arguments and exit status

Arguments

Words after the script name arrive as numbered positional parameters:

Parameter Contains
$0 The name the script was started with
$1, $2 … ${10} The first, second … tenth argument
$# The number of arguments
"$@" All arguments, each as its own word, exactly as given
"$*" All arguments joined into one word
$? The exit status of the last command
cat > args.sh <<'EOF'
#!/bin/bash
echo "Script: $0"
echo "First:  $1"
echo "Second: $2"
echo "Count:  $#"
echo 'With "$@":'
for arg in "$@"; do
  echo "  [$arg]"
done
echo 'With $@ unquoted:'
for arg in $@; do
  echo "  [$arg]"
done
EOF

bash args.sh "New York" Paris
Script: args.sh
First:  New York
Second: Paris
Count:  2
With "$@":
  [New York]
  [Paris]
With $@ unquoted:
  [New]
  [York]
  [Paris]

Only "$@", in double quotes, passes the arguments on exactly as the user typed them. A missing argument is simply empty; ${1:-default} supplies a fallback value:

cat > greet.sh <<'EOF'
#!/bin/bash
name="${1:-colleague}"
echo "Good morning, $name"
EOF
bash greet.sh
bash greet.sh Ana
Good morning, colleague
Good morning, Ana

Exit status

Every command ends with an exit status (exit code): a number from 0 to 255. 0 means success; any other value means some kind of failure, and the command's manual page says which. The special parameter $? holds the status of the command that just finished. A script sets its own status with exit n; without it, the script returns the status of its last command.

printf '09:00 INFO start\n09:05 ERROR disk full\n' > app.log

grep -q "ERROR" app.log
echo "grep found ERROR: exit status $?"

grep -q "CRITICAL" app.log
echo "grep found CRITICAL: exit status $?"

ls missing.txt
echo "ls on a missing file: exit status $?"

cat > check.sh <<'EOF'
#!/bin/bash
echo "Checking..."
exit 3
EOF
bash check.sh
echo "check.sh ended with exit status $?"
grep found ERROR: exit status 0
grep found CRITICAL: exit status 1
ls: cannot access 'missing.txt': No such file or directory
ls on a missing file: exit status 2
Checking...
check.sh ended with exit status 3

grep uses 1 for "nothing found" and 2 for a real error; the shell itself uses 126 for "found but not executable" and 127 for "command not found". $? is overwritten by every command, including echo, so read it immediately or save it: status=$?.

Conditions

if runs a command and looks at its exit status: 0 takes the then branch, anything else the elif or else branch. The command is often [, also called test, which exists only to compare things and return 0 or 1. Because [ is a command, it needs spaces around it and around every part inside.

mkdir reports
printf 'quarterly figures\n' > reports/q3.txt
: > empty.txt

for path in reports reports/q3.txt empty.txt notes.txt; do
  if [ -d "$path" ]; then
    echo "$path: directory"
  elif [ -s "$path" ]; then
    echo "$path: file with content"
  elif [ -f "$path" ]; then
    echo "$path: empty file"
  else
    echo "$path: does not exist"
  fi
done
reports: directory
reports/q3.txt: file with content
empty.txt: empty file
notes.txt: does not exist

Test operators

Kind Test True when
File -e path The path exists (any type)
File -f path It is a regular file
File -d path It is a directory
File -s path It exists and is not empty
File -r / -w / -x path You may read / write / execute it
File -L path It is a symbolic link
String "$a" = "$b" The strings are equal (!= for not equal)
String -z "$a" The string is empty
String -n "$a" The string is not empty
Number "$a" -eq "$b" Equal (-ne not equal)
Number -lt / -le Less than / less than or equal
Number -gt / -ge Greater than / greater than or equal
Logic ! test The test is false

Strings and numbers have separate operators. < and > compare text alphabetically, character by character, which gives surprising answers for numbers:

disk_used=91
name=""

if [ "$disk_used" -ge 90 ]; then
  echo "Disk almost full: ${disk_used}%"
fi

if [ -z "$name" ]; then
  echo "No name given"
fi

if [ "10" \< "9" ]; then
  echo "As strings, 10 sorts before 9"
fi
if [ 10 -gt 9 ]; then
  echo "As numbers, 10 is greater than 9"
fi
Disk almost full: 91%
No name given
As strings, 10 sorts before 9
As numbers, 10 is greater than 9

(Inside [ ], < needs a backslash, otherwise the shell reads it as input redirection.)

[ ] versus [[ ]]

[ ] is the portable POSIX test. Bash adds [[ ]], which is part of the shell's own syntax rather than a command. That makes it safer: variables inside are not split into words, so a missing quote does not break the test. With [ ], an empty unquoted variable simply disappears:

cat > unquoted.sh <<'EOF'
#!/bin/bash
answer=""
if [ $answer = "yes" ]; then
  echo "confirmed"
fi
echo "done"
EOF
bash unquoted.sh
unquoted.sh: line 3: [: =: unary operator expected
done

[ $answer = "yes" ] became [ = "yes" ]. [[ ]] also understands && and || inside, wildcard patterns with ==, and regular expressions with =~ (see Searching with grep, find and Regular Expressions):

file="backup-2026.tar.gz"
answer="Yes"

if [[ $file == *.tar.gz ]]; then
  echo "$file is a gzip-compressed tar archive"
fi

if [[ $file =~ ([0-9]{4}) ]]; then
  echo "Year in the name: ${BASH_REMATCH[1]}"
fi

if [[ $answer == [Yy]* && -n $file ]]; then
  echo "Going ahead"
fi
backup-2026.tar.gz is a gzip-compressed tar archive
Year in the name: 2026
Going ahead

In bash scripts, prefer [[ ]]. Keep the pattern on the right unquoted when you want it treated as a pattern: [[ $file == "*.tar.gz" ]] compares with the literal text *.tar.gz. For arithmetic comparisons, (( disk_used >= 90 )) is a readable alternative to -ge.

&& and ||

a && b runs b only if a succeeded; a || b runs b only if a failed. They are handy for short checks:

mkdir -p backup && echo "backup directory ready"
[ -d old-reports ] || echo "no old-reports directory, nothing to archive"

printf 'INFO all good\n' > today.log
grep -q "ERROR" today.log && echo "errors found" || echo "no errors found"
backup directory ready
no old-reports directory, nothing to archive
no errors found

case

case compares one value with a list of wildcard patterns and runs the first branch that matches. It is clearer than a long elif chain. Each branch ends with ;;, | separates alternatives, and *) catches everything else.

for file in report.pdf photo.JPG notes.txt site.tar.gz; do
  case "$file" in
    *.pdf)                 echo "$file: document" ;;
    *.jpg | *.JPG | *.png) echo "$file: image" ;;
    *.tar.gz | *.tgz)      echo "$file: archive" ;;
    *)                     echo "$file: other" ;;
  esac
done
report.pdf: document
photo.JPG: image
notes.txt: other
site.tar.gz: archive

Loops

for

for runs its body once for each word in a list. The list can be written out, come from a wildcard pattern, or be "$@". Bash also has a counting form with double parentheses.

for server in web01 web02 db01; do
  echo "Checking $server"
done

touch access.log error.log notes.txt
for log in *.log; do
  echo "Found log: $log"
done

for (( i = 1; i <= 3; i++ )); do
  echo "Attempt $i"
done
Checking web01
Checking web02
Checking db01
Found log: access.log
Found log: error.log
Attempt 1
Attempt 2
Attempt 3

Loop over files with a wildcard, never over the output of ls: the pattern handles names with spaces correctly, $(ls) does not. One catch: when a pattern matches nothing, bash leaves it as the literal text. Turn on nullglob to get an empty list instead:

for f in *.csv; do
  echo "Processing $f"
done

shopt -s nullglob
for f in *.csv; do
  echo "Processing $f"
done
echo "Loop finished"
Processing *.csv
Loop finished

while and reading lines

while repeats as long as its command succeeds. The most useful form reads a file line by line:

cat > stock.csv <<'EOF'
Laptop stand,14
USB-C cable,120
Monitor arm,0
EOF

while IFS=, read -r item quantity; do
  if [ "$quantity" -eq 0 ]; then
    echo "Reorder: $item"
  else
    echo "In stock: $item ($quantity)"
  fi
done < stock.csv
In stock: Laptop stand (14)
In stock: USB-C cable (120)
Reorder: Monitor arm
  • < stock.csv after done feeds the file to the whole loop; read takes one line per round and fails at the end of the file, which ends the loop.
  • IFS=, splits each line at commas, just for this read; the last variable gets the rest of the line. For whole lines, use while IFS= read -r line, which also keeps leading spaces.
  • -r keeps backslashes as they are (see Reading input safely).

A counting while loop:

count=0
while [ "$count" -lt 3 ]; do
  count=$(( count + 1 ))
  echo "Try $count"
done
Try 1
Try 2
Try 3

break leaves a loop early and continue skips to the next round, as in most languages.

Functions

A function groups commands under a name, so the script reads like a list of steps. It is called like a command, receives its own $1, $2 and $#, and ends with an exit status like a command: the one given to return, or else that of its last command.

greet() {
  local name="$1"
  echo "Hello, $name"
}

is_weekend() {
  case "$1" in
    Sat | Sun) return 0 ;;
    *)         return 1 ;;
  esac
}

name="outer"
greet "Ana"
echo "name is still: $name"

for day in Fri Sat; do
  if is_weekend "$day"; then
    echo "$day: weekend"
  else
    echo "$day: workday"
  fi
done

message=$(greet "Ben")
echo "Captured: $message"
Hello, Ana
name is still: outer
Fri: workday
Sat: weekend
Captured: Hello, Ben
  • Variables in bash are global unless declared local. Without local, greet would have overwritten name.
  • return only sets the exit status (0 to 255), which makes functions like is_weekend usable in if. To hand back text, echo it and capture it with $( ).
  • A function must be defined before the line that calls it.

Safer scripts

set -euo pipefail

By default bash carries on after a failed command. In a script that cleans up, deploys or deletes, that is dangerous: a failed cd leaves you in the wrong directory, and a typo in a variable name silently becomes an empty string. Many scripts therefore start with set -euo pipefail:

Option Effect
-e (errexit) Exit as soon as a command fails
-u (nounset) Treat the use of an unset variable as an error and exit
-o pipefail A pipeline fails if any command in it fails, not only the last one
cat > nosafety.sh <<'EOF'
#!/bin/bash
cd missing-dir
echo "Still running, in the wrong directory"
echo "Cleaning up files of: $custmer"
EOF

cat > safe.sh <<'EOF'
#!/bin/bash
set -euo pipefail
cd missing-dir
echo "never printed"
EOF

cat > typo.sh <<'EOF'
#!/bin/bash
set -euo pipefail
customer="acme"
echo "Cleaning up files of: $custmer"
EOF

bash nosafety.sh; echo "--- exit status $?"
bash safe.sh;     echo "--- exit status $?"
bash typo.sh;     echo "--- exit status $?"
nosafety.sh: line 2: cd: missing-dir: No such file or directory
Still running, in the wrong directory
Cleaning up files of:
--- exit status 0
safe.sh: line 3: cd: missing-dir: No such file or directory
--- exit status 1
typo.sh: line 4: custmer: unbound variable
--- exit status 1

Without the options, the first script reported success although both of its problems went unnoticed. And pipefail changes what a pipeline reports:

false | true
echo "Without pipefail: $?"

set -o pipefail
false | true
echo "With pipefail: $?"
Without pipefail: 0
With pipefail: 1

set -e has exceptions you need to know. It does not react to a failing command that is itself being tested: in an if or while condition, before && or ||, or after !. And some commands "fail" as part of normal work: grep with no match returns 1, and so does the arithmetic command (( )) when its result is 0:

cat > counter.sh <<'EOF'
#!/bin/bash
set -e
count=0
(( count++ ))
echo "count is $count"
EOF
bash counter.sh
echo "exit status $?"
exit status 1

count++ returns the old value, 0, so the command "fails" and the script stops silently. Write count=$(( count + 1 )) instead, and add || true to commands such as grep whose "nothing found" is not an error for you. Treat set -euo pipefail as a safety net, not as a replacement for checking the things that matter. Its full list of exceptions, including functions called from if or ||, is on Error Handling › Where set -e does not help.

Reading input safely

read reads one line from standard input into variables. Always give it -r: without it, backslashes are treated as escape characters and disappear. -p "text" shows a prompt when someone types at a terminal.

printf 'C:\\new\\temp\n' > paths.txt
read line < paths.txt
echo "Without -r: $line"
read -r line < paths.txt
echo "With -r:    $line"
Without -r: C:newtemp
With -r:    C:\new\temp

Treat everything a user or a file supplies as untrusted: check that it has the form you expect before using it, and reject anything else. A regular expression in [[ ]] does that in one line. Send error messages to standard error with >&2 and exit with a non-zero status:

cat > order.sh <<'EOF'
#!/bin/bash
read -r -p "Quantity: " qty
if [[ ! $qty =~ ^[0-9]+$ ]]; then
  echo "Not a whole number: '$qty'" >&2
  exit 1
fi
echo "Ordering $qty items"
EOF

echo "12" | bash order.sh
echo "twelve" | bash order.sh
echo "exit status $?"
Ordering 12 items
Not a whole number: 'twelve'
exit status 1

Never put passwords or tokens in a script; see Linux Security Basics › Secrets.

A complete script, step by step

Time to combine everything into a tool you could actually use: a script that summarizes a log file, counting the lines per level and listing the error messages. It should behave like a well-mannered command: explain its usage, write errors to standard error, and return an exit status that another script or a scheduled job can act on (0 = no errors, 1 = errors found or unreadable file, 2 = wrong usage).

Step 1: check the arguments

Start with the skeleton: the shebang, the safety options, and a check that exactly one readable file was given. Test the failure cases before writing the real work.

cat > log-summary.sh <<'EOF'
#!/bin/bash
# log-summary.sh - step 1: check the argument
set -euo pipefail

if [[ $# -ne 1 ]]; then
  echo "Usage: $0 LOGFILE" >&2
  exit 2
fi
logfile="$1"
if [[ ! -r $logfile ]]; then
  echo "Error: cannot read '$logfile'" >&2
  exit 1
fi
echo "Summarizing $logfile"
EOF
chmod +x log-summary.sh

printf '2026-10-01 09:00:01 INFO Backup started\n' > app.log
./log-summary.sh;          echo "exit status $?"
./log-summary.sh old.log;  echo "exit status $?"
./log-summary.sh app.log;  echo "exit status $?"
Usage: ./log-summary.sh LOGFILE
exit status 2
Error: cannot read 'old.log'
exit status 1
Summarizing app.log
exit status 0

Step 2: add the work

Now the real work, in two small functions. usage replaces the inline message. count_level counts the lines of one level with grep -c, which prints 0 but exits with status 1 when nothing matches; || true stops set -e from treating that as a failure. The error messages are listed with the pipeline from Pipes, Redirection and Filters: cut off the date, time and level, then count identical lines.

cat > app.log <<'EOF'
2026-10-01 09:00:01 INFO Backup started
2026-10-01 09:00:04 WARN Slow response from db01
2026-10-01 09:01:12 ERROR Disk quota exceeded
2026-10-01 09:02:30 INFO Retrying upload
2026-10-01 09:02:31 ERROR Disk quota exceeded
2026-10-01 09:03:00 ERROR Connection reset by peer
2026-10-01 09:04:45 INFO Backup finished
EOF
grep -v ERROR app.log > quiet.log

cat > log-summary.sh <<'EOF'
#!/bin/bash
# log-summary.sh - count log levels and list the errors in a log file
# Usage: log-summary.sh LOGFILE
# Exit status: 0 no errors, 1 errors or unreadable file, 2 wrong usage
set -euo pipefail

usage() {
  echo "Usage: $0 LOGFILE" >&2
  exit 2
}

count_level() {
  local level="$1"
  grep -c " $level " "$logfile" || true   # grep -c exits 1 when it counts 0
}

[[ $# -eq 1 ]] || usage
logfile="$1"
if [[ ! -r $logfile ]]; then
  echo "Error: cannot read '$logfile'" >&2
  exit 1
fi

echo "Summary of $logfile"
for level in INFO WARN ERROR; do
  printf '  %-5s %3d\n' "$level" "$(count_level "$level")"
done

errors=$(count_level ERROR)
if (( errors > 0 )); then
  echo "Errors by message:"
  grep " ERROR " "$logfile" | cut -d ' ' -f 4- | sort | uniq -c | sort -rn
  exit 1
fi
echo "No errors."
EOF
chmod +x log-summary.sh

./log-summary.sh app.log
echo "exit status $?"
./log-summary.sh quiet.log
echo "exit status $?"
Summary of app.log
  INFO    3
  WARN    1
  ERROR   3
Errors by message:
      2 Disk quota exceeded
      1 Connection reset by peer
exit status 1
Summary of quiet.log
  INFO    3
  WARN    1
  ERROR   0
No errors.
exit status 0

Because it reports through its exit status, the script combines with other tools: ./log-summary.sh app.log || echo "check the log", or a nightly job that alerts someone only when the status is not 0 (see Scheduling Tasks). Before sharing a script, run it through ShellCheck (shellcheck log-summary.sh, in the package repositories of most distributions): it finds missing quotes and most of the other traps on this page (see ShellCheck).