Basic Bash Commands for Scripting

Most people learn Bash by typing commands straight into a terminal and reacting to whatever comes back. Scripting is a different job. You’re not there to see the output, the script is, and it has to make a decision based on it. That changes which commands actually matter. You won’t need ls -la to look pretty in a script, but you will need wc -l to count something. Here’s the short list that shows up in almost every Bash script worth writing, with real output from both an Ubuntu and a RHEL box so you can see it isn’t cherry-picked.

If you haven’t written a script yet, our first script walkthrough covers the shebang and basic structure. This one assumes that part and focuses on the handful of commands you’ll reach for constantly once the script actually exists.

echo, printf and read: getting things in and out

echo is what everyone reaches for first, and it’s fine for a quick status line:

echo "Backup started"
Backup started

The trouble starts once you need to control formatting. echo‘s behavior with escape sequences and trailing newlines isn’t the same across every shell, and that inconsistency has bitten enough people that printf is the safer habit for anything beyond a plain string:

printf "Backup started at %s for host %s\n" "$(date +%H:%M:%S)" "$(hostname)"
Backup started at 13:16:09 for host linux1

printf takes a format string and fills in the %s slots, same idea as C or Python’s printf. It never adds a newline on its own, which looks annoying until you realize that’s exactly what makes it predictable in a script that’s building output line by line.

read goes the other direction, pulling a value in rather than pushing one out:

read -p "Service name: " svc
Service name: app-prod
echo "You entered: $svc"
You entered: app-prod

The -p flag prints the prompt for you, one line instead of an echo followed by a separate read. Whatever the person types lands in $svc, ready to use for the rest of the script. You’ll see this same pattern again lower down in a script that actually reads a value and does something with it.

pwd, cd, basename, dirname and realpath: knowing where you actually are

A script that assumes it’s being run from a particular directory will eventually be run from somewhere else, and then it breaks in a way that’s annoying to debug. These five commands are how you stop guessing.

pwd
/home/asif/bashdemo
cd logs
pwd
/home/asif/bashdemo/logs

pwd just prints where you are. Not exciting, but it’s the thing you echo into a log file when a script fails and you need to know what directory it was in at the time.

basename and dirname split a path into its two obvious halves:

basename /var/log/nginx/access.log
access.log
dirname /var/log/nginx/access.log
/var/log/nginx

Useful any time a script is handed a full path and only needs the filename, or needs to cd into whatever folder a file lives in without knowing that folder’s name ahead of time.

realpath does something neither of those does: it resolves symlinks and relative paths into one absolute, unambiguous path.

ln -sf logs mylogs
realpath mylogs
/home/asif/bashdemo/logs

mylogs is a symlink pointing at logs, and realpath followed it straight to the real location. Any time a script logs a path or passes one to another program, running it through realpath first means you’re never left wondering which of three possible relative paths actually got used.

cp, mv, rm, mkdir and touch: handling files without wrecking something

mkdir -p creates a directory and doesn’t complain if it (or its parents) already exist, which is exactly the behavior you want in a script that might run more than once:

mkdir -p backups/2026-01-22
touch backups/2026-01-22/.keep
ls -la backups/2026-01-22
total 8
drwxrwxr-x 2 asif asif 4096 Jan 22 13:16 .
drwxrwxr-x 3 asif asif 4096 Jan 22 13:16 ..
-rw-rw-r-- 1 asif asif    0 Jan 22 13:16 .keep

touch creates an empty file, or if the file already exists, just updates its modified time. Both show up constantly, mkdir -p for setting up a destination before writing to it, touch for marker files, lock files, or just making sure a log file exists before you append to it.

cp and mv look similar but mean very different things. cp leaves the original in place:

cp app.conf app.conf.bak
ls -la app.conf app.conf.bak
-rw-rw-r-- 1 asif asif 17 Jan 22 13:16 app.conf
-rw-rw-r-- 1 asif asif 17 Jan 22 13:16 app.conf.bak

mv doesn’t, it relocates the file, or renames it if the destination is in the same directory:

mv app.conf.bak backups/2026-01-22/app.conf.bak
ls backups/2026-01-22
app.conf.bak

Then there’s rm, the one command on this list that can actually hurt you. In a script, always target a specific file or a tightly scoped pattern, never a bare variable that might be empty:

