Skip to main content
Bash · lesson 8 of 22
Bash intermediate 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

  1. Fix slugify so 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
  2. For each of photo1.jpeg holiday.v2.jpeg notes.txt, print the mv command that would rename a .jpeg file 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 .v2 inside the name is untouched.

  3. Split DATABASE_URL=postgres://u:p@db/app?sslmode=require into 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.

  4. Write mask so 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
    ****5678

    printf -v stars '%*s' N '' builds N spaces straight into a variable, and // turns each one into *.

Frequently Asked Questions

Do I need external tools like sed to manipulate strings in Bash?
Not for most operations. Bash's built-in parameter expansion handles substrings, replacement, trimming, and case conversion without forking an external process, which is faster for simple operations.
What is parameter expansion?
Parameter expansion is the ${variable} syntax with special modifiers that transform the value — like ${var#prefix} to strip a prefix, or ${var/old/new} to replace text.
What is a heredoc used for?
Heredocs let you write multi-line strings inline in a script without escaping newlines. They're commonly used for configuration templates, SQL queries, or multi-line messages.
Why does ${var:-6} not give me the last six characters?
Because :- is the default-value operator, so ${var:-6} means 'use 6 if var is empty'. To count from the end, put a space or parentheses before the minus sign: ${var: -6} or ${var:(-6)}.