← Back to DevBytes

Fish Scripting: Regular Expressions Complete Guide

What Are Regular Expressions in Fish Scripting?

Regular expressions (regex) in Fish shell are pattern-matching strings used to search, extract, validate, and transform text. Unlike POSIX shells that rely on external tools like grep, sed, or awk, Fish provides a powerful built-in string command that natively supports regex operations. This built-in approach means you get consistent behavior across platforms, faster execution (no subshell spawning), and a cleaner syntax that integrates seamlessly with Fish's scripting paradigm.

Fish's regex engine is based on PCRE2 (Perl-Compatible Regular Expressions 2), giving you access to modern regex features such as lookaheads, lookbehinds, named capture groups, non-greedy quantifiers, and Unicode support. This is a significant upgrade over the basic POSIX regular expressions found in many shell environments.

The string Command: Fish's Regex Powerhouse

The string command is the central interface for regex operations in Fish. It has several subcommands that accept regex patterns:

Why Regex in Fish Matters

πŸš€ Deploy your AI agent in 10 minutes

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

Try it free →

Understanding regex in Fish transforms your scripting capabilities. Here's why it's critical:

Getting Started: The string match Subcommand

The most common regex operation is testing whether a string matches a pattern. Use string match --regex for this. The basic syntax is:

string match --regex [OPTIONS] PATTERN [STRING...]

If no string is provided via arguments, Fish reads from standard input, making it perfect for pipelines.

Basic Pattern Matching

Here's a simple example that checks if a string contains a valid hexadecimal color code:

#!/usr/bin/env fish

set color "#FF5733"

if string match --regex --quiet '^#[0-9A-Fa-f]{6}$' "$color"
    echo "Valid hex color: $color"
else
    echo "Invalid hex color"
end

The --quiet flag suppresses output and only returns an exit status (0 for match, 1 for no match), which is perfect for conditional tests. Without --quiet, the matched portion is printed to stdout.

Extracting Capture Groups

Capture groups let you extract specific parts of a match. Fish uses parentheses () for capturing. The --groups-only flag makes string match output only the captured groups, separated by tabs:

#!/usr/bin/env fish

set url "https://github.com/fish-shell/fish-shell"

# Extract protocol, domain, and path segments
set groups (string match --regex --groups-only \
    '^(https?)://([^/]+)/(.+)$' "$url")

echo "Protocol: $groups[1]"
echo "Domain:   $groups[2]"
echo "Path:     $groups[3]"

Output:

Protocol: https
Domain:   github.com
Path:     fish-shell/fish-shell

Named Capture Groups

Fish supports named capture groups using the PCRE2 syntax (?P<name>pattern). These are accessed via the --groups-only output in positional order, but you can also store results in variables using a loop with string match --all --groups-only:

#!/usr/bin/env fish

set log_entry '[2024-01-15] [ERROR] [main] Connection refused: 10.0.0.1:8080'

# Named groups for clarity
set pattern '\[(?P[^\]]+)\] \[(?P[^\]]+)\] \[(?P[^\]]+)\] (?P.+)'

set parts (string match --regex --groups-only "$pattern" "$log_entry")

echo "Date:      $parts[1]"
echo "Level:     $parts[2]"
echo "Component: $parts[3]"
echo "Message:   $parts[4]"

While Fish stores captures by position, using named groups in your pattern serves as self-documentation and helps when sharing patterns across different regex engines.

Regex Replacement with string replace

The string replace --regex subcommand performs substitution, similar to sed but with PCRE2 power. Its syntax:

string replace --regex [OPTIONS] PATTERN REPLACEMENT [STRING...]

Simple Substitution

Replace all occurrences of a pattern in a string:

#!/usr/bin/env fish

set text "The quick brown fox jumps over the lazy dog"

# Replace vowels with asterisks
set censored (string replace --regex --all '[aeiou]' '*' "$text")
echo "$censored"

Output:

Th* q**ck br*wn f*x j*mps *v*r th* l*zy d*g

The --all flag replaces every occurrence. Without it, only the first match is replaced.

Backreferences in Replacements

You can reference captured groups in the replacement string using $n syntax (where n is the group number):

#!/usr/bin/env fish

# Reformat date from MM/DD/YYYY to YYYY-MM-DD
set date_string "12/25/2024"

set iso_date (string replace --regex \
    '^(\d{2})/(\d{2})/(\d{4})$' \
    '$3-$1-$2' \
    "$date_string")

echo "$iso_date"

Output:

2024-12-25

Transforming Multi-line Text

Fish's string replace works across lines when processing stdin. Here's a practical example that converts markdown headings to HTML:

#!/usr/bin/env fish

