Back to Blog

Zsh Tutorial #13: String Operations Every Developer Needs. #13

Sandy LaneSandy Lane

Video: Zsh Tutorial #13: String Operations Every Developer Needs. #13 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Zsh Lesson 13: String Operations — Substring, Replace, Case, Split/Join

$#str for 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 (above 5, 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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.