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:
string match --regexβ Test if a string matches a pattern and extract capturesstring replace --regexβ Perform regex-based substitutionstring search --regexβ Find pattern occurrences and return indicesstring split --regexβ Split strings using a regex delimiter
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:
- Cross-platform consistency: Since
stringis built-in, your regex behaves identically on Linux, macOS, and any other platform running Fish β no GNU vs BSD tool discrepancies. - Performance: Built-in operations avoid the overhead of forking external processes for every regex match, which matters in loops processing thousands of lines.
- Readability: Fish's syntax eliminates the need for complex quoting and escaping chains common in POSIX shells. Patterns are clearer and easier to maintain.
- Modern features: PCRE2 support means you can use advanced techniques like conditional subpatterns, recursive patterns, and Unicode category matching that are unavailable in basic regex engines.
- Data validation: Regex enables robust input validation in Fish scripts β essential for configuration parsers, user prompts, and data pipeline scripts.
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 'FirstSecond'
# 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:
--allβ Match/replace all occurrences, not just the first--quietβ Suppress output, return only exit status (0 = match, 1 = no match)--groups-onlyβ Output only captured groups, not full match--indexβ Report match positions (withstring search)--only-matchingβ Output only the matched portion, not the whole line--invertβ Return non-matching lines/strings--ignore-case(-i) β Case-insensitive matching
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:
- Avoid nested quantifiers: Patterns like
(a+)+bcan cause catastrophic backtracking on long non-matching strings. Use atomic grouping(?>a+)bor rewrite the pattern. - Prefer
string matchoverstring searchwhen possible: Matching is generally faster than searching with index tracking. - Use
--quietfor boolean checks: It exits early on first match without constructing full result lists. - Batch operations: Process entire files through pipes rather than calling
stringin a loop for each line.
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.