Unix Shell Scripting: A Beginner’s Guide
Learn how Unix shells turn text into commands, then build reliable scripts with quoting, variables, control flow, functions, and pipelines.
A Unix shell is both a command interpreter and a programming language. It runs commands you type and lets you combine utilities into reusable files called scripts. This guide uses Bash for its examples and explains which parts are portable to POSIX-style sh.
A script works best when you understand what happens before a command runs: the shell reads and parses text, performs expansions, applies redirections, then executes commands and makes an exit status available. That order explains many beginner surprises, especially around spaces, quotes, wildcards, and variables.
1. What a shell script is
A shell is the program that interprets commands in a terminal. Bash is one shell; sh is the name commonly used for a POSIX-style shell interface. A script is a text file containing commands for a shell to read and execute. It can automate a routine sequence such as creating folders, processing files, or calling system utilities.
The examples below target Bash unless marked POSIX. Check which shell you are running with printf '%s\n' "$SHELL"; this usually reports your configured login shell, though a script’s shebang determines the interpreter used when that script is launched directly.
2. Create and run your first script
Create a file named hello.sh:
#!/usr/bin/env bash
printf 'Hello, %s!\n' "${USER:-there}"
The first line is the shebang. When you execute the file directly, the operating system uses it to select an interpreter. The second line runs Bash’s printf builtin, printing a greeting and a newline.
Run it with Bash:
bash hello.sh
Or make it executable and run it directly:
chmod +x hello.sh
./hello.sh
Use bash hello.sh when you want to explicitly run it with Bash, regardless of the executable bit. Use the shebang and executable bit when the file should be run like a command. The path ./hello.sh means “run the file in this directory”; the current directory is not necessarily included in your command search path.
3. How the shell turns text into commands
The Bash manual describes a sequence in which the shell reads input, breaks it into words and operators according to quoting rules, parses the commands, performs expansions, applies redirections, executes, and makes the result status available. The exact details can vary by shell and context, but this model is a practical way to understand what your script is asking the shell to do.
For example, in cp "$source" "$destination", the shell expands the variables and passes the resulting values as arguments to cp. Quoting keeps a value containing spaces in one argument. Without the quotes, the shell may split that value into multiple words. A wildcard such as *.log may expand to matching filenames before the command receives it.
Commands are usually written as a command name followed by arguments. For example, mkdir -p "reports/weekly summary" runs mkdir with the option -p and one path argument. Options are interpreted by the command, not generally by the shell.
4. Quote strings and paths deliberately
Quoting controls how the shell treats special characters. Use quotes around variable expansions and paths unless you specifically want splitting or wildcard expansion.
| Form | What it does | Example |
|---|---|---|
| Single quotes | Preserve characters literally; expansions do not happen inside. | '$HOME/*.txt' is those literal characters. |
| Double quotes | Allow selected expansions such as variables and command substitutions; keep the result as one word. | "$HOME/My Files" |
| Backslash | Escapes a special character in many contexts. | My\ Files |
name='Ada Lovelace'
printf 'Hello, %s\n' "$name"
pattern='*.txt'
printf 'Literal pattern: %s\n' "$pattern"
printf 'Expanded matching files, if any:\n'
printf '%s\n' *.txt
In the last line, the unquoted wildcard is intentionally left for the shell to expand. Quoting it as "*.txt" would pass a literal asterisk pattern instead. Use single quotes for fixed text that should not expand, and double quotes for a string that needs variable or command substitution.
5. Variables, parameters, and command substitution
Assign a value with no spaces around the equals sign. Refer to it with a dollar sign. Quoting the expansion prevents word splitting and wildcard expansion of the value.
project_dir="$HOME/projects/demo"
mkdir -p "$project_dir/output"
printf 'Output directory: %s\n' "$project_dir/output"
Scripts also receive positional parameters: $1 is the first argument, $2 the second, and "$@" represents all arguments as separate words when quoted. $# is the number of arguments; $0 is the script name.
#!/usr/bin/env bash
printf 'Script: %s\n' "$0"
printf 'Argument count: %s\n' "$#"
for item in "$@"; do
printf 'Argument: %s\n' "$item"
done
Use "$@" when forwarding arguments to another command: it preserves each argument boundary, including spaces. Avoid unquoted $* or $@ in most scripts because splitting and wildcard expansion can change the argument list.
Command substitution captures a command’s standard output. The modern form is $(...):
today=$(date +%F)
printf 'Date: %s\n' "$today"
Command substitution removes trailing newline characters from the output. It is suitable for ordinary text values, not for preserving arbitrary file contents or NUL bytes.
6. Exit status and reliable failure handling
Commands return an exit status: by convention, zero means success and a nonzero value means some kind of failure. The special parameter $? contains the most recent command’s status, so check it immediately if you use it.
if cp -- "$source" "$destination"; then
printf 'Copy completed.\n'
else
status=$?
printf 'Copy failed with status %s.\n' "$status" >&2
exit "$status"
fi
The -- argument is supported by many utilities and tells them to treat later arguments as operands, even if a path begins with a dash. Utility support varies, so consult that command’s documentation when portability matters.
For Bash scripts, a common starting point is set -o errexit -o nounset -o pipefail (often shortened to set -euo pipefail). These options can catch mistakes, but they are not a substitute for understanding statuses: errexit has context-dependent exceptions, nounset makes unset variable expansions an error, and pipefail makes a pipeline fail when a command in it fails. Add them when their behavior fits the script, and test conditional commands and pipelines deliberately.
7. Conditions, loops, and functions
Use if to branch based on a command’s status. The Bash [[ ... ]] conditional syntax is convenient, but it is Bash-specific. The POSIX [ ... ] form is more widely portable; quote expansions there too.
#!/usr/bin/env bash
set -o nounset
file=${1:-}
if [[ -z "$file" ]]; then
printf 'Usage: %s FILE\n' "$0" >&2
exit 2
fi
if [[ -f "$file" ]]; then
printf 'Found regular file: %s\n' "$file"
else
printf 'Not a regular file: %s\n' "$file" >&2
exit 1
fi
The ${1:-} form expands to the first argument, or an empty string if it is unset or empty. It avoids an unset-parameter error when Bash nounset mode is enabled.
A for loop iterates over a list. Quote "$@" to process each script argument safely:
for file in "$@"; do
if [[ -f "$file" ]]; then
printf 'Regular file: %s\n' "$file"
fi
done
A while loop repeats while a command succeeds. This reads a file line by line without splitting each line into separate words:
while IFS= read -r line || [[ -n "$line" ]]; do
printf '%s\n' "$line"
done < input.txt
IFS= prevents trimming or splitting on whitespace, and -r prevents backslashes from being treated as escapes. The final condition handles a last line that lacks a newline.
Functions give a reusable name to a group of commands. In Bash, use local for function-local variables:
log_error() {
local message=$1
printf 'Error: %s\n' "$message" >&2
}
log_error 'configuration file is missing'
local is supported by Bash and several other shells, but it is not specified by POSIX. A function’s exit status is normally the status of its last command; use return to set a function status explicitly.
8. Redirection and pipelines
Redirection changes where a command reads input or sends output. A pipeline connects the standard output of one command to the standard input of another.
| Syntax | Effect |
|---|---|
command > file |
Write standard output to a file, replacing its contents. |
command >> file |
Append standard output to a file. |
command 2> file |
Write standard error to a file. |
command > file 2>&1 |
Send standard output and standard error to the same destination. |
command < file |
Read standard input from a file. |
first | second |
Pass output from the first command as input to the second. |
grep -n 'ERROR' application.log > errors.txt
printf 'Run started\n' >> run.log
find . -type f -name '*.log' -print | sort
Redirections are applied by the shell before execution. For example, > errors.txt creates or truncates that file before the command runs. Bash’s |& syntax pipes standard output and standard error together, but it is not a POSIX feature. A pipeline’s status behavior also differs by shell and settings; Bash’s pipefail option can help detect failures before the final pipeline command.
9. A complete example: safely organize files by extension
This Bash script takes a destination directory and one or more input files. It creates the directory, skips non-files, and copies each file into it. It handles spaces in arguments and reports copy failures.
#!/usr/bin/env bash
set -o nounset -o pipefail
if (( $# < 2 )); then
printf 'Usage: %s DESTINATION FILE...\n' "$0" >&2
exit 2
fi
destination=$1
shift
if ! mkdir -p -- "$destination"; then
printf 'Could not create destination: %s\n' "$destination" >&2
exit 1
fi
failed=0
for source in "$@"; do
if [[ ! -f "$source" ]]; then
printf 'Skipping non-file: %s\n' "$source" >&2
failed=1
continue
fi
if ! cp -- "$source" "$destination/"; then
printf 'Could not copy: %s\n' "$source" >&2
failed=1
fi
done
exit "$failed"
Save it as collect-files.sh, then run bash collect-files.sh "./archive folder" "report one.txt" report-two.txt. This copies the named files into the destination; it does not recursively search folders or sort files into extension-specific subdirectories. The script leaves existing files with the same names subject to cp‘s normal overwrite behavior and permissions.
10. Portability: Bash or POSIX sh?
POSIX specifies important shell features such as flow control, program execution, redirection, pipelines, argument handling, variable expansion, and quoting. Bash aims to implement the POSIX shell specification, but Bash’s default behavior is not identical to POSIX in every area. Bash also offers extensions. A script that uses them should name Bash in its shebang and should not be described as portable POSIX shell.
| Concern | POSIX-oriented choice | Bash-specific or less portable choice |
|---|---|---|
| Interpreter | #!/bin/sh, with only POSIX syntax |
#!/usr/bin/env bash, with Bash syntax |
| Conditional | [ "$value" = yes ] |
[[ $value == yes ]] |
| Arithmetic | value=$((value + 1)) |
((value++)) |
| Arrays | No POSIX array syntax | Bash arrays such as items=(one two) |
| Convenience options | Use documented POSIX behavior | Bash options such as pipefail |
Choose the interpreter based on where the script must run. If the target requires POSIX sh, use its shebang, avoid Bash-only constructs, and validate against the target shell. If you need Bash features, use a Bash shebang and document that dependency. Bash POSIX mode can make Bash behave more closely to the standard in some areas, but it does not turn Bash-only syntax into portable sh.
11. Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
Permission denied |
The file is not executable, or the directory/file permissions disallow access. | Run with bash script.sh or use chmod +x script.sh and check permissions. |
command not found |
A misspelled command, missing utility, or command absent from PATH. |
Check the spelling and environment; use an appropriate absolute path if needed. |
| A path with spaces is treated as several arguments | A variable or path was expanded without quotes. | Use "$path" and preserve lists with "$@". |
| A wildcard matches unexpectedly | The shell expanded an unquoted glob. | Quote it when you need literal characters; leave it unquoted only when you want filename expansion. |
bad interpreter or ^M shown in an error |
The shebang may point to a missing interpreter, or the file may have Windows CRLF line endings. | Check the interpreter path and convert the file to Unix LF line endings. |
[[: not found or syntax errors under sh |
A Bash script was run by a different shell. | Run it with Bash and use a Bash shebang, or rewrite Bash-only syntax for POSIX sh. |
| A pipeline seems successful despite an earlier failure | By default, the pipeline status may reflect only its final command. | In Bash, consider set -o pipefail and handle the resulting status explicitly. |
| A script exits on an expected missing-file check | errexit or another strict option interacts with the command’s context. |
Use an explicit if around expected failures and inspect statuses in the context where they occur. |
12. Performance, reliability, and cost
Shell scripts are useful glue for connecting existing commands. For small automation jobs, clarity and correct argument handling usually matter more than reducing a few process launches. If a script processes very large datasets or needs complex data structures, compare the maintainability of a dedicated language with the cost of starting many external commands.
Reliability improves when scripts quote paths, validate required arguments, check command statuses, send diagnostics to standard error, and avoid assumptions about the current directory or shell. For portability, name the intended interpreter and test on the shells and systems that matter. Shell scripting itself has no per-run license cost, but commands may consume machine resources or invoke paid services; check those tools’ own terms and pricing.
13. Automate a website screenshot from the shell
Shell scripts can call HTTP APIs with command-line tools such as curl. For example, this request saves a website screenshot as a WebP file. Replace the placeholder with your API key and use a URL you are authorized to capture.
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
Equivalent Python and Node.js examples are useful when the rest of your automation is written in those languages. See the ScreenshotNeo API documentation for the request options and response details.
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await require('node:fs/promises').writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. Its one-call request can replace setting up and maintaining browser automation for this capture:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed; response headers say whether the page was clean and billed. An MCP server lets AI agents use screenshot tools. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots. See the API documentation, then sign up free for 1,000 screenshots a month, no card required.
14. Frequently asked questions
Do I need to install Bash?
Many Unix-like systems include a shell, but the available shells and versions depend on the system. Check for Bash on the target machine and use the interpreter named by your script’s shebang.
Can I use shell scripts on Windows?
Unix-like shells are available in environments such as Linux distributions and other POSIX-oriented toolchains. The script’s shell and utilities must be available in the environment where it runs; a Windows command interpreter does not automatically understand Bash syntax.
Should every script start with set -e?
No single setting fits every script. Bash’s errexit behavior depends on context, so use explicit conditionals where failure is expected and understand how options affect the commands you use.
Where can I check Bash syntax and behavior?
Use the GNU Bash Reference Manual, including its sections on shell operation, quoting, parameters, control constructs, redirections, and POSIX mode.


