Arrays, Here-docs, Strings and Regex · about 45 min

String manipulation

Why this matters

Scripts spend much of their time massaging names: strip the directory from a path, swap an extension, pull the date out of backup-2026-10-31.tar.gz, lowercase a user's input before comparing it. You could call basename, sed or cut for each of these, and early on you did. But each call is a separate process (a fork and an exec), which is slow inside a loop over thousands of files, and each brings its own quoting traps. bash can do nearly all of this itself, inside the ${...} of a parameter expansion.

The concept

All of these are parameter expansions: they happen at the same step as plain $var, they never change the variable itself, and they produce a new string. Quote the result like any expansion.

Length and substrings

$ s="Hello, world"
$ echo "${#s}"
12
$ echo "${s:7}"
world
$ echo "${s:0:5}"
Hello
$ echo "${s: -5:3}"
wor

${s:offset:length} counts from 0. A negative offset counts from the end, but needs a space (or parentheses) so bash does not read :- as the default-value operator.

Removing a prefix or suffix

FormRemovesFrom
${v#pattern}the shortest matchthe start
${v##pattern}the longest matchthe start
${v%pattern}the shortest matchthe end
${v%%pattern}the longest matchthe end

The pattern is a glob, the same language as file name patterns: * any string, ? one character, [...] a set. Not a regular expression.

$ f=/var/log/nginx/access.log.2.gz
$ echo "${f##*/}"            # basename: longest prefix ending in /
access.log.2.gz
$ echo "${f%/*}"             # dirname: shortest suffix starting with /
/var/log/nginx
$ echo "${f%.gz}"            # drop one known extension
/var/log/nginx/access.log.2
$ echo "${f%%.*}"            # drop everything from the first dot
/var/log/nginx/access
$ echo "${f##*.}"            # extension after the last dot
gz

If the pattern does not match, the value is returned unchanged, which is usually what you want: ${name%.txt} on README is README.

Search and replace

FormMeaning
${v/pat/rep}replace the first match
${v//pat/rep}replace every match
${v/#pat/rep}replace a match at the start only
${v/%pat/rep}replace a match at the end only
${v//pat}delete every match
$ d="2026-10-31"
$ echo "${d//-/}"
20261031
$ echo "${d/-//}"
2026/10-31
$ p="my holiday photo.jpg"
$ echo "${p// /_}"
my_holiday_photo.jpg
$ echo "${p//[aeiou]}"
my hldy pht.jpg

Changing case (bash 4+)

$ n="ada LOVELACE"
$ echo "${n^^}  ${n,,}  ${n^}"
ADA LOVELACE  ada lovelace  Ada LOVELACE

^^ uppercases everything, ,, lowercases everything, single ^ or , changes only the first character. Handy for case-insensitive comparisons: if [[ ${answer,,} == y* ]].

All of these work on arrays too

Applied to "${arr[@]}", an operation is done on every element:

$ files=(a.txt "b c.txt" d.txt)
$ printf '%s\n' "${files[@]%.txt}"
a
b c
d

What happens underneath

When bash meets ${f##*/}, it takes the value of f and tries the glob */ against prefixes of it, from the longest down (for ##), and keeps the rest. It is all string work inside the bash process: no fork, no exec, no pipe. Compare the two loops below on 5000 file names:

for f in "${files[@]}"; do b=$(basename "$f"); done   # 5000 forks + 5000 execs
for f in "${files[@]}"; do b=${f##*/}; done          # no new processes at all

On a typical machine the first takes several seconds and the second a few hundredths of a second. Each $(...) forks a subshell, and basename then needs an exec on top. For a single call the difference is irrelevant; inside loops it dominates.

The one thing expansion cannot do is work on a stream of lines; that remains the job of sed, awk and friends. Rule of thumb: one string in a variable, use expansion; a file or a pipeline, use the text tools.

Worked examples

Rename with a changed extension.

for f in *.jpeg; do
  mv -- "$f" "${f%.jpeg}.jpg"
done

Pull a date out of a file name. backup-2026-10-31.tar.gz:

name=backup-2026-10-31.tar.gz
date=${name#backup-}      # 2026-10-31.tar.gz
date=${date%%.*}          # 2026-10-31
year=${date:0:4}          # 2026

Trim whitespace. This uses an extended glob, so it needs shopt -s extglob (from the globbing module):

shopt -s extglob
s="   padded value  "
s=${s##+([[:space:]])}
s=${s%%+([[:space:]])}
echo "[$s]"     # [padded value]

Default to a sane value, then normalize.

mode=${1:-fast}
case ${mode,,} in
  fast|quick) ... ;;
  slow) ... ;;
esac

Split on a separator without cut.

pair="user=ana"
key=${pair%%=*}   # user
value=${pair#*=}  # ana

Common mistakes

Exercises

A quiz on the four trimming forms, a path splitter and a slug maker (both code), and a renaming task in the sandbox shell.

Recap

Going further: in man bash, the section "Parameter Expansion" lists every form, including ${v@Q} (quote a value so it can be reused as shell input) and ${!prefix*} (names of variables starting with a prefix). Try x="it's"; echo "${x@Q}".

Exercises

Exercise 1 · quiz

Which expansions are right?

With path=/home/ana/archive.tar.gz, which statements are true? Select all that apply.

Answer
  • ${path##*/} is archive.tar.gz

  • ${path%.*} is /home/ana/archive.tar

  • ${path%%.*} is /home/ana/archive

# removes from the front, % from the back; doubled (##, %%) means the longest match, single the shortest. ${path#*.} removes the shortest prefix ending in a dot, /home/ana/archive., leaving tar.gz. To get just gz you need the longest prefix: ${path##*.}. A memory aid: on a US keyboard # is left of $ and % is right of it, so # cuts the left end and % the right.

Exercise 2 · write code

Take a path apart

Write a script that takes one path as its argument and prints its parts, using only parameter expansion (no basename, dirname, sed, awk or cut). For /home/ana/archive.tar.gz:

dir: /home/ana
base: archive.tar.gz
stem: archive.tar
ext: gz

Rules:

File: script.sh

Starting point

path=$1
# print dir:, base:, stem: and ext: lines

Example with arguments /home/ana/archive.tar.gz

expected output

dir: /home/ana
base: archive.tar.gz
stem: archive.tar
ext: gz

Example with arguments notes.txt

expected output

dir: .
base: notes.txt
stem: notes
ext: txt
Hint 1

${path##*/} removes the longest prefix ending in /; ${path%/*} removes the shortest suffix starting with /.

Hint 2

Check first whether there is a slash at all: [[ $path == */* ]]. Same idea for the dot in base: [[ $base == *.* ]].

Hint 3

${base%.*} is the stem, ${base##*.} the extension.

Reference solution
path=$1
if [[ $path == */* ]]; then
  dir=${path%/*}
else
  dir=.
fi
base=${path##*/}
if [[ $base == *.* ]]; then
  stem=${base%.*}
  ext=${base##*.}
else
  stem=$base
  ext=
fi
echo "dir: $dir"
echo "base: $base"
echo "stem: $stem"
echo "ext: $ext"

Exercise 3 · task in a shell

Fix the extensions

The directory /work/camera holds photos copied from a camera. In the shell, rename every file that ends in .JPG so that it ends in .jpg instead, keeping the rest of the name exactly (one name contains a space). Leave all other files alone, including notes.TXT.

Use a for loop over a glob and ${f%.JPG}; do not type the names one by one.

Hint 1

for f in camera/*.JPG; do ...; done visits exactly the files ending in .JPG.

Hint 2

${f%.JPG} is the name without the extension; add .jpg back: mv -- "$f" "${f%.JPG}.jpg". Keep the quotes for the name with a space.

Reference solution
for f in camera/*.JPG; do
  mv -- "$f" "${f%.JPG}.jpg"
done

Exercise 4 · write code

Make a URL slug

Write a script that turns its one argument (a title) into a URL slug:

  1. convert it to lowercase,
  2. replace every run of characters that are not a-z or 0-9 with a single -,
  3. remove any - at the start or end.

Examples: Hello, World! 2026 becomes hello-world-2026; C++ & Rust becomes c-rust. Print the slug followed by a newline. Use parameter expansion, not sed or tr.

File: script.sh

Starting point

title=$1

Example with arguments Hello, World! 2026

expected output

hello-world-2026

Example with arguments C++ & Rust

expected output

c-rust
Hint 1

${1,,} lowercases. ${s//[^a-z0-9]/-} replaces every unwanted character (one at a time) with -.

Hint 2

Now runs of dashes remain. Repeat s=${s//--/-} while [[ $s == *--* ]].

Hint 3

Finally strip one leading and one trailing dash with ${s#-} and ${s%-}.

Reference solution
s=${1,,}
s=${s//[^a-z0-9]/-}
while [[ $s == *--* ]]; do
  s=${s//--/-}
done
s=${s#-}
s=${s%-}
echo "$s"