Arithmetic and String Operations
🔢 Topic 4 — Arithmetic and String Operations
Bash stores everything as a string, but it can perform integer arithmetic natively and provides a rich set of parameter expansion operators for slicing, replacing, and transforming strings — all without calling an external command. This chapter covers the arithmetic context $(( )), floating-point math with bc, and the full string manipulation toolkit built into bash's parameter expansion syntax.
1 — Integer Arithmetic with $(( ))
The arithmetic expansion $(( expression )) evaluates an integer expression and substitutes the result. It is the standard, preferred way to do arithmetic in bash — fast, built-in, and readable.
#!/bin/bash
a=10
b=3
echo "Addition : $((a + b))" → 13
echo "Subtraction : $((a - b))" → 7
echo "Multiplication : $((a * b))" → 30
echo "Division : $((a / b))" → 3 (integer — truncates)
echo "Modulo : $((a % b))" → 1
echo "Exponentiation : $((a ** b))" → 1000
# Store result in a variable
result=$(( a * b + 5 ))
echo "Result: $result"
Result: 35
# Variables inside (( )) do NOT need the $ prefix
total=$(( a + b )) # both work
total=$(( $a + $b )) # also fine, but redundant
Increment, Decrement, and Compound Assignment
count=0
# Increment by 1
count=$(( count + 1 )) # explicit form
$(( count++ )) # post-increment (returns old value, then adds 1)
$(( ++count )) # pre-increment (adds 1 first, then returns)
(( count++ )) # (( )) alone — no $ needed when not capturing value
# Compound assignment operators
n=10
(( n += 5 )) # n = n + 5 → 15
(( n -= 3 )) # n = n - 3 → 12
(( n *= 2 )) # n = n * 2 → 24
(( n /= 4 )) # n = n / 4 → 6
(( n %= 4 )) # n = n % 4 → 2
echo "$n"
2
(( expr )) without the leading $ evaluates the expression for its side effects (like updating a counter) and sets the exit status to 0 (true) if the result is non-zero, 1 (false) if zero. This is useful in loop conditions (Topic 6).Arithmetic with let
The let built-in is an older alternative that evaluates arithmetic expressions without needing $(( )) syntax. It is less commonly used in modern scripts but still appears in legacy code.
# let evaluates the expression directly
let x=5+3
echo "$x"
8
let x++
echo "$x"
9
# Equivalent using $(( )) — preferred in modern scripts
x=$(( 5 + 3 ))
(( x++ ))
2 — Floating-Point Arithmetic with bc
Bash's built-in arithmetic only handles integers. For decimal calculations you need an external tool — bc (basic calculator) is the standard choice. You pipe an expression to it as a string and capture the result.
# Basic: pipe an expression string to bc
echo "3.14 * 2" | bc
6.28
# scale= controls decimal places
echo "scale=2; 10 / 3" | bc
3.33
echo "scale=4; sqrt(2)" | bc -l # -l loads the math library (sqrt, sin, cos...)
1.4142
# Store result in a variable using command substitution
price=49.99
tax_rate=0.20
total=$(echo "scale=2; $price * (1 + $tax_rate)" | bc)
echo "Total with tax: £$total"
Total with tax: £59.98
# Comparison — bc returns 1 (true) or 0 (false)
result=$(echo "3.14 > 3" | bc)
if [ "$result" -eq 1 ]; then
echo "3.14 is greater than 3"
fi
-l flag loads bc's standard maths library, which provides sqrt(x), s(x) (sine), c(x) (cosine), a(x) (arctangent), e(x) (e^x), and l(x) (natural log). It also sets the default scale to 20 decimal places.
3 — String Length and Slicing
Bash provides parameter expansion operators for extracting information from strings. These work without any external commands — they are built directly into the shell.
str="Hello, World!"
# ${#var} — length of string
echo "Length: ${#str}"
Length: 13
# ${var:offset} — substring from offset to end
echo "${str:7}"
World!
# ${var:offset:length} — substring of given length
echo "${str:0:5}"
Hello
echo "${str:7:5}"
World
# Negative offset — count from the END of the string
# Note: space before negative number avoids confusion with ${var:-default}
echo "${str: -6}"
World!
echo "${str: -6:5}"
World
4 — Prefix and Suffix Removal
These operators strip matching patterns from the beginning or end of a string. They are extremely useful for manipulating file paths, extensions, and structured strings — all without calling sed or cut.
filepath="/home/philip/documents/report.final.txt"
# ${var#pattern} — remove SHORTEST match from the FRONT
echo "${filepath#*/}"
home/philip/documents/report.final.txt
# ${var##pattern} — remove LONGEST match from the FRONT
echo "${filepath##*/}" # strips everything up to last /
report.final.txt
# ${var%pattern} — remove SHORTEST match from the END
echo "${filepath%.*}" # strips last extension
/home/philip/documents/report.final
# ${var%%pattern} — remove LONGEST match from the END
echo "${filepath%%.*}" # strips everything from first dot
/home/philip/documents/report
file="/var/log/nginx/access.log"
# Filename only (equivalent to basename)
filename="${file##*/}"
echo "Filename : $filename"
Filename : access.log
# Directory only (equivalent to dirname)
dir="${file%/*}"
echo "Directory: $dir"
Directory: /var/log/nginx
# Extension only
ext="${filename##*.}"
echo "Extension: $ext"
Extension: log
# Filename without extension
base="${filename%.*}"
echo "Base name: $base"
Base name: access
#, ##, %, %% use glob wildcards, not regular expressions. * matches any sequence of characters, ? matches a single character, and [abc] matches a character class. Regular expression matching is covered in Topic 10.
5 — Search and Replace
Bash can search for a pattern in a string and replace it — again using parameter expansion, with no external tools.
sentence="the cat sat on the mat"
# ${var/pattern/replacement} — replace FIRST occurrence
echo "${sentence/the/a}"
a cat sat on the mat
# ${var//pattern/replacement} — replace ALL occurrences
echo "${sentence//the/a}"
a cat sat on a mat
# ${var/pattern/} — delete pattern (replace with nothing)
echo "${sentence// /}" # remove all spaces
thecatsatonthemat
# Replace only at the start (# anchor)
echo "${sentence/#the/a}"
a cat sat on the mat
# Replace only at the end (% anchor)
echo "${sentence/%mat/rug}"
the cat sat on the rug
# Practical: replace spaces with underscores in a filename
name="my document file.txt"
safe_name="${name// /_}"
echo "$safe_name"
my_document_file.txt
6 — Case Conversion
Bash 4.0 introduced built-in case conversion operators. These are available on almost all modern Linux systems (check with bash --version — you need 4.0 or higher).
str="Hello, World!"
# ${var^^} — convert ALL characters to UPPERCASE
echo "${str^^}"
HELLO, WORLD!
# ${var,,} — convert ALL characters to lowercase
echo "${str,,}"
hello, world!
# ${var^} — capitalise FIRST character only
word="hello"
echo "${word^}"
Hello
# ${var,} — lowercase FIRST character only
word="HELLO"
echo "${word,}"
hELLO
# With a pattern — only matching characters are changed
echo "${str^^[aeiou]}" # uppercase vowels only
HEllO, WOrld!
tr as a fallback: echo "$str" | tr '[:upper:]' '[:lower:]'#!/bin/bash
read -r -p "Continue? (yes/no): " answer
if [ "${answer,,}" = "yes" ]; then
echo "Proceeding..."
else
echo "Aborted."
fi
# "YES", "Yes", "yes", "yEs" all match cleanly
7 — Testing Strings
Before operating on a string it is often useful to check its length, or whether it contains a particular substring. Here are the standard approaches.
str="Hello, World!"
# Check if empty (zero length)
if [ -z "$str" ]; then echo "empty"; else echo "not empty"; fi
not empty
# Check if non-empty (non-zero length)
if [ -n "$str" ]; then echo "has content"; fi
has content
# Check if a string contains a substring — use glob matching in [[ ]]
if [[ "$str" == *"World"* ]]; then
echo "Contains 'World'"
fi
Contains 'World'
# Check if a string starts with a prefix
if [[ "$str" == "Hello"* ]]; then
echo "Starts with Hello"
fi
Starts with Hello
# Check if a string ends with a suffix
if [[ "$str" == *"!" ]]; then
echo "Ends with !"
fi
Ends with !
# String equality and inequality
if [ "$str" = "Hello, World!" ]; then echo "equal"; fi
if [ "$str" != "Goodbye" ]; then echo "not equal"; fi
[[ ]] (double brackets) is needed for glob matching with *. Conditionals are covered fully in Topic 5.8 — String Concatenation
Bash has no explicit concatenation operator — you simply place strings and variables next to each other inside double quotes. You can also use += to append to a string variable.
# Adjacent values concatenate automatically
first="Hello"
second="World"
combined="$first, $second!"
echo "$combined"
Hello, World!
# += appends to an existing string
msg="Hello"
msg+=", World"
msg+="!"
echo "$msg"
Hello, World!
# Building a string in a loop
csv=""
for item in apple banana cherry; do
csv+= "$item,"
done
# Strip trailing comma
echo "${csv%,}"
apple, banana, cherry
9 — Quick Reference
Arithmetic
| Syntax | What it does |
|---|---|
$(( a + b )) | Integer addition (also - * / % **) |
(( x++ )) | Post-increment x (side-effect only, no substitution) |
(( x += 5 )) | Compound assignment (also -= *= /= %=) |
let x=a+b | Alternative arithmetic (legacy — prefer $(( ))) |
echo "scale=2; expr" | bc | Floating-point arithmetic |
echo "expr" | bc -l | Float with maths library (sqrt, sin, cos…) |
String Operations
| Syntax | What it does | Example result |
|---|---|---|
${#var} | String length | ${#"hello"} → 5 |
${var:n} | Substring from index n | ${"hello":2} → llo |
${var:n:len} | Substring of length len from index n | ${"hello":1:3} → ell |
${var#pat} | Remove shortest prefix matching pat | ${"file.tar.gz"#*.} → tar.gz |
${var##pat} | Remove longest prefix matching pat | ${"file.tar.gz"##*.} → gz |
${var%pat} | Remove shortest suffix matching pat | ${"file.tar.gz"%.*} → file.tar |
${var%%pat} | Remove longest suffix matching pat | ${"file.tar.gz"%%.*} → file |
${var/pat/rep} | Replace first occurrence of pat | |
${var//pat/rep} | Replace all occurrences of pat | |
${var/#pat/rep} | Replace prefix pat | |
${var/%pat/rep} | Replace suffix pat | |
${var^^} | Convert to UPPERCASE (bash 4+) | |
${var,,} | Convert to lowercase (bash 4+) | |
${var^} | Capitalise first character (bash 4+) |
✏️ Exercises
Apply what you have learned in this chapter. Try each exercise yourself before looking at the sample solution.
calc.sh that accepts two numbers and an operator (+, -, *, /) as command-line arguments and prints the result. Division should show two decimal places. If the operator is not one of the four supported ones, print an error to stderr and exit with code 1.case statement on $2 (the operator). For division use bc with scale=2; for the others use $(( )). Quote the operator argument carefully to avoid shell interpretation of *.#!/bin/bash
# calc.sh — usage: ./calc.sh 10 + 3
a="$1"
op="$2"
b="$3"
case "$op" in
+) echo "$(( a + b ))" ;;
-) echo "$(( a - b ))" ;;
x) echo "$(( a * b ))" ;; # use 'x' to avoid shell expanding *
/) echo "scale=2; $a / $b" | bc ;;
*) echo "ERROR: unsupported operator '$op'. Use + - x /" >&2
exit 1 ;;
esac
We use x for multiply to avoid the shell expanding * into a filename glob. Run it as: ./calc.sh 10 + 3 or ./calc.sh 7 / 2
pathinfo.sh that accepts a full file path as a command-line argument and prints: the directory, the filename, the file extension, and the filename without its extension — all using parameter expansion only (no basename, dirname, or cut).${path%/*} for directory, ${path##*/} for filename, ${filename##*.} for extension, and ${filename%.*} for base name.#!/bin/bash
# pathinfo.sh — usage: ./pathinfo.sh /var/log/nginx/access.log
path="$1"
filename="${path##*/}"
echo "Full path : $path"
echo "Directory : ${path%/*}"
echo "Filename : $filename"
echo "Extension : ${filename##*.}"
echo "Base name : ${filename%.*}"
invoice.sh that stores a list of at least four item prices as variables, adds them together using arithmetic expansion, calculates 20% VAT, and prints a formatted invoice using printf showing each item, the subtotal, the VAT amount, and the grand total — all to 2 decimal places.bc with scale=2 for the float additions and VAT calculation. Use printf "%-20s £%8.2f\n" to align the rows.#!/bin/bash
# invoice.sh
item1_name="Raspberry Pi 5"; item1_price=74.99
item2_name="USB-C Cable"; item2_price=8.50
item3_name="MicroSD 64GB"; item3_price=11.99
item4_name="HDMI Adapter"; item4_price=12.00
subtotal=$(echo "scale=2; $item1_price + $item2_price + $item3_price + $item4_price" | bc)
vat=$(echo "scale=2; $subtotal * 0.20" | bc)
total=$(echo "scale=2; $subtotal + $vat" | bc)
printf "\n %-22s %s\n" "INVOICE" "$(date +%Y-%m-%d)"
printf ' %.0s─' {1..34}; echo
printf " %-22s £%7.2f\n" "$item1_name" $item1_price
printf " %-22s £%7.2f\n" "$item2_name" $item2_price
printf " %-22s £%7.2f\n" "$item3_name" $item3_price
printf " %-22s £%7.2f\n" "$item4_name" $item4_price
printf ' %.0s─' {1..34}; echo
printf " %-22s £%7.2f\n" "Subtotal" $subtotal
printf " %-22s £%7.2f\n" "VAT (20%%)" $vat
printf ' %.0s─' {1..34}; echo
printf " %-22s £%7.2f\n" "TOTAL" $total
echo
slugify.sh that accepts a string argument (a blog post title, for example) and converts it into a URL-friendly slug: lowercase, spaces replaced with hyphens, and any characters that are not letters, numbers, or hyphens removed. For example, "Hello World! This is Bash 4" should become hello-world-this-is-bash-4.${title,,} for lowercase, ${result// /-} to replace spaces, then pipe through tr -cd 'a-z0-9-' to strip non-slug characters.#!/bin/bash
# slugify.sh — usage: ./slugify.sh "Hello World! This is Bash 4"
title="$*" # $* joins all arguments into one string
# Step 1: lowercase
slug="${title,,}"
# Step 2: replace spaces with hyphens
slug="${slug// /-}"
# Step 3: remove any character that isn't a-z, 0-9, or hyphen
slug=$(echo "$slug" | tr -cd 'a-z0-9-')
# Step 4: collapse multiple consecutive hyphens into one
while [[ "$slug" == *"--"* ]]; do
slug="${slug//--/-}"
done
# Step 5: strip leading/trailing hyphens
slug="${slug#-}"
slug="${slug%-}"
echo "$slug"
Uses $* so you can call it as ./slugify.sh Hello World! This is Bash 4 without quotes. The while loop handling double hyphens is a preview of Topic 6.