Bash · lesson 8 of 22
Bash String Manipulation
Master Bash parameter expansion: substrings, prefix and suffix stripping, find-and-replace, case conversion, trimming, splitting and heredocs.
By SkillByExample · Updated
Every example below is a complete script, run with bash 5.2 on Ubuntu 24.04. None of
them starts an external process unless it says so. Parameter expansion does all of it
inside Bash.
Length and Substrings
${#var} gives the length. ${var:start:length} extracts part of the string, counting
from 0.
str="Hello, World!"
echo "length: ${#str}"
echo "7:5 -> ${str:7:5}"
echo "7 -> ${str:7}"
echo "' -6' -> ${str: -6}"
echo "'-6' -> ${str:-6}"
$ bash substr.sh
length: 13
7:5 -> World
7 -> World!
' -6' -> World!
'-6' -> Hello, World!
The last two lines differ by one space, and the space changes the operator. ${str: -6}
counts six characters back from the end. ${str:-6} is the default-value operator
from the variables lesson: “use 6 if str is empty”. str isn’t empty, so you get the
whole string back and no error.
Prefix and Suffix Stripping
# strips a pattern from the front and % from the back. Doubled (##, %%), each
takes the longest match instead of the shortest.
path="/home/alice/projects/app/main.sh"
echo "##*/ ${path##*/}"
echo "%/* ${path%/*}"
echo "#*/ ${path#*/}"
filename="backup.2025.tar.gz"
echo "##*. ${filename##*.}"
echo "#*. ${filename#*.}"
echo "%.* ${filename%.*}"
echo "%%.* ${filename%%.*}"
$ bash strip.sh
##*/ main.sh
%/* /home/alice/projects/app
#*/ home/alice/projects/app/main.sh
##*. gz
#*. 2025.tar.gz
%.* backup.2025.tar
%%.* backup
${path##*/} is basename and ${path%/*} is dirname, with no process started. The
file name shows why the choice matters: “the extension” of backup.2025.tar.gz could be
gz (##*.) or 2025.tar.gz (#*.). The patterns are globs, not regexes, so *
matches any characters.
Find and Replace
${var/pattern/new} replaces the first match. // replaces all of them. /# and /%
only match at the start and end.
sentence="foo bar foo baz foo"
echo "/ ${sentence/foo/X}"
echo "// ${sentence//foo/X}"
echo "/# ${sentence/#foo/X}"
echo "/% ${sentence/%foo/X}"
echo "del ${sentence//foo}"
echo "glob ${sentence//b??/_}"
$ bash replace.sh
/ X bar foo baz foo
// X bar X baz X
/# X bar foo baz foo
/% foo bar foo baz X
del bar baz
glob foo _ foo _ foo
With the replacement left out, matches are deleted. That left the spaces behind, which is
why del shows gaps. The pattern is a glob, so b?? matched both bar and baz.
Case Conversion
^ uppercases, , lowercases, and doubling either applies it to every character.
name="alice smith"
echo "${name^} | ${name^^}"
upper="ALICE SMITH"
echo "${upper,} | ${upper,,}"
for answer in y Y yes n; do
[[ ${answer,,} == y* ]] && echo "$answer -> continue" || echo "$answer -> abort"
done
$ bash case.sh
Alice smith | ALICE SMITH
aLICE SMITH | alice smith
y -> continue
Y -> continue
yes -> continue
n -> abort
${name^} capitalizes only the first character of the string, not of each word. Lowercase
user input with ${var,,} before you compare it.
Checking String Content
[[ ]] with a glob tests for contains, starts-with and ends-with. =~ takes a full regular
expression.
for email in user@example.com admin@corp user@@x.io; do
printf '%-18s' "$email"
[[ $email == *"@"* ]] && printf ' has-@'
[[ $email == user* ]] && printf ' starts-user'
[[ $email =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]] && printf ' valid'
echo
done
$ bash contains.sh
user@example.com has-@ starts-user valid
admin@corp has-@
user@@x.io has-@ starts-user
A “contains @” check passes for user@@x.io, which is clearly malformed. Only the anchored
regex rejects it. Keep regexes in a variable (re='^...$'; [[ $s =~ $re ]]) once they get
long. That also avoids quoting surprises.
Trimming Whitespace
Bash has no trim, but two nested expansions do the job. The inner one finds the leading
(or trailing) whitespace run, and the outer one strips it.
trim() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
printf '%s' "$s"
}
raw=" hello world "
echo "[$raw]"
echo "[$(trim "$raw")]"
echo "[$(echo "$raw" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')]"
$ bash trim.sh
[ hello world ]
[hello world]
[hello world]
Read it from the inside out. ${s%%[![:space:]]*} deletes everything from the first
non-space character onward, which leaves exactly the leading whitespace. ${s#"..."} then
removes that from the front. The sed version does the same thing and is easier to read,
but it runs an extra process on every call.
Splitting Strings and Herestrings
<<< (a herestring) feeds one string to a command’s stdin. Combined with IFS and read,
it splits a string into fields.
csv="alice,bob,carol"
IFS=',' read -ra names <<< "$csv"
echo "${#names[@]} names: ${names[1]} is second"
read -r first_word <<< "hello world"
echo "one variable: [$first_word]"
read -r first rest <<< "hello big world"
echo "two variables: [$first] [$rest]"
IFS=: read -r user _ uid _ _ home shell <<< "root:x:0:0:root:/root:/bin/bash"
echo "user=$user uid=$uid home=$home shell=$shell"
$ bash split.sh
3 names: bob is second
one variable: [hello world]
two variables: [hello] [big world]
user=root uid=0 home=/root shell=/bin/bash
read puts one field in each variable, and the last variable gets everything left over.
With only one variable, that means the whole line. To get just the first word, give read
a second variable to absorb the rest (_ works as a throwaway name). Writing IFS=...
directly in front of read changes it for that one command only.
Heredocs
A heredoc embeds a multi-line block. An unquoted delimiter expands variables. A quoted one
(<<'EOF') keeps everything literally.
name="Alice"
cat <<EOF
Hello, $name. 2 + 2 = $((2 + 2))
EOF
cat <<'EOF'
Literal: $name $((2 + 2)) `whoami`
EOF
cat > app.ini <<EOF
[server]
host = ${APP_HOST:-0.0.0.0}
port = ${APP_PORT:-8080}
EOF
cat app.ini
$ bash heredoc.sh
Hello, Alice. 2 + 2 = 4
Literal: $name $((2 + 2)) `whoami`
[server]
host = 0.0.0.0
port = 8080
Use <<'EOF' whenever the body is itself shell code or contains $, for example when you
generate a script from a script. <<-EOF also strips leading tab characters (not
spaces), so you can indent the body inside a function.
Putting It Together: Slugify
Lowercase, delete unwanted characters, then replace spaces:
slugify() {
local slug="${1,,}"
slug="${slug//[^a-z0-9 -]/}"
slug="${slug// /-}"
echo "$slug"
}
slugify "Hello, World! This is a Test"
slugify "Rock & Roll Hall of Fame"
$ bash slugify.sh
hello-world-this-is-a-test
rock--roll--hall-of-fame
The first input works. The second shows the bug: deleting & leaves two spaces in a row,
and the input already had a double space, so each gap becomes --. Practice task 1 fixes
it.
Practice
-
Fix
slugifyso runs of dashes collapse to one, and so the result never starts or ends with a dash.Solution
Turn on extended globs so
+(-)means “one or more dashes”:shopt -s extglob slugify() { local slug="${1,,}" slug="${slug//[^a-z0-9 -]/}" slug="${slug// /-}" slug="${slug//+(-)/-}" slug="${slug#-}" slug="${slug%-}" echo "$slug" } slugify "Rock & Roll Hall of Fame" slugify " --Leading and trailing!-- "$ bash p1.sh rock-roll-hall-of-fame leading-and-trailing -
For each of
photo1.jpeg holiday.v2.jpeg notes.txt, print themvcommand that would rename a.jpegfile to.jpg, and skip everything else.Solution
for f in photo1.jpeg holiday.v2.jpeg notes.txt; do [[ $f == *.jpeg ]] || continue echo "mv $f ${f%.jpeg}.jpg" done$ bash p2.sh mv photo1.jpeg photo1.jpg mv holiday.v2.jpeg holiday.v2.jpg%strips the shortest suffix, so the.v2inside the name is untouched. -
Split
DATABASE_URL=postgres://u:p@db/app?sslmode=requireinto its key and value. The value contains a second=.Solution
line="DATABASE_URL=postgres://u:p@db/app?sslmode=require" key="${line%%=*}" value="${line#*=}" echo "key=[$key]" echo "value=[$value]"$ bash p3.sh key=[DATABASE_URL] value=[postgres://u:p@db/app?sslmode=require]Longest-from-the-end (
%%=*) for the key, and shortest-from-the-front (#*=) for the value, so only the first=splits the line. -
Write
maskso that it prints a secret as asterisks, leaving only the last four characters visible.Solution
mask() { local s="$1" local keep="${s: -4}" local stars printf -v stars '%*s' $(( ${#s} - 4 )) '' echo "${stars// /*}$keep" } mask "sk_live_51HxYzAbCd9f3a" mask "12345678"$ bash p4.sh ******************9f3a ****5678printf -v stars '%*s' N ''builds N spaces straight into a variable, and//turns each one into*.