Build the mental model
Bash bundles a surprisingly complete string-manipulation toolkit directly into its parameter expansion syntax — the ${var...} forms — so many text transformations that would otherwise mean shelling out to sed or awk can be done inline, in the shell itself. ${#str} gives you a string's length; ${str:offset:length} slices out a substring; ${str/pattern/replacement} and ${str//pattern/replacement} substitute the first or every occurrence of a pattern; ${var^^} and ${var,,} convert to upper or lower case. The trim family — ${var#pattern}, ${var##pattern}, ${var%pattern}, ${var%%pattern} — deserves special attention because it is really two separate axes: # trims from the front and % trims from the back, while the doubled form (## or %%) is greedy and matches as much as possible versus the single form matching as little as possible. This is exactly why a path with several slashes, like /usr/local/bin/script.sh, gives a different result for ${path#*/} (strip up to the first slash) than ${path##*/} (strip up to the last slash, leaving just the filename). A related but distinct form, ${var:-default} and ${var:?message}, is not really about string manipulation at all — it is about safely handling variables that might be unset, letting a script fall back to a default value or fail loudly with a clear error instead of silently continuing with an empty string. Because none of this spawns a separate process, it is also meaningfully faster than calling out to external tools inside a loop.
Connect it to a real scenario
Say you're writing a script that processes uploaded filenames and needs to strip a file extension, validate that a required environment variable was set before continuing, and normalize a username to lowercase before comparing it — the code in this lesson walks through exactly these kinds of operations. Stripping the .tar.gz extension from archive.tar.gz shows the # vs ## / % vs %% distinction concretely: ${filename%.*} removes only the shortest match from the end, leaving archive.tar (the .gz is gone but .tar remains), while ${filename%%.*} greedily removes everything from the first dot onward, leaving just archive — which one you want depends on whether the file has one extension or a compound one like .tar.gz. The same logic applies in reverse to path in the example: ${path#*/} strips only up to the first slash, while ${path##*/} strips all the way to the last one, which is the idiomatic way to get a bare filename out of a full path without calling basename. For validating a required variable, ${required_var:?message} is deliberately more aggressive than ${greeting:-Hi there} — instead of quietly substituting a fallback, it aborts with an error message if the variable is unset or empty, which is exactly the behavior you want at the top of a script before doing anything destructive with a variable that should have been set by the caller.
Try the working example
#!/bin/bash
str="Hello, Bash World"
echo "Length: ${#str}"
echo "Substring: ${str:7:4}"
echo "Replace first: ${str/Bash/Shell}"
echo "Replace all: ${str//o/0}"
filename="archive.tar.gz"
echo "Remove shortest suffix: ${filename%.*}"
echo "Remove longest suffix: ${filename%%.*}"
path="/usr/local/bin/script.sh"
echo "Remove shortest prefix: ${path#*/}"
echo "Remove longest prefix: ${path##*/}"
unset greeting
echo "Default value: ${greeting:-Hi there}"
# ${var:?message} exits with an error if the variable is unset or empty
unset required_var
if (: "${required_var:?required_var must be set}") 2>/dev/null; then
echo "required_var is set"
else
echo "Caught missing variable error"
fi
name="bash"
echo "Uppercase: ${name^^}"
echo "Lowercase: ${name,,}"Running this script prints:
Length: 17
Substring: Bash
Replace first: Hello, Shell World
Replace all: Hell0, Bash W0rld
Remove shortest suffix: archive.tar
Remove longest suffix: archive
Remove shortest prefix: usr/local/bin/script.sh
Remove longest prefix: script.sh
Default value: Hi there
Caught missing variable error
Uppercase: BASH
Lowercase: bash5-minute try-it
Given a variable holding a full file path like /home/user/photos/vacation.jpg, use parameter expansion alone (no external commands) to print just the filename without its directory, and separately print just the extension without the filename.
One important caution
Mixing up # (shortest match, from the front) with ## (longest match, from the front), so ${path#*/} and ${path##*/} give very different results on a path with multiple slashes
Forgetting that ${var/pattern/replacement} only replaces the first occurrence — using // is required to replace all of them
GNU Bash Manual: Shell Parameter Expansion — Bash / Shell Scripting