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.

Two expansion families

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

  1. 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).

  2. Trim paths in-shell. ${path##*/} is basename; ${path%/*} is dirname; ${file%.*} strips the extension. These run thousands of times faster than the external equivalents inside a loop.

  3. Replace, don't pipe. ${str//foo/bar} replaces every foo with bar — no sed, no subshell. Anchor with # (prefix) or % (suffix) for one-sided matches.

  4. Use (( … )) for integer conditions. if (( i % 2 == 0 )) is clearer and faster than if [[ $((i % 2)) -eq 0 ]]. Reserve [[ … ]] for string and file tests.

  5. Reach for bc -l early. 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 adjustable scale.

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.

Continue exploring

Tags