Bash functions are a first-class citizen, not just a shell scripting quirk.

Let’s see one in action. Imagine you need to check if a file exists and if it’s readable.

#!/bin/bash

# Define a function to check file readability
check_readable() {
  local file="$1" # Use local for variables within functions
  if [[ -f "$file" && -r "$file" ]]; then
    echo "File '$file' exists and is readable."
    return 0 # Success
  else
    echo "Error: File '$file' does not exist or is not readable." >&2 # Send errors to stderr
    return 1 # Failure
  fi
}

# Call the function
check_readable "/etc/passwd"
check_readable "/tmp/nonexistent_file.txt"

When you run this, you’ll see:

File '/etc/passwd' exists and is readable.
Error: File '/tmp/nonexistent_file.txt' does not exist or is not readable.

The problem Bash functions solve is repetitive code. Instead of writing if [[ -f "$file" && -r "$file" ]] multiple times, you encapsulate it. This makes your scripts shorter, more readable, and easier to maintain. If you need to change how you check file readability, you only change it in one place.

Internally, a Bash function is just a block of commands that you can call by name. When you call a function, Bash sets up a new execution environment for it. Variables declared with local are scoped to that function, preventing them from clobbering global variables or variables in other functions. Arguments are passed by position and accessed using $1, $2, etc., just like script arguments. The exit status of the last command executed in the function becomes the function’s exit status, which you can explicitly set with return.

Consider error handling. The >&2 redirects the echo output to standard error. This is crucial because standard output is often piped to other commands, and error messages should go to the terminal or a log file, not be captured as if they were data. The return 0 and return 1 explicitly signal success or failure, allowing you to check the outcome of the function call using $?.

#!/bin/bash

# Define a function that returns a value and signals success/failure
get_user_home() {
  local user="$1"
  local home_dir
  home_dir=$(eval "getent passwd $user | cut -d: -f6") # Use eval for commands that need expansion

  if [[ -n "$home_dir" ]]; then
    echo "$home_dir" # Output the home directory to stdout
    return 0
  else
    echo "Error: User '$user' not found." >&2
    return 1
  fi
}

# Call the function and capture its output and status
read -r -p "Enter a username: " username

if home=$(get_user_home "$username"); then
  echo "Home directory for $username is: $home"
else
  echo "Failed to get home directory for $username."
fi

Running this might produce:

Enter a username: root
Home directory for root is: /root

Or, if the user doesn’t exist:

Enter a username: nonuser
Error: User 'nonuser' not found.
Failed to get home directory for nonuser.

The eval command in get_user_home is a bit of a hammer, but it’s necessary here because getent passwd $user | cut -d: -f6 needs the getent command to be executed with the expanded $user variable within the eval context. Without eval, it would try to execute getent passwd $user literally if $user contained spaces or special characters, which is not what we want. This highlights that while functions are powerful, the commands within them still need to be constructed carefully.

The most surprising thing about Bash functions is how seamlessly they integrate with standard I/O and exit codes, making them as flexible as any standalone script but without the overhead of process creation. You can pipe the output of a function, check its exit status, and pass arguments just like any other command.

The next step is understanding how to manage multiple functions and organize them, perhaps by sourcing them from separate files.

Want structured learning?

Take the full Bash course →