← Back to DevBytes

Fish Scripting: File Operations Complete Guide

Fish Scripting: File Operations Complete Guide

Fish (Friendly Interactive SHell) is a modern, user-friendly command-line shell designed with usability and discoverability in mind. While its interactive features like autosuggestions and syntax highlighting are widely praised, Fish also provides a powerful scripting environment. This guide covers every essential file operation you can perform in Fish scripts — from basic reading and writing to advanced file manipulation techniques.

What Are File Operations in Fish Scripting?

File operations in Fish scripting refer to the programmatic handling of files and directories using built-in commands, functions, and utilities available within the Fish shell environment. This includes creating, reading, writing, copying, moving, deleting files, checking file properties, iterating over directory contents, and managing permissions — all from within Fish scripts.

Why File Operations Matter in Fish Scripts

File operations form the backbone of system automation. Whether you're building deployment pipelines, log rotation scripts, backup routines, or configuration managers, you need reliable file handling. Fish simplifies this with clean syntax, predictable behavior, and excellent error handling. Unlike POSIX-compatible shells like bash, Fish removes arcane quoting rules and provides consistent command substitutions, making file operation scripts easier to write and maintain.

Getting Started: The Fish Script Structure

A Fish script is a plain text file with a .fish extension. It typically begins with a shebang line pointing to the Fish interpreter:

#!/usr/bin/env fish

You make the script executable and run it directly:

chmod +x my_script.fish
./my_script.fish

Alternatively, you can invoke it through the fish command:

fish my_script.fish

Creating and Writing Files

Fish provides multiple ways to write content to files. The most common approach uses the echo command with output redirection.

Creating a New File with Simple Content

#!/usr/bin/env fish

# Write a single line to a file, overwriting existing content
echo "Hello, Fish Scripting!" > greeting.txt

# Append content to an existing file (or create if it doesn't exist)
echo "This is an appended line." >> greeting.txt

Writing Multiline Content Using Heredoc-style Syntax

Fish supports a clean multiline string syntax using echo with escaped newlines or the printf command for precise formatting:

#!/usr/bin/env fish

# Write multiline content using printf
printf '%s\n' 'Line one' 'Line two' 'Line three' > multiline.txt

# Using a variable with newlines
set -l content "First line
Second line
Third line"
echo "$content" > document.txt

Using the read Built-in for Interactive File Creation

#!/usr/bin/env fish

# Prompt user for input and save to file
read --prompt "Enter filename: " filename
read --prompt "Enter content: " content
echo "$content" > "$filename"
echo "File '$filename' created successfully."

Reading Files

Reading file contents is essential for processing data, parsing configuration, or analyzing logs. Fish offers several approaches depending on your needs.

Reading an Entire File into a Variable

#!/usr/bin/env fish

# Read entire file content using the read built-in with --delimiter
set -l file_content (cat myfile.txt)
echo "File contents: $file_content"

# Alternative: read the file line by line into an array
set -l lines (cat myfile.txt | string split "\n")
echo "Number of lines: "(count $lines)

Reading Files Line by Line

The idiomatic Fish way to process files line by line uses the read command inside a while loop:

#!/usr/bin/env fish

set line_number 0
while read -l line
    set line_number (math $line_number + 1)
    echo "Line $line_number: $line"
end < data.txt

For files with custom delimiters, use the --delimiter flag:

#!/usr/bin/env fish

# Read CSV-style data with comma delimiter
while read -l -d "," field1 field2 field3
    echo "Field1: $field1, Field2: $field2, Field3: $field3"
end < data.csv

Using cat with String Processing

#!/usr/bin/env fish

# Extract specific patterns from a file
cat logfile.txt | string match -r 'ERROR.*' > errors.txt

# Count lines containing a pattern
set -l error_count (cat logfile.txt | grep -c "ERROR")
echo "Found $error_count errors"

File Testing and Conditional Checks

