← Back to DevBytes

Fish Scripting: String Manipulation Complete Guide

What Is Fish String Manipulation?

Fish shell provides a dedicated string utility that serves as a Swiss Army knife for text processing. Unlike POSIX shells that rely on external tools like sed, awk, or cut, Fish ships with a built-in command that handles substring extraction, replacement, splitting, joining, trimming, and more — all with a consistent, readable syntax. The string command was introduced in Fish 2.3.0 and has become one of the most celebrated features of the shell.

Why String Manipulation Matters in Shell Scripting

Shell scripts spend much of their time wrangling text: parsing command output, transforming file names, constructing arguments, formatting logs, and sanitizing user input. In traditional shells, each of these tasks requires remembering a different external tool with its own quirks. Fish unifies these operations under one command, reducing context switching, improving readability, and eliminating many classes of quoting bugs. For developers writing maintainable scripts, mastering string is not optional — it's fundamental.

Getting Started: The string Command Structure

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

The string command follows a consistent pattern:

string SUBCOMMAND [OPTIONS] ARGUMENTS...

Input data can come from arguments or from stdin. When arguments are provided, they are processed directly. When stdin is piped in, each line is treated as a separate input item. This dual input mechanism makes string incredibly flexible in pipelines. Here are all the available subcommands:

Measuring String Length

The simplest operation is finding out how many characters a string contains. This is especially useful for validation, truncation logic, or progress display formatting.

# Measure a literal string
string length "hello world"
# Output: 11

# Measure a variable
set my_var "Fish Shell"
string length $my_var
# Output: 10

# Measure each line from stdin
echo -e "one\ntwo\nthree" | string length
# Output:
# 3
# 3
# 5

Extracting Substrings with string sub

The string sub command extracts portions of a string using zero-based indexing. You specify the start position and optionally the length. If you omit length, the substring goes to the end of the string. Negative start values count from the end of the string.

# Extract from position 0, length 5
string sub --length 5 "Hello World"
# Output: Hello

# Extract from position 6 to end
string sub --start 6 "Hello World"
# Output: World

# Extract the last 5 characters using negative start
string sub --start -5 "Hello World"
# Output: World

# Extract a slice: start at position 2, take 7 characters
string sub --start 2 --length 7 "Hello Wonderful World"
# Output: llo Won

# Work with piped input
printf "apple\nbanana\ncherry" | string sub --start 2 --length 3
# Output:
# ple
# nan
# err

Splitting Strings Apart

string split breaks a string into pieces based on a delimiter. By default it splits on each occurrence. The --fields option lets you extract specific fields, similar to cut. The --max option limits the number of splits. An empty delimiter splits on every character.

# Simple split on comma
string split "," "red,green,blue"
# Output:
# red
# green
# blue

# Split with --fields to extract specific columns
string split --fields 2,3 "," "apple,banana,cherry,date"
# Output:
# banana
# cherry

# Split path-like strings
string split "/" "/usr/local/bin/fish"
# Output:
# 
# usr
# local
# bin
# fish

# Limit splits with --max
string split --max 2 ":" "a:b:c:d"
# Output:
# a
# b
# c:d

# Split on every character (empty delimiter)
string split "" "abc"
# Output:
# a
# b
# c

# Split piped input
echo "one two three" | string split " "
# Output:
# one
# two
# three

Joining Strings Together

The counterpart to split is string join. It takes a delimiter and a list of strings, combining them into a single string. This is perfect for constructing paths, CSV lines, or any delimited output. When used without a delimiter argument, it joins with no separator at all.

# Join with a delimiter
string join "," apple banana cherry
# Output: apple,banana,cherry

# Join with a colon for PATH-like constructs
string join ":" usr local bin
# Output: usr:local:bin

# Join without a delimiter (concatenate)
string join "" hello world
# Output: helloworld

# Join piped input lines into a single string
echo -e "line1\nline2\nline3" | string join ", "
# Output: line1, line2, line3

# Practical: build a classpath
set jars "core.jar" "util.jar" "parser.jar"
set classpath (string join ":" $jars)
echo $classpath
# Output: core.jar:util.jar:parser.jar

Trimming Whitespace and Characters

string trim strips unwanted characters from the edges of strings. By default it removes whitespace (spaces, tabs, newlines). You can specify custom characters to trim, or use --left or --right to trim only one side.