cat README.md | string replace --regex --all \
    '^### (.+)$' '

\1

' | \ string replace --regex --all \ '^## (.+)$' '

\1

' | \ string replace --regex --all \ '^# (.+)$' '

\1

'

Note the order: process deeper headings first to avoid # matching ## or ### prefixes.

Searching with string search

The string search --regex subcommand finds patterns and returns match indices and lengths. It's ideal for locating patterns without modifying text:

#!/usr/bin/env fish

set json_data '{"name":"Alice","age":30,"city":"New York"}'

# Find all quoted values with their positions
string search --regex --all --index --only-matching \
    '"[^"]*"' "$json_data"

Output shows the index and length of each match, enabling programmatic text analysis within Fish scripts.

Combining Search with Conditional Logic

#!/usr/bin/env fish

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

validate_email "user@example.com"   # passes
validate_email "not-an-email"       # fails

Advanced Regex Techniques in Fish

Lookaheads and Lookbehinds

Fish's PCRE2 engine supports zero-width assertions. These match patterns without consuming characters:

#!/usr/bin/env fish

set prices "apple: $5.99, banana: $0.49, cherry: $12.50"

# Positive lookbehind: match numbers preceded by $
set amounts (string match --regex --all --groups-only \
    '(?<=\$)[0-9]+\.[0-9]{2}' "$prices")

echo "Found prices: $amounts"

Output:

Found prices: 5.99 0.49 12.50

Non-Greedy Quantifiers

By default, quantifiers like * and + are greedy. Append ? for non-greedy matching, crucial when parsing structured text:

#!/usr/bin/env fish

set html '
First
Second
' # Greedy: matches everything between first < and last > set greedy (string match --regex --groups-only '<.+>' "$html") echo "Greedy: $greedy" # Non-greedy: matches individual div tags set non_greedy (string match --regex --all --groups-only '<.+?>' "$html") echo "Non-greedy: $non_greedy"

Unicode Category Matching

PCRE2 supports Unicode property escapes. Match characters by their Unicode category:

#!/usr/bin/env fish

set text "CafΓ© rΓ©sumΓ© naΓ―ve 123"

# Match all letter characters (including accented)
set letters (string match --regex --all --groups-only '\p{L}' "$text")
echo "Letters found: "(count $letters)

# Match all digits
set digits (string match --regex --all --groups-only '\p{N}' "$text")
echo "Digits found: "(count $digits)

Atomic Grouping for Performance

Atomic groups (?>...) prevent backtracking, improving performance on complex patterns:

#!/usr/bin/env fish

# Without atomic grouping, this pattern can cause catastrophic backtracking
# on long strings of 'a' characters that don't end with 'b'
set pattern '(?>a+)[ab]b'

set result (string match --regex --quiet "$pattern" "aaaaab")
echo "Match result: $status"

Practical Real-World Examples

Example 1: Config File Parser

Parse INI-style configuration files using regex:

#!/usr/bin/env fish

function parse_ini --argument filename
    set current_section ""
    
    for line in (cat "$filename")
        # Skip comments and empty lines
        if string match --regex --quiet '^\s*[#;]' "$line"
            continue
        end
        if string match --regex --quiet '^\s*$' "$line"
            continue
        end
        
        # Match section headers: [section]
        set section_match (string match --regex --groups-only \
            '^\s*\[([^\]]+)\]\s*$' "$line")
        if test -n "$section_match"
            set current_section "$section_match[1]"
            echo "Entering section: $current_section"
            continue
        end
        
        # Match key=value pairs
        set kv_match (string match --regex --groups-only \
            '^\s*([^=]+)\s*=\s*(.+?)\s*$' "$line")
        if test -n "$kv_match"
            echo "[$current_section] $kv_match[1] = $kv_match[2]"
        end
    end
end

parse_ini ~/.config/example.ini

Example 2: Log File Analyzer

Extract and aggregate information from server logs:

#!/usr/bin/env fish

function analyze_access_log --argument logfile
    set pattern '^(\S+) \S+ \S+ \[([^\]]+)\] "(\w+) ([^"]+)" (\d+) (\d+)'
    
    set total_requests 0
    set status_counts (fish dict)
    
    for line in (cat "$logfile")
        set match (string match --regex --groups-only "$pattern" "$line")
        if test -z "$match"
            continue
        end
        
        set total_requests (math $total_requests + 1)
        set status $match[5]
        
        # Aggregate status codes
        if set -q status_counts[$status]
            set status_counts[$status] (math $status_counts[$status] + 1)
        else
            set status_counts[$status] 1
        end
    end
    
    echo "Total requests: $total_requests"
    echo "Status distribution:"
    for status in (dict keys $status_counts)
        echo "  HTTP $status: "(dict get $status_counts $status)
    end