Fish provides the test command (also accessible via [ brackets) for checking file existence, type, permissions, and more. These checks are crucial for writing robust scripts.

Checking File Existence and Type

#!/usr/bin/env fish

set target_file "/etc/hosts"

# Check if a file exists
if test -e "$target_file"
    echo "File exists: $target_file"
else
    echo "File does not exist: $target_file"
end

# Check if it's a regular file (not a directory, symlink, or device)
if test -f "$target_file"
    echo "$target_file is a regular file"
end

# Check if it's a directory
if test -d "$target_file"
    echo "$target_file is a directory"
else
    echo "$target_file is not a directory"
end

# Check if it's a symbolic link
if test -L "$target_file"
    echo "$target_file is a symbolic link"
end

Checking File Permissions

#!/usr/bin/env fish

set script "my_script.fish"

# Check if file is readable
if test -r "$script"
    echo "$script is readable"
else
    echo "$script is NOT readable — check permissions"
end

# Check if file is writable
if test -w "$script"
    echo "$script is writable"
end

# Check if file is executable
if test -x "$script"
    echo "$script is executable"
else
    echo "$script is NOT executable"
    chmod +x "$script"
    echo "Made $script executable"
end

Checking File Size and Emptiness

#!/usr/bin/env fish

# Check if file is empty (zero size)
if test -s "data.txt"
    echo "data.txt contains data"
else
    echo "data.txt is empty or does not exist"
end

# Check if file has content using string length
set content (cat "data.txt" 2>/dev/null)
if test -n "$content"
    echo "File has content, length: "(string length "$content")" characters"
else
    echo "File is empty"
end

Comparing Files

#!/usr/bin/env fish

# Check if two files are identical
if test (cat file1.txt) = (cat file2.txt)
    echo "Files have identical content"
else
    echo "Files differ"
end

# Check modification time comparison
if test "file1.txt" -nt "file2.txt"
    echo "file1.txt is newer than file2.txt"
end

if test "file1.txt" -ot "file2.txt"
    echo "file1.txt is older than file2.txt"
end

Copying, Moving, and Deleting Files

Fish uses standard Unix commands for file manipulation, but with cleaner syntax and better error handling patterns.

Copying Files

#!/usr/bin/env fish

# Basic copy
cp source.txt destination.txt

# Copy with confirmation prompt before overwriting
cp -i source.txt destination.txt

# Recursive copy of directories
cp -r source_dir/ destination_dir/

# Copy preserving attributes (timestamps, permissions)
cp -p source.txt destination.txt

# Verbose copy showing progress
cp -v source.txt destination.txt

# Copy multiple files to a directory
cp file1.txt file2.txt file3.txt target_directory/

Moving and Renaming Files

#!/usr/bin/env fish

# Rename a file
mv old_name.txt new_name.txt

# Move a file to another directory
mv document.txt /home/user/archive/

# Move with overwrite confirmation
mv -i file.txt existing_location/

# Move without overwriting existing files
mv -n source.txt destination.txt

# Verbose move operation
mv -v *.log archive_directory/

Deleting Files and Directories

#!/usr/bin/env fish

# Remove a single file
rm unwanted_file.txt

# Remove with confirmation
rm -i important_file.txt

# Force remove without confirmation (use with caution)
rm -f temporary_file.txt

# Remove directory and all contents recursively
rm -rf old_project_directory/

# Remove only empty directories
rmdir empty_directory/

# Safe deletion script with existence check
function safe_delete
    for file in $argv
        if test -e "$file"
            rm -i "$file"
            echo "Deleted: $file"
        else
            echo "File not found: $file"
        end
    end
end

safe_delete temp1.txt temp2.txt

Working with Directories

Directory operations are fundamental for organizing files, traversing project structures, and managing file systems.

Creating Directories

#!/usr/bin/env fish

# Create a single directory
mkdir new_project

# Create nested directories (parent directories as needed)
mkdir -p project/src/components

# Create directory with specific permissions
mkdir -m 755 secure_directory

# Create multiple directories at once
mkdir docs tests config

Listing and Iterating Over Directory Contents

#!/usr/bin/env fish

# List all files and directories
ls -la /path/to/directory

# Iterate over all .txt files in current directory
for file in *.txt
    echo "Processing: $file"
    # Perform operations on each file
end

# Iterate over all items including hidden files
for item in * .*
    if test -e "$item" -a "$item" != "." -a "$item" != ".."
        echo "Found: $item"
    end
end

# Recursively find all .log files using find command
for logfile in (find . -name "*.log" -type f)
    echo "Log file: $logfile"
    # Process log file
end

Directory Traversal with Recursive Functions

#!/usr/bin/env fish

function process_directory
    set dir $argv[1]
    
    if not test -d "$dir"
        echo "Error: $dir is not a directory"
        return 1
    end
    
    for item in "$dir"/*
        if test -d "$item"
            echo "Entering subdirectory: $item"
            process_directory "$item"  # Recursive call
        else if test -f "$item"
            echo "Processing file: $item"
            # Add your file processing logic here
        end
    end
end

process_directory ./project

Getting the Current Working Directory

#!/usr/bin/env fish

# Get current directory
set current_dir (pwd)
echo "Current directory: $current_dir"

# Get the directory where the script is located
set script_dir (dirname (status --current-filename))
echo "Script location: $script_dir"

# Change to script's directory to ensure relative paths work
cd (dirname (status --current-filename))
echo "Now working in: "(pwd)

File Permissions and Ownership

Managing file permissions programmatically is essential for security and system administration scripts.

Changing Permissions with chmod

#!/usr/bin/env fish

# Make a script executable
chmod +x script.fish

# Set specific permissions using octal notation
chmod 755 script.fish      # rwxr-xr-x
chmod 644 config.txt       # rw-r--r--
chmod 600 secret.key       # rw-------

# Recursively set permissions on a directory
chmod -R 755 public_html/

# Add read permissions for all users
chmod a+r shared_file.txt

# Remove write permissions for group and others
chmod go-w sensitive_data.txt

Changing Ownership

#!/usr/bin/env fish

# Change owner of a file (requires sudo typically)
sudo chown username file.txt

# Change owner and group simultaneously
sudo chown username:groupname file.txt

# Recursively change ownership of directory
sudo chown -R username:groupname project_directory/

# Change only the group
sudo chgrp developers source_code/

File I/O Redirection Deep Dive

Fish handles input and output redirection elegantly, with clear syntax for piping, redirecting stdout, stderr, and combining streams.

Standard Output Redirection

#!/usr/bin/env fish

# Redirect stdout to a file (overwrite)
echo "This goes to a file" > output.txt

# Redirect stdout to a file (append)
echo "This appends to the file" >> output.txt

# Redirect stdout and stderr to different files
command_that_may_fail > stdout.txt 2> stderr.txt

# Redirect both stdout and stderr to the same file
command_with_output > combined_output.txt 2>&1

# Fish-specific: redirect stderr to stdout and pipe
some_command 2>&1 | grep "error"

Discarding Output

#!/usr/bin/env fish

# Discard all output (send to /dev/null)
noisy_command > /dev/null 2>&1

# Discard only stdout, keep stderr
command_with_errors > /dev/null

# Discard only stderr
command_with_errors 2> /dev/null

Using Pipes for File Operations

#!/usr/bin/env fish

# Pipe file contents through multiple filters
cat access.log | grep "404" | string split " " | head -n 10

# Process command output and save to file
ps aux | grep "fish" > fish_processes.txt

# Chain file operations
cat raw_data.txt | sort | uniq > cleaned_data.txt

Working with Temporary Files

Temporary files are useful for intermediate processing without cluttering the filesystem. Fish can leverage the system's mktemp utility.

#!/usr/bin/env fish

# Create a temporary file safely
set tmpfile (mktemp)
echo "Temporary file created: $tmpfile"

# Write to temporary file
echo "Temporary processing data" > "$tmpfile"

# Read from temporary file
set processed_data (cat "$tmpfile")
echo "Processed: $processed_data"

# Clean up when done (always remove temp files)
rm -f "$tmpfile"
echo "Temporary file removed"

# Create a temporary directory
set tmpdir (mktemp -d)
echo "Working in temporary directory: $tmpdir"

# Use the temp directory
cd "$tmpdir"
# ... do work ...

# Clean up the entire temp directory
cd ..
rm -rf "$tmpdir"

File Encoding and Binary Operations

When working with non-text files or specific encodings, Fish provides tools through external commands.

Reading Binary Files

#!/usr/bin/env fish

# Read raw bytes using od (octal dump)
od -A x -t x1z binary_file.bin | head -n 20

# Get file checksums for verification
set md5_hash (md5sum document.pdf | string split " ")[1]
echo "MD5 hash: $md5_hash"

set sha_hash (sha256sum document.pdf | string split " ")[1]
echo "SHA-256 hash: $sha_hash"

Encoding Conversion

#!/usr/bin/env fish

# Convert file encoding using iconv (if available)
if command -v iconv > /dev/null
    iconv -f ISO-8859-1 -t UTF-8 legacy_file.txt > utf8_file.txt
    echo "Encoding converted to UTF-8"
else
    echo "iconv not available — install libiconv or equivalent"
end

Advanced File Operations

Finding Files with Complex Criteria

#!/usr/bin/env fish

# Find files modified in the last 7 days
find . -type f -mtime -7 -print

# Find files larger than 10MB
find . -type f -size +10M -print

# Find and execute a command on each file
find . -name "*.tmp" -type f -exec rm {} \;

# Find empty files and directories
find . -type f -empty -print
find . -type d -empty -print

File Locking for Concurrent Script Safety

#!/usr/bin/env fish

# Simple file-based locking mechanism
set lockfile "/tmp/my_script.lock"

# Try to acquire lock
if test -e "$lockfile"
    echo "Script is already running — exiting"
    exit 1
else
    # Create lock file
    touch "$lockfile"
    
    # Main script logic here
    echo "Processing critical section..."
    sleep 5
    
    # Release lock
    rm -f "$lockfile"
    echo "Lock released"
end

Watching Files for Changes

#!/usr/bin/env fish

# Watch a file for changes using inotifywait (Linux)
if command -v inotifywait > /dev/null
    echo "Watching config.fish for changes..."
    inotifywait -m -e modify config.fish | while read -l line
        echo "Config file changed — reloading..."
        source config.fish
    end
else
    echo "inotifywait not available — install inotify-tools"
end

Atomic File Operations

#!/usr/bin/env fish

# Write to a temporary file then move atomically
function atomic_write
    set target_file $argv[1]
    set content $argv[2]
    
    set tmpfile (mktemp)
    echo "$content" > "$tmpfile"
    mv "$tmpfile" "$target_file"
    echo "Atomic write complete: $target_file"
end

atomic_write config.json '{"version": "2.0", "debug": false}'

Error Handling in File Operations

Robust Fish scripts handle file operation failures gracefully. Fish provides several mechanisms for error detection and recovery.

#!/usr/bin/env fish

# Check if a command succeeded
if cp source.txt dest.txt
    echo "Copy succeeded"
else
    echo "Copy failed — check if source exists and destination is writable"
end

# Using begin/end blocks for try-catch-like behavior
begin
    set -l content (cat missing_file.txt 2>/dev/null)
    if test $status -ne 0
        echo "Failed to read file — using default values"
        set content "default_content"
    end
    echo "Content: $content"
end

# Check file operation prerequisites
function safe_file_operation
    set source $argv[1]
    set dest $argv[2]
    
    # Pre-flight checks
    if not test -e "$source"
        echo "Error: Source file '$source' does not exist" >&2
        return 1
    end
    
    if not test -r "$source"
        echo "Error: Source file '$source' is not readable" >&2
        return 1
    end
    
    if test -e "$dest" -a ! -w "$dest"
        echo "Error: Destination '$dest' exists but is not writable" >&2
        return 1
    end
    
    # Perform the operation
    cp "$source" "$dest"
    echo "Successfully copied '$source' to '$dest'"
end

safe_file_operation important_data.txt backup/important_data.txt

Best Practices for File Operations in Fish Scripts

Complete Practical Example: Log Rotation Script

This complete script demonstrates many file operations working together in a real-world scenario — rotating log files when they exceed a size threshold:

#!/usr/bin/env fish

# Log Rotation Script — rotates log files when they exceed maximum size
# Usage: ./rotate_logs.fish /var/log/myapp 5 10485760

set log_dir $argv[1]
set max_rotations (math $argv[2])
set max_size (math $argv[3])

# Validate inputs
if test (count $argv) -lt 3
    echo "Usage: rotate_logs.fish   " >&2
    exit 1
end

if not test -d "$log_dir"
    echo "Error: Directory '$log_dir' does not exist" >&2
    exit 1
end

if not test -r "$log_dir" -a -w "$log_dir"
    echo "Error: Insufficient permissions on '$log_dir'" >&2
    exit 1
end

echo "Starting log rotation in $log_dir"
echo "Max rotations: $max_rotations, Max size: $max_size bytes"

# Process each .log file in the directory
for logfile in "$log_dir"/*.log
    # Skip if no log files found (glob didn't match)
    if not test -e "$logfile"
        echo "No log files found in $log_dir"
        break
    end
    
    set filename (basename "$logfile")
    echo "Checking $filename..."
    
    # Get file size
    set filesize (stat -c %s "$logfile" 2>/dev/null)
    if test $status -ne 0
        echo "Warning: Could not get size for $filename — skipping"
        continue
    end
    
    echo "  Size: $filesize bytes"
    
    # Check if rotation is needed
    if test $filesize -lt $max_size
        echo "  Under threshold — no rotation needed"
        continue
    end
    
    echo "  Rotating $filename..."
    
    # Remove oldest rotation if it exists
    set oldest_rotation "$log_dir/$filename.$max_rotations"
    if test -e "$oldest_rotation"
        rm -f "$oldest_rotation"
        echo "  Removed oldest rotation: $oldest_rotation"
    end
    
    # Shift existing rotations (rotate 3 -> 4, 2 -> 3, 1 -> 2)
    set rotation_index $max_rotations
    while test $rotation_index -gt 1
        set current_rot "$log_dir/$filename."(math $rotation_index - 1)
        set target_rot "$log_dir/$filename.$rotation_index"
        if test -e "$current_rot"
            mv "$current_rot" "$target_rot"
            echo "  Shifted "(math $rotation_index - 1)" -> $rotation_index"
        end
        set rotation_index (math $rotation_index - 1)
    end
    
    # Move current log to .1 rotation and create new empty log
    set rotation_1 "$log_dir/$filename.1"
    mv "$logfile" "$rotation_1"
    echo "  Current log moved to $filename.1"
    
    # Create fresh log file
    touch "$logfile"
    chmod 644 "$logfile"
    echo "  Created fresh $filename"
    
    # Optionally compress old rotations
    if command -v gzip > /dev/null
        if test -e "$rotation_1" -a ! -e "$rotation_1.gz"
            gzip "$rotation_1"
            echo "  Compressed $filename.1 to .gz"
        end
    end
end

echo "Log rotation complete."

Another Complete Example: Configuration File Parser

This example demonstrates reading, parsing, and updating configuration files safely:

#!/usr/bin/env fish

# Configuration File Parser and Updater
# Parses key=value config files and allows safe updates

function parse_config
    set config_file $argv[1]
    
    if not test -f "$config_file"
        echo "Error: Config file '$config_file' not found" >&2
        return 1
    end
    
    if not test -r "$config_file"
        echo "Error: Config file '$config_file' not readable" >&2
        return 1
    end
    
    echo "Parsing $config_file..."
    set line_count 0
    
    while read -l line
        set line_count (math $line_count + 1)
        
        # Skip comments and empty lines
        set trimmed (string trim "$line")
        if test -z "$trimmed"
            continue
        end
        if string match -q '#' "$trimmed"
            continue
        end
        
        # Parse key=value pairs
        set key (echo "$trimmed" | string split -f 1 "=")
        set value (echo "$trimmed" | string split -f 2 "=")
        
        if test -n "$key" -a -n "$value"
            echo "  Found setting: $key = $value"
            # Store in a universal variable for script-wide access
            set -g config_$key "$value"
        end
    end < "$config_file"
    
    echo "Parsed $line_count lines total"
end

function update_config
    set config_file $argv[1]
    set setting_key $argv[2]
    set new_value $argv[3]
    
    if not test -w "$config_file"
        echo "Error: Config file '$config_file' not writable" >&2
        return 1
    end
    
    # Create temporary file for atomic update
    set tmpfile (mktemp)
    set found false
    
    while read -l line
        set trimmed (string trim "$line")
        set key (echo "$trimmed" | string split -f 1 "=")
        
        if test "$key" = "$setting_key"
            echo "$setting_key=$new_value" >> "$tmpfile"
            set found true
        else
            echo "$line" >> "$tmpfile"
        end
    end < "$config_file"
    
    # If key wasn't found, append it
    if test "$found" = "false"
        echo "$setting_key=$new_value" >> "$tmpfile"
        echo "  Added new setting: $setting_key=$new_value"
    else
        echo "  Updated setting: $setting_key=$new_value"
    end
    
    # Atomic move
    mv "$tmpfile" "$config_file"
    echo "Configuration updated successfully"
end

# Demo usage
echo "=== Config File Demo ==="

# Create sample config
echo "host=localhost
port=8080
debug=false
# This is a comment
max_connections=100" > test_config.txt

echo "Created sample configuration file"

# Parse it
parse_config test_config.txt

# Update a setting
update_config test_config.txt port 9090

# Add new setting
update_config test_config.txt log_level info

# Show final config
echo "=== Final Configuration ==="
cat test_config.txt

# Clean up demo file
rm -f test_config.txt
echo "Demo complete — cleaned up"

Conclusion

File operations in Fish scripting combine the power of traditional Unix tools with Fish's clean, modern syntax. By mastering the techniques covered in this guide — from basic file creation and reading to advanced patterns like atomic writes, recursive directory traversal, and robust error handling — you can build reliable, maintainable automation scripts. Fish eliminates many of the quoting pitfalls and inconsistent behaviors found in other shells, making file operations more predictable and your scripts less error-prone. Remember to always validate file existence and permissions before operations, clean up temporary files, and use atomic writes when modifying critical configuration. With these practices, your Fish scripts will handle file operations efficiently and safely in any environment.

🚀 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