The [[ ]] construct in Zsh is not just a fancier version of [ ] or test; it’s a fundamentally different parsing mechanism that allows for more sophisticated and safer shell scripting.

Let’s see it in action. Imagine you want to check if a file exists and is executable.

# Create a dummy file for demonstration
touch my_script.sh
chmod +x my_script.sh

# Check if the file exists and is executable
if [[ -f my_script.sh && -x my_script.sh ]]; then
  echo "my_script.sh exists and is executable."
else
  echo "my_script.sh is missing or not executable."
fi

# Clean up
rm my_script.sh

This looks straightforward, but the power lies in how [[ ]] handles string comparisons and pattern matching, which [ ] struggles with. For instance, [[ ... ]] treats variables within it as strings by default, meaning you don’t need to quote them to prevent word splitting or globbing.

# Example without quoting, which works safely in [[ ]]
filename="my file with spaces.txt"

if [[ -e $filename ]]; then
  echo "File '$filename' exists."
else
  echo "File '$filename' does not exist."
fi

This avoids the common pitfalls of [ ] where $filename would be interpreted as multiple arguments if not quoted. [[ ]] also introduces new, more powerful operators. The == operator, when used within [[ ]], performs pattern matching (globbing) by default, not just string equality.

# Pattern matching with ==
name="apple pie"

if [[ $name == apple* ]]; then
  echo "Starts with apple."
fi

if [[ $name == *pie ]]; then
  echo "Ends with pie."
fi

if [[ $name == apple ** pie ]]; then # ** matches zero or more directories
  echo "Matches apple, then anything, then pie."
fi

The problem [[ ]] solves is the ambiguity and potential for errors in older shell conditional syntax. [ ] (which is an alias for the test command) interprets its arguments strictly based on POSIX shell rules. This means spaces in variable values can break commands, and special characters can be misinterpreted. [[ ]], on the other hand, is a Zsh keyword, not an external command. Zsh parses it internally, understanding its distinct syntax for conditional logic, variable expansion, and pattern matching. This internal parsing allows it to offer features like:

  • No Word Splitting or Globbing on Unquoted Variables: As seen above, [[ $var ]] treats $var as a single string, even if it contains spaces or glob characters, preventing unexpected behavior.
  • Pattern Matching with == and !=: These operators allow glob-style matching.
  • Logical Operators && and ||: These are true shell logical operators within [[ ]], not commands that need to be passed as arguments.
  • Regular Expression Matching with =~: This is a powerful addition for complex pattern matching.
# Regular expression matching
log_line="INFO: User 'alice' logged in."

if [[ $log_line =~ ^INFO: User '.*' logged in.$ ]]; then
  echo "Matches the expected log format."
fi

The =~ operator is particularly noteworthy. It allows you to use Extended Regular Expressions (EREs) to match against a string. The matched substrings are stored in the ${match[index]} array, making extraction very convenient.

Consider the subtle but crucial difference when comparing strings. In [ ], you use -eq for numbers and = for strings. In [[ ]], = and == are equivalent for string comparison, and == can also do pattern matching. The == operator’s default behavior as a pattern matcher is a key distinction from the = operator in [ ]. This means [[ $var = foo* ]] checks if $var starts with foo, whereas [ "$var" = foo* ] checks if $var is literally foo* (unless * is quoted, which is confusing).

The most surprising true thing about [[ ]] is that it’s not just about convenience; it’s a significant security improvement for shell scripting. By preventing word splitting and globbing on unquoted variables, it mitigates a whole class of injection vulnerabilities. For example, if a filename variable is populated by user input, [ "$filename" = *.txt ] could be tricked by a filename like exploit.txt; rm -rf /. [[ -e $filename && $filename = *.txt ]] would correctly evaluate $filename as a single string, preventing the command injection.

The next concept you’ll likely encounter is how to manage the scope and lifetime of variables within functions and how local can prevent unintended side effects across different parts of your script.

Want structured learning?

Take the full Zsh course →