Zsh parameter expansion modifiers are the hidden superglue that holds complex shell scripting together, allowing you to manipulate strings with an elegance most shell users never dream of.

Let’s see it in action. Imagine you have a file list and want to extract just the filenames without their extensions.

# Create some dummy files
touch report.txt data.csv image.jpg script.sh

# List the files and loop through them
for f in *.{txt,csv,jpg,sh}; do
  # Extract the filename without extension using parameter expansion
  echo "Original: $f"
  echo "Filename: ${f%.*}"
done

# Clean up
rm report.txt data.csv image.jpg script.sh

This outputs:

Original: report.txt
Filename: report
Original: data.csv
Filename: data
Original: image.jpg
Filename: image
Original: script.sh
Filename: script

The core of this is ${f%.*}. Here, $f is the parameter (the filename). The %.* is the modifier. The % signifies "remove the shortest match of the following pattern from the end of the string." The .* is the pattern: a literal dot (.) followed by any character (.) zero or more times (*). So, ${f%.*} removes the shortest string starting with a dot from the end of $f. This effectively strips the file extension.

This is just a taste. Zsh parameter expansion is a deep well of string manipulation power. You can perform substitutions, check for existence, set defaults, and even perform case conversions, all within the parameter expansion syntax.

Consider these common scenarios:

  • Removing Suffixes: As seen above, ${parameter%word} removes the shortest match of word from the end of parameter. ${parameter%%word} removes the longest match.
  • Removing Prefixes: ${parameter#word} removes the shortest match of word from the beginning. ${parameter##word} removes the longest match.
  • Replacing Substrings: ${parameter/pattern/string} replaces the shortest match of pattern with string. ${parameter//pattern/string} replaces all matches.
  • Default Values: ${parameter:-default} substitutes default if parameter is unset or null. ${parameter:=default} substitutes default and assigns it to parameter if parameter is unset or null.
  • Case Conversion: ${parameter:u} converts to uppercase. ${parameter:l} converts to lowercase. ${parameter:U} converts the first character to uppercase. ${parameter:L} converts the first character to lowercase.

Let’s try a more complex example, extracting a username from a full path and ensuring it’s uppercase:

user_path="/home/users/alice/documents/report.txt"

# Extract the username (part after /home/users/ and before the next /)
# Then convert to uppercase
username_upper=${user_path#/home/users/}; username_upper=${username_upper%%/*}; username_upper=${username_upper:u}

echo "User path: $user_path"
echo "Extracted and uppercased username: $username_upper"

Output:

User path: /home/users/alice/documents/report.txt
Extracted and uppercased username: ALICE

Here, we chained two prefix removals and then a case conversion. ${user_path#/home/users/} removes the shortest match of /home/users/ from the beginning. The result is alice/documents/report.txt. Then, ${username_upper%%/*} takes that result and removes the longest match of /* from the end (effectively /* is the longest string starting with a slash). This leaves alice. Finally, ${username_upper:u} converts alice to ALICE.

The real power, and where many get tripped up, is in understanding how the patterns and the modifiers interact, especially with the greedy (##, %%) versus non-greedy (#, %) variants. For instance, if you had a path like /a/b/c/file.txt and wanted to get just a, using ${path##*/} would give you c because ##*/ matches the longest possible string from the beginning that ends in a slash. If you wanted to get the directory name immediately preceding the filename, you’d need to combine prefix and suffix removal carefully.

The next step in mastering shell scripting is understanding how to combine these parameter expansions with other shell features like arrays and associative arrays.

Want structured learning?

Take the full Zsh course →