Zsh Tutorial #13: String Operations Every Developer Needs. #13
Video: Zsh Tutorial #13: String Operations Every Developer Needs. #13 by Taught by Celeste AI - AI Coding Coach
Zsh Lesson 13: String Operations — Substring, Replace, Case, Split/Join
$#strfor length.$str[i,j]for substring.${str/old/new}first replace,${str//old/new}all.${str#prefix}strip prefix,${str%suffix}strip suffix.${(U)str},${(L)str},${(C)str}for case conversion.${(s:,:)str}split,${(j:-:)arr}join.
Most "string processing" in shell uses parameter expansion — no sed or awk needed for simple cases.
Length
greeting="Hello, World!"
echo "$#greeting" # 13
$#var returns the character length. Same as ${#var}.
Substrings
greeting="Hello, World!"
echo "$greeting[1,5]" # Hello
echo "$greeting[8,13]" # World!
echo "$greeting[3,-1]" # llo, World! — index 3 to end
echo "$greeting[-6,-1]" # World! — last 6 chars
Zsh slicing: $str[start,end]. 1-based, inclusive. Negative indices count from the end.
Concatenation
first="Jane"
last="Smith"
full="$first $last"
echo "$full" # Jane Smith
# Or
greeting="Hello, "
greeting+="World!"
echo "$greeting" # Hello, World!
+= appends. Or just write the variables one after another in a string.
Contains check
message="The quick brown fox"
if [[ "$message" == *"brown"* ]]; then
echo "found"
fi
== with * glob means "contains." For start: "$str" == prefix*. For end: "$str" == *suffix.
For regex match:
if [[ "$message" =~ "[a-z]+ox" ]]; then
echo "matches: ${BASH_REMATCH[0]}" # or $MATCH in Zsh
fi
Empty/non-empty
[[ -z "$empty" ]] # zero length
[[ -n "$filled" ]] # non-zero length
Always quote — empty unquoted vars become nothing and break [. (Zsh's [[ ]] is more forgiving but still — quote.)
Replace: first occurrence
msg="hello world hello world"
echo "${msg/hello/hi}"
# hi world hello world
${var/pattern/replacement} replaces the first match.
Replace: all occurrences
echo "${msg//hello/hi}"
# hi world hi world
Double slash = replace all.
Replace at start / end
echo "${var/#prefix/new}" # only at start
echo "${var/%suffix/new}" # only at end
Strip prefix / suffix
filepath="/home/user/docs/report.txt"
echo "${filepath#*/}" # home/user/docs/report.txt — strip shortest prefix
echo "${filepath##*/}" # report.txt — strip longest prefix (basename)
echo "${filepath%.*}" # /home/user/docs/report — strip shortest suffix (no ext)
echo "${filepath%/*}" # /home/user/docs — strip suffix from last / (dirname)
The mnemonics:
#strips prefix (top of keyboard, where strings start).%strips suffix (above5, on the right? loose mnemonic).- Double
##or%%is greedy; single is shortest match.
These replace dirname/basename for in-shell work:
file=$(basename "$filepath") # external command
file=${filepath##*/} # pure shell — same result, faster
Change extension
file="photo.jpeg"
newfile="${file%.jpeg}.png"
echo "$newfile" # photo.png
# Or generic — strip whatever extension
newfile="${file%.*}.png"
Case conversion (Zsh)
name="hello world"
echo "${(U)name}" # HELLO WORLD — upper
echo "${(L)name}" # hello world — lower
echo "${(C)name}" # Hello World — capitalize each word
(U), (L), (C) are Zsh expansion flags. Bash 4+ uses different syntax:
echo "${name^^}" # upper (Bash)
echo "${name,,}" # lower (Bash)
echo "${name^}" # first char upper (Bash)
For portability, use tr:
echo "$name" | tr '[:lower:]' '[:upper:]'
Case-insensitive comparison
input="Yes"
if [[ "${(L)input}" == "yes" ]]; then
echo "matches"
fi
Lowercase before compare. Or in [[ ]]:
[[ "$input:l" == "yes" ]] # Zsh-specific shortcut
Trim whitespace
raw=" jOhN dOe "
trimmed="${raw// /}" # remove all spaces
echo "$trimmed" # jOhNdOe
For just leading/trailing:
str=" hello "
str="${str#"${str%%[![:space:]]*}"}" # strip leading
str="${str%"${str##*[![:space:]]}"}" # strip trailing
echo "[$str]"
Cryptic. Easier:
echo "$str" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
Or in Zsh, the parameter expansion ${str##[[:space:]]##} strips leading whitespace.
Split into array
csv="apple,banana,cherry,date"
fruits=(${(s:,:)csv})
echo "$#fruits" # 4
for f in $fruits; do
echo " $f"
done
${(s:sep:)var} is "split on sep." Curly braces and parentheses required.
For PATH-like:
path_str="/usr/bin:/usr/local/bin:/home/user/bin"
dirs=(${(s.:.)path_str}) # use `.` as the delimiter wrapper since `:` is the separator
The wrapper around the separator can be any character that doesn't appear in the separator itself.
Join from array
colors=(red green blue yellow)
echo "${(j:-:)colors}" # red-green-blue-yellow
echo "${(j:, :)colors}" # red, green, blue, yellow
echo "${(j:|:)colors}" # red|green|blue|yellow
${(j:sep:)arr} joins with sep.
Bash equivalent:
( IFS=','; echo "${colors[*]}" )
# red,green,blue,yellow
Set IFS in a subshell for one-off joins.
Padding
echo "${(l:5:)num}" # left-pad to width 5 with spaces
echo "${(l:5::0:)num}" # left-pad to width 5 with '0' — useful for dates
echo "${(r:10:)str}" # right-pad to width 10
(l:N:) pads on the left to total length N. The third colon-arg is the pad character.
Reverse a string
str="hello"
echo "${(j::)${(s::)str:#*}}" # cryptic
# Or via tr/rev
echo "$str" | rev
# olleh
Use rev (an external utility). Pure-shell reversal is unreadable.
Real-world examples
Extract filename + extension
path="/home/alice/photos/sunset.jpeg.bak"
dir="${path%/*}" # /home/alice/photos
file="${path##*/}" # sunset.jpeg.bak
name="${file%%.*}" # sunset
ext="${file##*.}" # bak
Build a slug
title="Hello World 2026!"
slug="${(L)title}" # lowercase
slug="${slug// /-}" # spaces → dashes
slug="${slug//[!a-z0-9-]/}" # strip non-alphanum-dash
echo "$slug" # hello-world-2026
CSV processing
line="alice,30,engineer,portland"
fields=(${(s:,:)line})
name="$fields[1]"
age="$fields[2]"
role="$fields[3]"
Common stumbles
${var/old/new} for arrays. Replaces in each element:
arr=(hello world)
echo "${arr/o/0}"
# hell0 w0rld
Pattern is a glob, not regex. ${str/foo*/bar} matches "foo" followed by anything. For regex, use sed or [[ =~ ]].
Forget ${ } braces. $str/old/new doesn't work. Always wrap: ${str/old/new}.
Bash ^^ vs Zsh (U). Different syntax. Pick one shell or use tr.
Slicing 0-indexed. Zsh is 1-indexed. $str[0] is empty.
Split with multi-character delimiter. ${(s:--:)var} works. The colon-pair just delimits the separator string.
Trimming. Built-in trimming is awkward. For real work, sed 's/^[[:space:]]*//;s/[[:space:]]*$//'.
What's next
Lesson 14: file I/O. Reading and writing files in scripts; file tests.
Recap
$#str length. $str[i,j] substring (1-based). ${str/a/b} first replace, ${str//a/b} all. ${str#pre} ${str##pre} strip prefix; ${str%suf} ${str%%suf} strip suffix. ${(U)str} ${(L)str} ${(C)str} case (Zsh). ${(s:,:)str} split into array; ${(j:-:)arr} join. For complex regex/transform, escape to sed/awk/tr.
Next lesson: file I/O.