# Trim whitespace from both sides
string trim "   hello   "
# Output: hello

# Trim only leading whitespace
string trim --left "   hello   "
# Output: hello   

# Trim only trailing whitespace
string trim --right "   hello   "
# Output:    hello

# Trim custom characters (quotes)
string trim --chars '"' '"quoted string"'
# Output: quoted string

# Trim multiple specific characters
string trim --chars '()[]{}' "(enclosed)"
# Output: enclosed

# Practical: sanitize user input from a file
cat user_input.txt | string trim | string collect

Replacing Text with string replace

string replace substitutes occurrences of a pattern with a replacement string. By default it replaces only the first match. Use --all to replace every occurrence. Use --regex for regular expression matching. The --max flag limits the number of replacements.

# Replace first occurrence (literal)
string replace "dog" "cat" "I love my dog and dog"
# Output: I love my cat and dog

# Replace all occurrences
string replace --all "dog" "cat" "I love my dog and dog"
# Output: I love my cat and cat

# Replace using regex
string replace --regex --all "[0-9]+" "X" "file123 backup456"
# Output: fileX backupX

# Replace with captured groups
string replace --regex '(\w+)\.(\w+)' '$2.$1' "index.html"
# Output: html.index

# Limit number of replacements
string replace --all --max 2 "a" "A" "aaaaa"
# Output: AAaaa

# Replace with empty string (deletion)
string replace --all "-" "" "2024-01-15"
# Output: 20240115

# Pipeline replacement
git branch | string replace --all " " "" | string trim

Pattern Matching with string match

string match tests whether strings conform to a pattern. It supports both glob patterns and regular expressions (with --regex). Matches return the matching portion; non-matches return nothing and exit with status 1. The --entire flag forces the pattern to match the entire string. The --groups-only flag returns only capture groups.

# Simple glob match
string match "*.txt" "document.txt"
# Output: document.txt

# No match (returns nothing, exit code 1)
string match "*.jpg" "document.txt"

# Regex match
string match --regex "^[A-Z]{3}-\d{4}$" "ABC-1234"
# Output: ABC-1234

# Extract capture groups
string match --regex --groups-only '(\w+)@(\w+\.\w+)' 'user@example.com'
# Output:
# user
# example.com

# Match entire string only
string match --entire "file" "file.txt"
# No output (partial match fails with --entire)

# Use in conditionals
if string match --regex --quiet '^[a-z]+$' "$input"
    echo "Input is lowercase alphabetic"
else
    echo "Input contains other characters"
end

# Match against multiple arguments
string match --regex '\.txt$' "file.txt" "image.png" "notes.txt"
# Output:
# file.txt
# notes.txt

Escaping and Unescaping Strings

When you need to safely embed strings in code or handle backslash-escaped sequences, string escape and string unescape are invaluable. They handle quotes, backslashes, and control characters properly.

# Escape a string for safe use in shell
string escape 'He said "hello"'
# Output: He\ said\ \"hello\"

# Escape newlines and tabs
string escape "Line one
Line two\tindented"
# Output: Line\ one\ \nLine\ two\ \tindented

# Unescape a backslash-escaped string
string unescape 'He\ said\ \\"hello\\"'
# Output: He said "hello"

# Escape for use in a regex pattern
string escape --style=regex 'file[0-9].txt'
# Output: file\[0\-9\]\.txt

# Escape only specific characters
string escape --style=script 'path/with/slashes'

Repeating and Padding Strings

string repeat duplicates a string a specified number of times. string pad ensures strings reach a minimum width by adding characters to the left or right.

# Repeat a character for visual separation
string repeat --count 40 "="
# Output: ========================================

# Repeat a pattern
string repeat --count 3 "abc"
# Output: abcabcabc

# Repeat piped input
echo "na" | string repeat --count 4
# Output: nananana

# Pad left to minimum width (default is spaces)
string pad --width 10 "42"
# Output:         42

# Pad right
string pad --right --width 10 "42"
# Output: 42        

# Pad with a custom character
string pad --width 10 --char "0" "42"
# Output: 0000000042

# Useful for zero-padding numbers
seq 1 5 | string pad --width 3 --char "0"
# Output:
# 001
# 002
# 003
# 004
# 005

Collecting Output into a Single String

