String Manipulation in Bash

Bash doesn’t have a dedicated string data type the way Python or JavaScript do — every variable holds a sequence of characters, and even numbers are just text until you ask the shell to do arithmetic with them. What Bash does have is a large set of parameter expansion operators built directly into the shell’s syntax for slicing, searching, replacing, and reshaping that text without ever launching an external program. Once you know these operators, you can parse a filename, sanitize user input, or format a log message using nothing but Bash itself — faster than shelling out to sed, awk, or cut for simple jobs, and with one less external dependency to worry about. This lesson covers every common string operation in Bash: measuring length, extracting substrings, trimming whitespace, searching and replacing text, converting case, and comparing strings correctly.

Overview: How Strings Work in Bash

When you write name="Alice", Bash simply stores the characters Alice under the variable name. There’s no separate integer, float, or string class — everything is text, and commands like arithmetic expansion ($(( ))) or [ -eq ] are what interpret that text as a number when needed. Because a string is just its characters, ${#name} (5 in this case) counts characters, not bytes, based on the current locale.

The operators covered in this lesson — ${var#pattern}, ${var/pattern/repl}, ${var^^}, and friends — are called parameter expansion. They run during the shell’s expansion phase, before the resulting command line is actually executed: Bash sees ${...}, computes a replacement value from the variable and the operator, and substitutes that value into the command line as a plain word. If that word isn’t quoted, it’s then subject to the usual word-splitting and glob expansion — which is exactly why every example in this lesson wraps expansions in double quotes.

A detail that trips up almost everyone the first time: the pattern argument in #, ##, %, %%, /, and // is a glob pattern — the same kind of wildcard syntax you use with ls *.txt — not a regular expression. * means “any characters,” ? means “one character,” and [![:space:]] means “one character that is not whitespace.” Regex syntax like \d, +, or (a|b) means nothing special here. If you need real regular expressions, use the [[ "$str" =~ regex ]] construct instead, which is the one place Bash string matching uses POSIX extended regex.

One more thing worth knowing up front: the case-conversion operators (${var^^}, ${var,,}, and their single-character forms) were added in Bash 4.0. Every current Linux distribution ships a new enough Bash for this, but macOS’s default /bin/bash is still the ancient 3.2 for licensing reasons — not relevant on Linux, but worth knowing if you ever test a script on a Mac.

Syntax

The general form is always ${variable operator pattern-or-value}. Here is the full reference of the operators covered in this lesson:

Form Meaning
${#var} Length of var in characters
${var:offset} / ${var:offset:length} Substring starting at offset, optionally limited to length characters
${var#pattern} Remove the shortest match of pattern from the start
${var##pattern} Remove the longest match of pattern from the start
${var%pattern} Remove the shortest match of pattern from the end
${var%%pattern} Remove the longest match of pattern from the end
${var/pattern/repl} Replace the first match of pattern with repl
${var//pattern/repl} Replace every match of pattern with repl
${var/#pattern/repl} Replace pattern only if it matches at the very start
${var/%pattern/repl} Replace pattern only if it matches at the very end
${var^} / ${var^^} Uppercase the first character / uppercase the whole string
${var,} / ${var,,} Lowercase the first character / lowercase the whole string
${var:-default} Expand to default if var is unset or empty (does not change var)
${var:+alt} Expand to alt only if var IS set and non-empty

A quick illustration using the front-strip and back-strip operators on a filename:

name="report.tar.gz"
echo "${name%%.*}"
echo "${name#*.}"

Output:

report
tar.gz

${name%%.*} strips everything from the first dot onward (removing the longest match of .* from the end), leaving report. ${name#*.} strips everything up to and including the first dot, leaving tar.gz.

Examples

Example 1: Splitting a File Path into Directory, Name, and Extension

#!/usr/bin/env bash
filepath="/var/log/app/access.log"

filename="${filepath##*/}"
directory="${filepath%/*}"
extension="${filename##*.}"
basename="${filename%.*}"

echo "Directory: $directory"
echo "Filename:  $filename"
echo "Basename:  $basename"
echo "Extension: $extension"

Output:

Directory: /var/log/app
Filename:  access.log
Basename:  access
Extension: log

${filepath##*/} removes everything up to and including the last /, leaving just the filename. ${filepath%/*} removes the shortest match of /* from the end, which strips from the last / onward and leaves the directory. The same pair of ideas, applied to the dot instead of the slash, pulls the extension and base name out of the filename. No basename or dirname external command was needed.

Example 2: Sanitizing User Input into a Clean Username

#!/usr/bin/env bash
raw_input="  John Q. Public  "

trimmed="${raw_input#"${raw_input%%[![:space:]]*}"}"
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"

lower="${trimmed,,}"
username="${lower// /_}"
username="${username//./}"

echo "Original: '$raw_input'"
echo "Username: $username"

Output:

Original: '  John Q. Public  '
Username: john_q_public

This chains several operators together: the two nested expansions trim leading and trailing whitespace (explained in detail in the next section), ${trimmed,,} lowercases everything, ${lower// /_} replaces every space with an underscore, and ${username//./} deletes every literal dot. The result is a safe, predictable username built with zero calls to sed or tr.

Example 3: Formatting a Log Message with Case Conversion and Pattern Matching

#!/usr/bin/env bash
level="warning"
message="disk usage above 90%"

level_upper="${level^^}"

if [[ "$message" == *"disk"* ]]; then
  category="storage"
else
  category="general"
fi

echo "[$level_upper] ($category) $message"

Output:

[WARNING] (storage) disk usage above 90%

${level^^} uppercases the whole log level for a consistent tag. The [[ "$message" == *"disk"* ]] test uses a glob pattern with wildcards on both sides of disk to check whether that word appears anywhere in the string — a simple, fast way to categorize text without a regex engine.

How It Works, Step by Step

Take filename="${filepath##*/}" from Example 1. Bash looks at the pattern */ and asks: what is the longest prefix of $filepath that matches “any characters followed by a slash”? That consumes /var/log/app/ entirely, leaving access.log. Change ## to a single # and it would instead take the shortest such prefix — matching only the first / and leaving var/log/app/access.log. This greedy-versus-lazy distinction between the doubled and single forms is the key to all four strip operators.

The nested expansions in Example 2 look intimidating but follow the same rule applied twice. Bash evaluates the innermost ${...} first, producing a plain string, then uses that string as the literal pattern for the outer operator. So ${raw_input%%[![:space:]]*} first computes “everything from the first non-space character onward” and removes it, leaving just the leading whitespace as a literal string; that literal string is then fed into ${raw_input#"..."}, which strips exactly that leading whitespace from the original variable. The second line repeats the trick from the other end to strip trailing whitespace. All of this happens inside the running Bash process’s own memory — no subshell, no temporary file, no external process is ever started.

Common Mistakes

Mistake 1: Forgetting the Space in a Negative Substring Offset

To count from the end of a string, you write ${var: -N} — and that leading space before the minus sign is not optional. Without it, Bash parses -N as the default value operator instead of a substring offset.

token="a1b2c3d4e5f6"
last_three="${token:-3}"
echo "$last_three"

Because token is already set and non-empty, ${token:-3} just returns $token unchanged — the whole string prints, not the last three characters, and there’s no error to warn you.

token="a1b2c3d4e5f6"
last_three="${token: -3}"
echo "$last_three"

With the space, Bash correctly reads this as “substring starting at offset -3” and prints 5f6.

Mistake 2: Leaving a Variable Unquoted Inside a Test

name="John Smith"
if [ $name == "John Smith" ]; then
  echo "Match"
fi

Because $name is unquoted, Bash word-splits it into John and Smith before [ ] ever sees it, turning a two-argument comparison into a malformed test with too many arguments. This fails with an error instead of matching.

name="John Smith"
if [[ "$name" == "John Smith" ]]; then
  echo "Match"
fi

Quoting "$name" keeps it as a single word, and [[ ]] additionally never word-splits unquoted expansions even if you forget the quotes — but quoting is still the habit to build, since [[ ]] won’t save you everywhere (for example, inside a for loop over the same value).

Mistake 3: Comparing Strings with -eq

status="active"
if [ "$status" -eq "active" ]; then
  echo "Service is active"
fi

-eq is a numeric comparison operator. Running this produces bash: [: active: integer expression expected, because test tries to convert "active" to a number and fails.

status="active"
if [ "$status" == "active" ]; then
  echo "Service is active"
fi

Use == (or = for strict POSIX [ ]) for strings, and reserve -eq, -ne, -lt, -gt for numbers.

Best Practices

  • Prefer [[ ]] over [ ] for string tests in Bash-specific scripts — it doesn’t word-split unquoted variables and supports pattern matching with == directly.
  • Always quote variable expansions ("$var") unless you deliberately want word splitting or globbing to happen.
  • Reach for parameter expansion (${var#...}, ${var//...}, ${var,,}) instead of spawning sed, awk, or cut for simple, single-variable string operations — it avoids a subprocess and is measurably faster in loops.
  • Remember that pattern arguments in expansion operators are glob patterns, not regex; use [[ str =~ regex ]] when you genuinely need regular expressions.
  • Case-conversion operators (${var^^}, ${var,,}) require Bash 4.0+; check bash --version if you’re not sure of your target environment.
  • Test your string logic against edge cases: empty strings, strings that are only whitespace, and unset variables, before trusting a script with real input.

Practice Exercises

  • Write a script that takes a URL like https://learn.programmingline.com/learn/linux-bash-string-manipulation stored in a variable and prints just the path after the domain (/learn/linux-bash-string-manipulation). Hint: you’ll need to strip through three slashes using ## with a pattern like */*/*/.
  • Write a Bash function called slugify that turns "Hello World! Linux Rocks" into hello-world-linux-rocks: lowercase everything, replace spaces with hyphens, and remove the exclamation mark. Hint: chain ${var,,}, ${var// /-}, and ${var//!/}.
  • Write a script that reads a filename from the user with read and prints an error unless it ends in .txt. Try it two ways: once with a substring check on the last four characters, and once with a [[ ]] glob pattern match.

Summary

  • Bash has no separate string type — every variable is text, and ${#var} gives its character length.
  • ${var:offset:length} extracts substrings; remember the required space before a negative offset.
  • ${var#pattern} / ${var%pattern} strip the shortest match from the start/end; the doubled forms ## / %% strip the longest match.
  • ${var/pattern/repl} replaces the first match, ${var//pattern/repl} replaces every match.
  • ${var^^}, ${var,,}, and their single-character forms convert case (Bash 4+ only).
  • All pattern arguments in these operators are glob patterns, not regular expressions — use [[ str =~ regex ]] for real regex.
  • Always quote expansions and prefer [[ ]] for string tests to avoid word-splitting bugs.