Strings And Numbers
3 min read
Core idea
bash has a surprisingly capable string and integer toolkit built directly into the language. Parameter expansion — the rich ${VAR…} family — handles defaults, substrings, prefix/suffix trimming, search-and-replace, and case conversion. Arithmetic expansion — $(( … )) and the (( … )) compound — handles integer math, bitwise operations, comparisons, and assignment. When you need anything more (floats, transcendentals, scale beyond integers), there's bc, an arbitrary-precision calculator that takes a small C-like language. The combined effect: most data wrangling that used to spawn sed, awk, or cut can stay in-shell, often two orders of magnitude faster.
Why it matters
Every external command in a tight loop pays the cost of fork + exec. A script that calls echo "$x" | cut -c1-3 ten thousand times can take seconds; the equivalent ${x:0:3} runs in milliseconds. Beyond performance, the in-shell forms are also more readable once you know them — the intent is right there in the expansion rather than smuggled through a pipe. And the moment you need floating point or precise decimal arithmetic — currency, loan amortisation, scientific calculation — you stop pretending bash can do it and reach for bc instead of building a tower of integer hacks.
Mental model
Two expansion families
The two protagonists are easy to confuse because both use $ syntax. Parameter expansion is ${…} and works on text. Arithmetic expansion is $((…)) and works on integers. Most useful scripts use both.
Parameter-expansion cheat sheet
The most-used forms in one place. parameter is any variable, positional parameter, or array reference.
| Expansion | Purpose |
| ------------------------------- | -------------------------------------------------------------------- |
| ${parameter:-word} | use word if parameter is unset/empty (doesn't assign) |
| ${parameter:=word} | use and assign word if unset/empty |
| ${parameter:?word} | fail script with message word if unset/empty |
| ${parameter:+word} | use word only if parameter is set; else empty |
| ${#parameter} | length in characters |
| ${parameter:offset:length} | substring starting at offset, optional length |
| ${parameter#pattern} / ## | strip shortest / longest matching prefix |
| ${parameter%pattern} / %% | strip shortest / longest matching suffix |
| ${parameter/pat/string} | replace first match |
| ${parameter//pat/string} | replace all matches |
| ${parameter^^} / ,, | upper / lower case (all chars) |
| ${parameter^} / , | upper / lower case (first char only) |
Arithmetic — what's actually integer
bash arithmetic is strictly integer. Division truncates: 5 / 2 == 2. Modulo recovers the remainder: 5 % 2 == 1. Numbers with a leading 0 are octal (010 == 8); 0x is hex; base#digits lets you use any base from 2 through 64. The two compound forms differ in what they're used for:
$(( expr ))— expansion; evaluates and substitutes the value. Use in command lines:i=$((i + 1)).(( expr ))— compound command; evaluates and sets$?based on result. Use as a test:if (( count > 10 )); then …. Returns exit 0 if expr is non-zero (true), exit 1 otherwise.
When integers stop being enough
You hit this wall the moment you need a percentage, a price, or a precise division:
echo $(( 1 / 3 )) # → 0 (truncated, useless for finance)
The signal is "the answer should have a decimal point." That's bc's job. bc accepts a scale variable that fixes the number of digits after the point, supports all the same operators, and has a tiny C-like language for functions and loops. The cheapest way to call it is a here-string:
result=$(bc -l <<< "scale=4; 1/3") # → .3333
Practical application
-
Default missing variables defensively. Top of every script:
: "${LOG_DIR:=/var/log/myapp}". The:is a no-op command that exists so the expansion runs for side-effect (the:=assigns if unset). -
Trim paths in-shell.
${path##*/}isbasename;${path%/*}isdirname;${file%.*}strips the extension. These run thousands of times faster than the external equivalents inside a loop. -
Replace, don't pipe.
${str//foo/bar}replaces everyfoowithbar— nosed, no subshell. Anchor with#(prefix) or%(suffix) for one-sided matches. -
Use
(( … ))for integer conditions.if (( i % 2 == 0 ))is clearer and faster thanif [[ $((i % 2)) -eq 0 ]]. Reserve[[ … ]]for string and file tests. -
Reach for
bc -learly. The moment you find yourself multiplying by 100 to fake decimals, you're working too hard.bc -l(with the math library) gives you sin, cos, log, exp, and an adjustablescale.
Example
A common operations task: parse a backup filename like db_main_20260315_034521.sql.gz into its components and decide whether it's older than 7 days. Pure parameter and arithmetic expansion, no external tools beyond date:
#!/usr/bin/env bash
file="db_main_20260315_034521.sql.gz"
# Strip path (if any) then extension layers.
name=${file##*/} # db_main_20260315_034521.sql.gz
stem=${name%.gz} # db_main_20260315_034521.sql
stem=${stem%.sql} # db_main_20260315_034521
# Split on underscores via successive trims.
prefix=${stem%%_*} # db
rest=${stem#*_} # main_20260315_034521
host=${rest%%_*} # main
ts=${rest#*_} # 20260315_034521
date_part=${ts%_*} # 20260315
time_part=${ts##*_} # 034521
# Compute "days since" with integer arithmetic.
file_epoch=$(date -d "${date_part:0:4}-${date_part:4:2}-${date_part:6:2}" +%s)
now_epoch=$(date +%s)
days_old=$(( (now_epoch - file_epoch) / 86400 ))
if (( days_old > 7 )); then
echo "$file is $days_old days old — eligible for archive"
fi
Notice what isn't there: no cut, no awk, no sed, no basename, no tr. The five different trims (##, %%, %, #, :offset:length) handle every parsing step, and (( … )) handles the age check.
Related lessons
Related concepts
- Pattern Matchinglinked concept