Introduced in Fish 3.0, string collect gathers all input lines and concatenates them into a single string, separated by newlines by default. Use --no-newline to concatenate without separators. This is the opposite of splitting and is perfect for capturing multi-line command output into one variable.

# Collect multiple lines into one string
echo -e "line1\nline2\nline3" | string collect
# Output (with embedded newlines):
# line1
# line2
# line3

# Collect without trailing newline
echo -e "a\nb\nc" | string collect --no-newline
# Output: abc

# Capture command output into a variable
set files (ls *.txt | string collect)
echo "Files: $files"

# Combine with other string operations
git log --oneline -5 | string collect | string replace --all "\n" "; "

Practical Recipes and Real-World Patterns

Recipe 1: Extract a File Extension

function get_extension
    set filename $argv[1]
    string match --regex --groups-only '\.(\w+)$' $filename
end

get_extension "photo.jpg"    # Output: jpg
get_extension "archive.tar.gz" # Output: gz

Recipe 2: Convert a String to Lowercase/Uppercase

Fish doesn't have a dedicated case conversion subcommand, but you can achieve it using string replace with character classes and a creative approach, or by leveraging external tools. The most idiomatic Fish way uses string sub with tr in a pipeline:

# Using tr for full case conversion
echo "Hello World" | tr '[:upper:]' '[:lower:]'
# Output: hello world

echo "Hello World" | tr '[:lower:]' '[:upper:]'
# Output: HELLO WORLD

# Pure Fish approach for single-character mapping
# (For full strings, tr is more practical)

Recipe 3: Parse Key-Value Configuration Files

# Parse a config line like "PORT=8080"
function parse_config
    set line $argv[1]
    set key (string split --fields 1 "=" $line)
    set value (string split --fields 2 "=" $line)
    echo "Key: $key, Value: $value"
end

parse_config "PORT=8080"
# Output: Key: PORT, Value: 8080

parse_config "HOST=localhost"
# Output: Key: HOST, Value: localhost

Recipe 4: Slugify a String for URLs

function slugify
    set input $argv[1]
    echo $input | \
        string replace --all --regex '[^a-zA-Z0-9]' '-' | \
        string replace --all --regex '-+' '-' | \
        string trim --chars '-' | \
        tr '[:upper:]' '[:lower:]'
end

slugify "Hello World! How Are You?"
# Output: hello-world-how-are-you

Recipe 5: Extract All Numbers from a String

function extract_numbers
    set input $argv[1]
    echo $input | string match --all --regex '\d+'
end

extract_numbers "Server 192.168.1.1 has 4 cores and 16GB RAM"
# Output:
# 192
# 168
# 1
# 1
# 4
# 16

Recipe 6: Batch Rename Files Preview

# Preview renaming all .txt files to .md files
for file in *.txt
    set new_name (string replace --regex '\.txt$' '.md' $file)
    echo "$file -> $new_name"
end

# Preview with zero-padded numbers
set count 1
for file in *.jpg
    set new_name (printf "photo_%03d.jpg" $count)
    echo "$file -> $new_name"
    set count (math $count + 1)
end

Recipe 7: Validate an Email Address

function validate_email
    set email $argv[1]
    if string match --regex --quiet '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' $email
        echo "Valid email: $email"
        return 0
    else
        echo "Invalid email: $email"
        return 1
    end
end

validate_email "user@example.com"   # Valid
validate_email "not-an-email"       # Invalid
validate_email "bad@.com"           # Invalid

Combining String Operations in Pipelines

One of Fish's greatest strengths is how naturally string commands compose in pipelines. Each subcommand can feed into the next, creating sophisticated text processing workflows without leaving the shell.

# Parse, clean, and format a complex string in one pipeline
curl -s https://api.example.com/data | \
    string match --regex '"name":"[^"]+"' | \
    string replace --all --regex '"name":"([^"]+)"' '$1' | \
    string trim | \
    string join ", "

# Extract unique IP addresses from a log file
cat access.log | \
    string match --regex '^\d+\.\d+\.\d+\.\d+' | \
    sort -u | \
    string join ", "

# Clean up git branch output for a prompt
git branch --list | \
    string replace --all "*" "" | \
    string trim | \
    string join " | "

Working with Variables and Lists