end

analyze_access_log /var/log/nginx/access.log

Example 3: Batch File Renaming

Rename files using regex patterns with string replace:

#!/usr/bin/env fish

function rename_files --argument pattern replacement directory
    set dir (or $directory ".")
    
    for file in (ls "$dir")
        set new_name (string replace --regex "$pattern" "$replacement" "$file")
        if test "$file" != "$new_name"
            echo "Renaming: $file -> $new_name"
            mv "$dir/$file" "$dir/$new_name"
        end
    end
end

# Convert spaces to underscores in all .txt files
rename_files '\s+' '_' "./documents"

Example 4: URL Query String Parser

Extract query parameters from URLs:

#!/usr/bin/env fish

function parse_query_string --argument url
    # Extract query string portion
    set qs_match (string match --regex --groups-only \
        '\?(.+)$' "$url")
    
    if test -z "$qs_match"
        echo "No query string found"
        return 1
    end
    
    set query_string "$qs_match[1]"
    
    # Split into individual parameters
    set params (string split --regex '&' "$query_string")
    
    for param in $params
        set kv (string split --regex '=' "$param")
        set key (string unescape --style=url "$kv[1]")
        set value ""
        if test (count $kv) -ge 2
            set value (string unescape --style=url "$kv[2]")
        end
        echo "  $key = $value"
    end
end

parse_query_string "https://example.com/search?q=fish+shell&lang=en&page=2"

Regex Flags and Options Reference

The string command offers several flags that modify regex behavior:

Combining Flags for Powerful Pipelines

#!/usr/bin/env fish

# Extract all unique email addresses from a text file
cat contact_list.txt | \
    string match --regex --all --groups-only \
    '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' | \
    sort -u

Best Practices for Regex in Fish

1. Prefer --quiet for Conditionals

When you only need to know if a pattern matches, use --quiet to avoid unnecessary output and improve performance:

# Good: clean conditional
if string match --regex --quiet '^[0-9]+$' "$input"
    echo "Input is numeric"
end

# Avoid: captures output unnecessarily
set match (string match --regex '^[0-9]+$' "$input")
if test -n "$match"
    echo "Input is numeric"
end

2. Use Single Quotes for Regex Patterns

Fish performs variable expansion in double-quoted strings, which can interfere with regex syntax. Always use single quotes for literal regex patterns:

# Good: single quotes prevent interpretation
set pattern '^\d{3}-\d{2}-\d{4}$'

# Risky: double quotes may expand escape sequences
set pattern "^\d{3}-\d{2}-\d{4}$"

3. Test Patterns Interactively

Fish's interactive mode is perfect for regex experimentation. Use string match directly at the prompt to test patterns before embedding them in scripts:

# Interactive testing at Fish prompt
> string match --regex --groups-only '(\d{4})-(\d{2})-(\d{2})' '2024-12-25'
2024
12
25

4. Handle Empty/No-Match Cases Explicitly

Always check for empty results when using capture groups. An empty list indicates no match:

set captured (string match --regex --groups-only '(\w+)@(\w+)' "$input")

if test (count $captured) -eq 0
    echo "Pattern did not match" >&2
    return 1
end

echo "Username: $captured[1], Domain: $captured[2]"

5. Anchor Patterns When Validating

Use ^ and $ anchors when validating entire strings. Without anchors, the pattern matches anywhere in the string:

# Validation: must be exactly an integer
string match --regex --quiet '^\d+$' "$input"

# Search: find digits anywhere
string match --regex --quiet '\d+' "$input"

6. Break Complex Patterns into Variables

For readability, compose complex patterns from simpler named pieces:

#!/usr/bin/env fish

set label_pattern '[a-zA-Z][a-zA-Z0-9_-]*'
set domain_pattern '[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
set email_pattern "^$label_pattern@$domain_pattern\$"

if string match --regex --quiet "$email_pattern" "$candidate"
    echo "Valid email"
end

7. Use Non-Capturing Groups for Structure

When you need grouping without capturing, use (?:...) to reduce noise in captured output:

# Capture only the numeric value, not the optional currency symbol
set pattern '^(?:\$|€|Β£)?(\d+\.\d{2})$'

set price (string match --regex --groups-only "$pattern" '$49.99')
echo "Value: $price[1]"  # Outputs: 49.99

8. Leverage string split --regex for Complex Delimiters

When simple character delimiters aren't enough, use regex splitting:

#!/usr/bin/env fish

set csv_line '"Smith, John","Engineering","Level 5, Senior"'

# Split on commas not inside quotes
set fields (string split --regex ',(?=(?:[^"]*"[^"]*")*[^"]*$)' "$csv_line")

