Bash String Manipulation
Master parameter expansion, substrings, find-and-replace, trimming, case conversion, and heredocs in Bash.
Basic Parameter Expansion
Parameter expansion transforms a variable’s value at the point it is used, without modifying the variable itself. It is built into Bash and requires no external tools — no awk, no sed, no subshell. For simple string operations this makes your scripts both faster and easier to read.
name="Alice Smith"
echo "${#name}" # 11 — string length (# counts characters)
echo "${name:0:5}" # Alice — substring starting at index 0, length 5
echo "${name:6}" # Smith — from index 6 to the end
echo "${name: -5}" # Smith — last 5 characters (note the space before -)
Default Values
Default values solve a very common problem: a variable may or may not be set, and you want your script to work correctly in both cases. This is how scripts handle optional configuration and missing arguments gracefully.
# Use default if variable is unset or empty
greeting="${MESSAGE:-Hello}"
echo "$greeting" # Hello (if MESSAGE is not set)
# Use default only if unset (not if empty — subtle but important distinction)
value="${SETTING-default}"
# Assign the default and set the variable if it is unset or empty
: "${CONFIG_DIR:=/etc/myapp}"
# Error out immediately if variable is unset or empty (fail fast with a clear message)
: "${DATABASE_URL:?DATABASE_URL is required}"
Prefix and Suffix Stripping
The # and % operators strip patterns from the front and back of a variable. These are extremely useful for extracting filenames, extensions, and path components without calling basename or dirname.
path="/home/alice/projects/app/main.sh"
# Strip shortest match from the front (#)
echo "${path#/}" # home/alice/projects/app/main.sh
echo "${path#*/}" # alice/projects/app/main.sh
# Strip longest match from the front (##)
echo "${path##*/}" # main.sh (equivalent to basename)
# Strip shortest match from the end (%)
echo "${path%/*}" # /home/alice/projects/app (equivalent to dirname)
echo "${path%.*}" # /home/alice/projects/app/main
# Strip longest match from the end (%%)
echo "${path%%/*}" # (empty — everything after the first / is removed)
# Practical: extract file extension and base name
filename="report.2025.csv"
echo "${filename##*.}" # csv (extension)
echo "${filename%.*}" # report.2025 (name without last extension)
Find and Replace
The ${var/old/new} syntax replaces patterns within a variable. Use a single / to replace only the first match, or // to replace all occurrences. No external command needed.
text="The quick brown fox jumps over the lazy dog"
# Replace first occurrence only
echo "${text/fox/cat}" # The quick brown cat jumps over the lazy dog
# Replace all occurrences (//)
sentence="foo bar foo baz foo"
echo "${sentence//foo/replaced}" # replaced bar replaced baz replaced
# Replace only at the start (/#)
echo "${sentence/#foo/START}" # START bar foo baz foo
# Replace only at the end (/%}
echo "${sentence/%foo/END}" # foo bar foo baz END
Case Conversion (Bash 4+)
Case conversion operators were added in Bash 4.0. They let you normalize input — converting user-provided strings to a consistent case before comparing or storing them.
name="alice smith"
echo "${name^}" # Alice smith — capitalize first character only
echo "${name^^}" # ALICE SMITH — uppercase all characters
name="ALICE SMITH"
echo "${name,}" # aLICE SMITH — lowercase first character only
echo "${name,,}" # alice smith — lowercase all characters
A practical use — accepting both “Y” and “y” as confirmation:
read -rp "Continue? [y/N]: " answer
if [[ "${answer,,}" == "y" ]]; then
echo "Continuing..."
fi
Substring Extraction
Substring extraction uses ${var:start:length}. Negative start indices count from the end of the string (note the required space before the minus sign to avoid ambiguity with the :- default value operator).
str="Hello, World!"
echo "${str:7:5}" # World (start at index 7, take 5 characters)
echo "${str:7}" # World! (start at index 7 to end)
echo "${str: -6}" # orld! (last 6 characters — note the space)
echo "${str: -6:5}" # orld (last 6, then take only 5)
Checking String Content
Pattern matching inside [[ ]] lets you test string content without calling grep. Use == with glob patterns for simple prefix/suffix/contains checks, and =~ for full regular expressions.
email="user@example.com"
# Contains substring — glob with * on both sides
if [[ "$email" == *"@"* ]]; then
echo "Has @ sign"
fi
# Starts with — glob with * only at the end
if [[ "$email" == user* ]]; then
echo "Starts with 'user'"
fi
# Full format validation with regex
if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "Valid email format"
fi
Trimming Whitespace
Bash has no built-in trim function, but parameter expansion can do it. The pattern looks cryptic at first but works by stripping the leading whitespace-only prefix and the trailing whitespace-only suffix.
# Trim leading whitespace
ltrim() {
local s="$1"
s="${s#"${s%%[! ]*}"}" # strip the longest leading run of spaces
echo "$s"
}
# Trim trailing whitespace
rtrim() {
local s="$1"
s="${s%"${s##*[! ]}"}" # strip the longest trailing run of spaces
echo "$s"
}
# Trim both ends
trim() {
local s="$1"
s="${s#"${s%%[! ]*}"}"
s="${s%"${s##*[! ]}"}"
echo "$s"
}
result=$(trim " hello world ")
echo "'$result'" # 'hello world'
Or use sed when readability matters more than avoiding a subshell:
trim_sed() {
sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
}
echo " hello " | trim_sed # hello
String Splitting
Splitting a string on a delimiter is done by setting IFS temporarily for a read command. This is the idiomatic Bash way to parse delimited data like CSV fields or colon-separated paths.
# Split a CSV-like string into an array
csv="alice,bob,carol,dave"
IFS=',' read -ra names <<< "$csv"
for name in "${names[@]}"; do
echo "Name: $name"
done
# Name: alice
# Name: bob
# Name: carol
# Name: dave
# Split $PATH into its component directories
IFS=':' read -ra dirs <<< "$PATH"
echo "First PATH entry: ${dirs[0]}"
Heredoc
Heredocs embed multi-line strings directly in a script. They are cleaner than escaping every newline and are the standard way to write multi-line SQL queries, config file templates, email bodies, and usage messages.
# Basic heredoc — variables are expanded
cat <<EOF
This is line one.
This is line two.
Variables expand: $USER
EOF
# Indented heredoc — <<- strips leading tabs (not spaces)
generate_config() {
cat <<-EOF
[database]
host = localhost
port = 5432
name = myapp
EOF
}
# Quoted heredoc (single-quoted delimiter) — no variable expansion
cat <<'EOF'
No expansion: $USER $(date) `command`
All literal text — useful for writing scripts that contain bash syntax.
EOF
# Heredoc written directly to a file
cat > /etc/myapp/config.ini <<EOF
[server]
host = ${APP_HOST:-0.0.0.0}
port = ${APP_PORT:-8080}
EOF
Herestring
A herestring (<<<) feeds a single string to a command’s stdin. It avoids the overhead of echo "..." | command and does not create a subshell the way a pipe does.
# Instead of: echo "$var" | command
# Use: command <<< "$var"
read -r first_word <<< "hello world"
echo "$first_word" # hello
# Parse a colon-delimited line without a pipe (no subshell)
IFS=: read -r user _ uid gid _ home shell <<< "root:x:0:0:root:/root:/bin/bash"
echo "User: $user, UID: $uid, Home: $home"
Practical: Slugify a String
This function combines several string operations to convert an arbitrary string into a URL-safe slug — a common requirement when generating filenames, IDs, or URLs from human-readable input.
slugify() {
local input="$1"
local slug="${input,,}" # 1. convert to lowercase
slug="${slug//[^a-z0-9 -]/}" # 2. remove anything that is not a letter, digit, space, or dash
slug="${slug// /-}" # 3. replace spaces with dashes
echo "$slug"
}
slugify "Hello, World! This is a Test"
# hello-world-this-is-a-test
What’s Next
The next tutorial takes a deeper look at arrays — indexed and associative array operations, slicing, and reading files into arrays.