Fish variables can hold lists, and string operates seamlessly on list elements. When you pass a list variable to string, each element is processed individually. This is fundamentally different from bash where variables are always scalar strings.

# Process each element in a list
set fruits "apple" "banana" "cherry"
string replace "a" "A" $fruits
# Output:
# Apple
# bAnAnA
# cherry

# Filter a list with string match
set files "photo.jpg" "document.txt" "image.png" "notes.md"
set text_files (string match --regex '\.(txt|md)$' $files)
echo $text_files
# Output: document.txt notes.md

# Transform a list and assign back
set numbers 1 2 3 4 5
set padded (printf "%s\n" $numbers | string pad --width 3 --char "0")
echo $padded
# Output: 001 002 003 004 005

# Count elements after filtering
set log_lines (cat server.log | string match --regex 'ERROR')
set error_count (count $log_lines)
echo "Found $error_count errors"

Handling Empty Input and Edge Cases

Robust scripts must handle empty input gracefully. string commands return empty output for empty input, which is usually the desired behavior. However, be aware of how empty results interact with command substitution and conditionals.

# Empty input produces no output
echo "" | string trim | string length
# Output: (nothing)

# Check if a string is non-empty after trimming
set user_input "   "
set cleaned (echo "$user_input" | string trim)
if test -n "$cleaned"
    echo "User provided meaningful input"
else
    echo "Input was empty or whitespace only"
end

# Handle potentially empty command output
set branch (git rev-parse --abbrev-ref HEAD 2>/dev/null | string trim)
if test -z "$branch"
    set branch "unknown"
end
echo "Current branch: $branch"

Performance Considerations

The built-in string command is implemented in C++ and runs within the Fish process, making it significantly faster than forking external processes for each text operation. This matters in loops and hot code paths.

# Inefficient: forking sed for every line (avoid this)
for file in *.txt
    set base (echo $file | sed 's/\.txt$//')
    echo $base
end

# Efficient: using Fish's built-in string
for file in *.txt
    set base (string replace --regex '\.txt$' '' $file)
    echo $base
end

# Even more efficient: batch processing
string replace --regex '\.txt$' '' *.txt | while read -l base
    echo $base
end

Best Practices for Fish String Manipulation

Common Pitfalls and How to Avoid Them

Pitfall 1: Forgetting That string match Filters

string match only outputs lines that match the pattern. Lines that don't match are silently dropped. This is by design — it's a filtering operation. If you need to test every line and handle non-matches, use a loop with conditionals.

# string match filters out non-matching lines
echo -e "good line\nbad\nanother good" | string match "good"
# Output:
# good line
# another good
# (the line "bad" is dropped)

# To preserve all lines and annotate matches, use a loop
echo -e "good line\nbad\nanother good" | while read -l line
    if string match --regex --quiet "good" "$line"
        echo "MATCH: $line"
    else
        echo "NO MATCH: $line"
    end
end

Pitfall 2: Index Off-by-One Errors with string sub

Fish uses zero-based indexing for string sub. The first character is at position 0. The length parameter counts characters starting from the start position. A common mistake is using 1-based indexing or miscalculating the end position.

# Correct: first 5 characters
string sub --start 0 --length 5 "Hello World"  # Hello

# Common mistake: thinking start=1 gives first character
string sub --start 1 --length 5 "Hello World"  # ello 
# (skips 'H' because index 1 is 'e')

Pitfall 3: Unquoted Variables Expanding to Multiple Arguments

In Fish, an unquoted list variable expands to multiple arguments. This can cause string to process each element separately when you intended to process the whole string.

set my_list "apple banana cherry"
# Unquoted: treated as one element (no spaces in the element itself)
# But if the variable contains actual list elements:
set my_list "apple" "banana" "cherry"
string replace "a" "A" $my_list
# Processes three separate strings

# To treat as one concatenated string, join first
string replace "a" "A" (string join " " $my_list)

Pitfall 4: Regex Special Characters in Literal Patterns