for i in (seq (count $fields))
    set clean (string replace --regex '^"|"$' '' "$fields[$i]")
    echo "Field $i: $clean"
end

Common Pitfalls and How to Avoid Them

Pitfall 1: Forgetting that string match Returns Full Match by Default

Without --groups-only, string match returns the entire matched portion. This can be surprising when you expect only captures:

# Returns full match: "user@example.com"
string match --regex '(\w+)@(\w+\.\w+)' 'user@example.com'

# Returns only groups: "user" and "example.com" (tab-separated)
string match --regex --groups-only '(\w+)@(\w+\.\w+)' 'user@example.com'

Pitfall 2: Greedy Quantifiers Matching Too Much

A common mistake when parsing delimited content:

set html '

Hello

World

' # Greedy: matches entire string as one tag string match --regex '

.+

' "$html" # Matches:

Hello

World

# Non-greedy fix: string match --regex --all '

.+?

' "$html" # Matches:

Hello

and

World

separately

Pitfall 3: Escaping Inside Single Quotes

Inside Fish single quotes, backslashes are literal. You don't need to double-escape regex metacharacters:

# Correct: single quotes, literal backslash for regex escape
set pattern '\.com$'

# Unnecessary: double escaping (only needed in double quotes or POSIX shells)
set pattern "\\.com$"

Pitfall 4: Not Handling Multi-line Input Properly

The string command processes input line by line when reading from stdin. For multi-line patterns, pre-join the input:

# Process file as single string for multi-line patterns
set content (cat file.txt | string join "\n")
string replace --regex --all '---\n(.+)\n---' '=== $1 ===' "$content"

Pitfall 5: Assuming Regex Flags Apply Globally

Unlike some regex engines, Fish's string does not support inline flags like (?i) within the pattern itself. Use the command-line flags instead:

# Correct: use --ignore-case flag
string match --regex --ignore-case 'error' "$log_line"

# Not supported: inline flag syntax
string match --regex '(?i)error' "$log_line"

Performance Considerations

While Fish's built-in regex is fast, certain patterns can still cause performance issues:

Debugging Regex Patterns

When a regex isn't working as expected, use these debugging techniques:

#!/usr/bin/env fish

function debug_regex --argument pattern text
    echo "Pattern: $pattern"
    echo "Text:    $text"
    echo "---"
    
    # Test full match
    if string match --regex --quiet "$pattern" "$text"
        echo "Full match: SUCCESS"
        set full (string match --regex "$pattern" "$text")
        echo "  Matched: $full"
    else
        echo "Full match: FAILED"
    end
    
    # Show groups if any
    set groups (string match --regex --groups-only "$pattern" "$text" 2>/dev/null)
    if test (count $groups) -gt 0
        echo "Groups:"
        for i in (seq (count $groups))
            echo "  [$i] $groups[$i]"
        end
    else
        echo "No capture groups extracted"
    end
end

debug_regex '(\d{4})-(\d{2})-(\d{2})' '2024-12-25'

Regex in Fish vs Other Shells: Migration Guide

If you're coming from Bash/Zsh with grep/sed, here's how common operations translate:

# Bash: extract matches
echo "Hello World" | grep -oE '[A-Z][a-z]+'
# Fish:
echo "Hello World" | string match --regex --all '[A-Z][a-z]+'

# Bash: substitution
echo "foo bar baz" | sed -E 's/(\w+)/[\1]/g'
# Fish:
echo "foo bar baz" | string replace --regex --all '(\w+)' '[\1]'

# Bash: conditional match
if [[ "$var" =~ ^[0-9]+$ ]]; then echo "numeric"; fi
# Fish:
if string match --regex --quiet '^[0-9]+$' "$var"
    echo "numeric"
end

# Bash: split by regex
echo "a1b2c3" | awk -F'[0-9]' '{print $1, $2, $3}'
# Fish:
echo "a1b2c3" | string split --regex '[0-9]+'

Conclusion

Regular expressions in Fish scripting, powered by the built-in string command and PCRE2 engine, offer a clean, fast, and feature-rich approach to text processing. By mastering string match --regex, string replace --regex, and their companion subcommands, you gain the ability to write robust data validators, parsers, and transformers entirely within Fish β€” no external dependencies required.

The key takeaways are: use single quotes for pattern literals, always consider --quiet for conditionals, anchor patterns when validating, and leverage non-greedy quantifiers and atomic groups for precise, performant matching. With these techniques, your Fish scripts become more maintainable, portable, and powerful. The interactive nature of Fish makes regex experimentation immediate and intuitive β€” take advantage of the REPL-like prompt to refine patterns before embedding them in production scripts.

πŸš€ 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