touch scratch.tmp
rm -f scratch.tmp
ls scratch.tmp
ls: cannot access 'scratch.tmp': No such file or directory

That last line is rm doing its job correctly, the file is gone and ls confirms it. The habit worth building is checking what a variable actually holds before it ends up next to rm -rf. An empty $dir turning rm -rf "$dir"/* into rm -rf /* is a classic, and it’s exactly the kind of mistake that’s invisible until the one time it matters.

cat, less, head, tail and wc: reading data from inside a script

These are how a script looks at a file instead of a person doing it.

cat app.conf
service=app-prod

cat dumps the whole file. Fine for something small like a one-line config, a bad idea for a 50,000-line log, which is where head and tail earn their keep:

head -n 3 logs/app.log
2026-01-22 10:01:00 INFO request handled in 70ms
2026-01-22 10:02:00 INFO request handled in 81ms
2026-01-22 10:03:00 INFO request handled in 80ms

tail -n 3 logs/app.log
2026-01-22 10:08:00 INFO request handled in 51ms
2026-01-22 10:09:00 ERROR database connection timed out
2026-01-22 10:10:00 INFO request handled in 41ms

head grabs the first few lines, tail the last few, both far cheaper than reading the whole file when all you want is a sample. wc -l counts lines instead of showing them, which is usually what a script actually needs to know:

wc -l logs/app.log
10 logs/app.log

One command on this list doesn’t belong in a script at all, and that’s worth saying plainly: less. It’s the pager you reach for when you’re reading a long file yourself, scrolling with arrow keys and searching with /. It’s genuinely the best tool for that job, but it waits for a human to press a key, so it has no place in anything meant to run unattended. Know it for your own terminal sessions, skip it in scripts.

Putting it together

None of this means much in isolation, so here’s a small script that uses most of it at once, a quick check that reads an environment name, finds its log file with realpath, and reports how many lines in it are errors:

#!/bin/bash
set -euo pipefail

read -p "Environment to check: " env
logfile="logs/app.log"

echo "Checking $env using $(realpath "$logfile")"

errors=$(grep -c ERROR "$logfile" || true)
total=$(wc -l < "$logfile")

printf "%s: %d/%d lines are errors\n" "$env" "$errors" "$total"

if [ "$errors" -gt 0 ]; then
    echo "Last error:"
    grep ERROR "$logfile" | tail -n 1
fi

Run it, typing production at the prompt:

./deploy-check.sh
Environment to check: production
Checking production using /home/asif/bashdemo/logs/app.log
production: 1/10 lines are errors
Last error:
2026-01-22 10:09:00 ERROR database connection timed out

read for input, realpath so the path in the log is unambiguous, wc -l and grep -c to count, printf to report it cleanly, tail to pull the last match. Same output, verified on both Ubuntu 26.04 and RHEL 9, because none of these commands are distribution-specific. They're just core Bash and coreutils, which is exactly why they're worth knowing cold.

Frequently asked questions

Should I use echo or printf in scripts?

printf for anything with variables or formatting, because its behavior is consistent and predictable. Plain echo "some text" is still fine for a simple status line.

What's the difference between basename/dirname and realpath?

basename and dirname just split a path string into pieces, they don't check whether anything actually exists. realpath resolves the path against the real filesystem, following symlinks and collapsing .. and . along the way.

Why not just use rm -rf and move on?

Because the cost of being wrong is total and immediate. Target specific files or tightly scoped patterns, and if a variable is involved, make sure it's never empty before it reaches rm.

Is less ever used inside a script?

Not in any script meant to run unattended. It waits for keyboard input, which a cron job or CI pipeline will never provide. It's strictly an interactive tool.

Asif Khan, author of LinuxPathfinder.com

Asif Khan

DevOps, Linux, and Cloud engineer with 10+ years building infrastructure that stays up, and automation so I do not have to fix it at 2am. My focus is automation, security, and systems built to survive failure, using Infrastructure as Code, CI/CD, containers, monitoring, and cloud platforms instead of manual fixes. This blog is where I write down what worked, and just as often what didn't the first time around. Real tutorials you can run yourself, troubleshooting notes pulled from actual outages, and the kind of detail you only get once you've hit the problem firsthand. If you're trying to get Linux, DevOps, and cloud infrastructure to behave, that's what you'll find here: reproducible solutions you can understand, test, and apply in your own environment.