When using string replace without --regex, the pattern is treated literally. When using --regex, characters like ., *, [, and ( have special meaning. Always decide explicitly which mode you need.

# Literal replacement (safe, no regex interpretation)
string replace "file.txt" "document.md" "my file.txt"
# Output: my document.md

# Regex replacement (dot means any character!)
string replace --regex "file.txt" "document.md" "my file_txt"
# Output: my document.md
# (file_txt also matched because . matches underscore)

Advanced Techniques

Using Named Capture Groups

Fish supports named capture groups in PCRE2 regex, making complex extractions self-documenting.

# Named capture groups for structured extraction
string match --regex --groups-only \
    '^(?P\w+)://(?P[^/]+)(?P/.*)?$' \
    "https://example.com/docs/tutorial"

# Accessing named groups requires iterating or using --all
# For simple cases, positional groups are usually sufficient:
string match --regex --groups-only \
    '^(\w+)://([^/]+)(/.*)?$' \
    "https://example.com/docs/tutorial"
# Output:
# https
# example.com
# /docs/tutorial

Recursive String Processing with Functions

Build reusable string manipulation functions that compose string commands for domain-specific tasks.

function normalize_url
    set url $argv[1]
    # Remove trailing slash
    set url (string replace --regex '/$' '' "$url")
    # Ensure https prefix
    if not string match --regex --quiet '^https?://' "$url"
        set url "https://$url"
    end
    # Remove www prefix if present
    set url (string replace --regex '^https?://www\.' 'https://' "$url")
    echo $url
end

normalize_url "www.example.com/"
# Output: https://example.com

normalize_url "http://www.api.test.com/v1/"
# Output: https://api.test.com/v1

Building a String Processing Library

For larger projects, organize commonly used string operations into autoloadable Fish functions. Save them in ~/.config/fish/functions/ to have them available in all interactive sessions and scripts.

# ~/.config/fish/functions/str_between.fish
function str_between
    set input $argv[1]
    set left_delim $argv[2]
    set right_delim $argv[3]
    string match --regex --groups-only \
        (string escape --style=regex "$left_delim")'(.*?)'(string escape --style=regex "$right_delim") \
        "$input"
end

# Usage
str_between "name: 'John' age: 30" "'" "'"
# Output: John

# ~/.config/fish/functions/str_contains.fish
function str_contains
    set haystack $argv[1]
    set needle $argv[2]
    string match --regex --quiet (string escape --style=regex "$needle") "$haystack"
end

# Usage
if str_contains "hello world" "world"
    echo "Found it"
end

Debugging String Operations

When complex string manipulations don't produce expected results, Fish offers several debugging approaches. Use the --debug flag on newer Fish versions, or break pipelines apart to inspect intermediate results.

# Debug a pipeline step by step
set input "Hello World 123"
echo "Step 1 - original: '$input'"

set step1 (echo "$input" | string replace --regex '[0-9]+' 'NUMBERS')
echo "Step 2 - after replace: '$step1'"

set step2 (echo "$step1" | string split " ")
echo "Step 3 - after split: $step2"

set step3 (echo "$step2" | string join "-")
echo "Step 4 - after join: '$step3'"

# Use --debug flag if available (Fish 3.1+)
string replace --debug --regex --all 'a' 'A' "banana"

Comparison with Other Shells

Understanding how Fish's approach differs from traditional shells helps when translating scripts or collaborating across teams.

# Bash: parameter expansion for substring
# ${var:0:5}  →  first 5 characters
# Fish equivalent:
# string sub --start 0 --length 5 $var

# Bash: ${var/pattern/replacement}
# Fish equivalent:
# string replace "pattern" "replacement" $var

# Bash: using cut for field extraction
# echo "a:b:c" | cut -d':' -f2
# Fish equivalent:
# string split --fields 2 ":" "a:b:c"

# Bash: using sed for regex replacement
# echo "text" | sed 's/[0-9]/X/g'
# Fish equivalent:
# echo "text" | string replace --regex --all '[0-9]' 'X'

Conclusion

Fish shell's string command transforms text processing from a fragmented collection of external tools into a cohesive, built-in capability. By mastering its subcommands — length, sub, split, join, trim, replace, match, collect, escape, repeat, and pad — you gain a unified vocabulary for all string operations that is both expressive and performant. The patterns shown in this guide — from basic substring extraction to complex multi-step pipelines — form the foundation of robust Fish scripting. Remember to prefer built-in operations over external forks, handle empty inputs gracefully, test regex patterns interactively, and leverage Fish's native list handling. With these skills, your shell scripts will be cleaner, faster, and more maintainable. The string command isn't just a convenience; it's a design philosophy that makes Fish one of the most developer-friendly shells available today.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles