let, double parentheses, and the pitfalls of pure integer math
Bash gives you three different ways to do math, $(( )), let, and (( )), that look interchangeable at first glance but differ in return value, exit status, and error behavior. Anyone who does not know the integer limits of 64-bit systems, the lack of floating point, and the typical traps around comparison operators ends up with scripts that suddenly produce wrong results, or abort entirely, for certain inputs.
Table of Contents
- 1. Three notations for the same thing: $(( )), let, and (( ))
- 2. The exit status trap: why (( )) can terminate a script
- 3. Integer overflow in 64-bit arithmetic
- 4. Floating-point limits: why bc and awk are the way out
- 5. Common mistakes with comparison operators in conditions
- 6. Number bases and the implicit interpretation of leading zeros
- 7. Compound assignment operators and their practical limits
- 8. Performance considerations: built-in arithmetic vs. external processes
- 9. Best practices: which notation, when
- 10. Summary
- 11. FAQ
1. Three notations for the same thing: $(( )), let, and (( ))
Bash provides three syntactically different ways to compute numbers that internally all fall back on the same arithmetic evaluator. Command substitution $(( expression )) returns the result as a string and fits well for assigning a computed value to a variable or inserting it into another command. The let expression command evaluates the same kind of expression, but does not return a string, it only sets the exit status depending on whether the result is non-zero.
The compound command (( expression )) is functionally almost identical to let, but is considered the more modern and readable variant, because it does not require quotes around expressions containing spaces, whereas let strictly requires quoting whenever the expression contains a space. In practice, (( )) has become the standard for conditions and increments, while $(( )) remains the right choice whenever the result actually needs to be used further as a value.
#!/usr/bin/env bash
set -euo pipefail
a=5
b=3
# $(( )) returns a value -- use for assignment or substitution
sum=$((a + b))
echo "Sum via \$(( )): $sum"
# let evaluates the expression, exit status reflects the result
let "product = a * b"
echo "Product via let: $product"
# (( )) is the modern equivalent, no quoting needed around spaces
(( difference = a - b ))
echo "Difference via (( )): $difference"
2. The exit status trap: why (( )) can terminate a script
A particularly nasty pitfall is that both let and (( )), when used as a command, return an exit status of 1 when the arithmetic result is 0, and 0 when the result is non-zero. Under set -e, that causes a script to abort immediately the moment a counter variable happens to reach zero, for example with (( counter-- )) when counter just dropped from 1 to 0, even though the script should logically keep running just fine.
The common fix is appending || true to the expression when the return value is not meant as an error indicator, so (( counter-- )) || true, or alternatively using the prefix form (( --counter )), which decrements the value first and then checks it, which is what most counting loops actually want, because the resulting value rarely happens to land on zero.
#!/usr/bin/env bash
set -euo pipefail
counter=1
# BUG: this line makes the whole script exit under set -e once counter hits 0,
# because (( )) returns exit status 1 when the arithmetic result is 0
# (( counter-- ))
# FIX 1: neutralize the exit status explicitly
(( counter-- )) || true
echo "After fix 1: $counter"
counter=1
# FIX 2: use pre-decrement so the checked value is the new one, not the old one
(( --counter )) || true
echo "After fix 2: $counter"
3. Integer overflow in 64-bit arithmetic
Bash internally computes exclusively with signed 64-bit integers, which on most systems today corresponds to a range of roughly minus 9.2 to plus 9.2 quintillion. As long as scripts work with typical counters, file sizes, or timestamps, that limit is practically never reached, but calculations involving very large numbers, for example multiplying several large factors inside a loop, can overflow, silently wrapping around to a negative or unexpected value without Bash issuing any warning.
Unlike many other languages, Bash has no built-in overflow detection and raises no exception when the range is exceeded. Anyone working with potentially very large numbers, such as cryptographic checksums or scientific calculations, should sanity-check the result after critical operations, or switch to a tool like bc up front, which computes with arbitrary precision.
#!/usr/bin/env bash
set -euo pipefail
readonly MAX_INT64=9223372036854775807
echo "Max int64: $MAX_INT64"
echo "Max int64 + 1 wraps to: $(( MAX_INT64 + 1 ))"
# Output: a large negative number -- silent overflow, no error, no warning
4. Floating-point limits: why bc and awk are the way out
Bash's built-in arithmetic only ever knows integers, every division automatically truncates toward zero, and an expression like $(( 7 / 2 )) returns 3, not 3.5. Anyone who tries to insert a floating-point number like 3.14 directly into an arithmetic expression gets an error, because Bash treats the dot as an invalid character in the expression, not as a decimal separator.
For real floating-point calculations, the only path is an external tool: bc with the -l option to load the math library provides precise decimal arithmetic including rounding to a chosen number of digits, while awk is often the better fit when text data is already being processed line by line and an additional floating-point calculation needs to happen in the same pass, without requiring another external process call.
#!/usr/bin/env bash
set -euo pipefail
# Bash integer division truncates toward zero
echo "Integer division: $(( 7 / 2 ))" # 3, not 3.5
# bc handles real floating point, -l loads the math library
result=$(echo "scale=4; 7 / 2" | bc -l)
echo "bc result: $result" # 3.5000
# awk works well when already processing lines of numeric data
awk 'BEGIN { printf "%.4f\n", 7 / 2 }' # 3.5000
5. Common mistakes with comparison operators in conditions
A classic beginner mistake is using the numeric comparison operators -eq, -lt, -gt from the test command inside (( )), where they simply do not exist, because double parentheses expect the C-style syntax ==, <, >. Conversely, [[ $a < $b ]] fails for numeric comparisons, because square brackets compare operands as strings by default, so 10 < 9 evaluates true as a string comparison, because "1" lexicographically sorts before "9".
The reliable rule is: numeric comparisons consistently belong inside (( a < b )) or inside [[ ]] combined with -lt/-gt/-eq, never using the C-style operators inside [[ ]]. Consistently separating the two forms avoids the single most common source of bugs in Bash conditions, a string comparison that looks like a number comparison but behaves completely differently once multi-digit numbers are involved.
#!/usr/bin/env bash
set -euo pipefail
a=10
b=9
# WRONG: [[ ]] compares strings by default, "10" < "9" lexicographically is true
if [[ "$a" < "$b" ]]; then
echo "String comparison says: $a is less than $b (WRONG for numbers!)"
fi
# CORRECT: numeric comparison with test-style operators inside [[ ]]
if [[ "$a" -lt "$b" ]]; then
echo "This will not print"
else
echo "Numeric comparison correctly says: $a is not less than $b"
fi
# CORRECT: C-style operators only work inside (( ))
if (( a < b )); then
echo "This will not print either"
else
echo "(( )) comparison also correctly says: $a is not less than $b"
fi
6. Number bases and the implicit interpretation of leading zeros
A lesser-known pitfall involves numbers with leading zeros: Bash interprets an arithmetic expression like $(( 010 )) as an octal number, because a leading zero counts as a base prefix in Bash's arithmetic grammar, so the expected result 10 actually comes back as 8. When processing values from external sources such as config files or user input, for example date components like 08 or 09, a misinterpretation as octal can even trigger a hard error, because 08 and 09 are not valid octal digits.
The safe approach is to explicitly strip leading zeros before arithmetic evaluation, for example with a parameter expansion like ${value#0} in a loop, or by forcing the base with the 10# prefix, which explicitly tells Bash to interpret the following value as decimal regardless of leading zeros.
#!/usr/bin/env bash
set -euo pipefail
# Leading zero is interpreted as octal -- surprising result
echo "Naive: $(( 010 ))" # 8, not 10
# 09 is not even a valid octal digit -- this line would error out
# echo "$(( 09 ))"
# Fix: force base-10 interpretation explicitly with the 10# prefix
value="09"
echo "Forced base 10: $(( 10#$value ))" # 9, as expected
7. Compound assignment operators and their practical limits
Beyond basic arithmetic, Bash arithmetic also supports compound operators like +=, -=, *=, /=, as well as bit operations like <<, >>, &, |, and ^, which come in handy for flag masks or bit-field manipulation. These operators behave identically across all three notations, though (( )) still offers the most readable syntax here too, since spaces around operators do not require quoting.
An often overlooked point is that division with /= truncates just like regular division, so a chain like value /= 3; value *= 3 does not necessarily return the original value once value was not evenly divisible by 3. Rounding losses like this accumulate quietly across loops with many iterations and only surface as a surprising deviation from the expected result once an explicit final check is performed.
8. Performance considerations: built-in arithmetic vs. external processes
An often underestimated advantage of Bash's built-in arithmetic is its speed: because (( )) and $(( )) are evaluated entirely inside the running Bash process, a single calculation costs practically no measurable time, while every call to bc or awk spawns a new process, which quickly becomes a noticeable bottleneck in loops with thousands of iterations. In a loop performing a simple integer addition tens of thousands of times, the difference between (( sum += i )) and a repeated bc call can easily be a factor of several hundred.
The practical consequence is staying with pure integer arithmetic inside performance-critical loops for as long as possible, and calling external tools like bc only for a final floating-point calculation outside the loop, rather than restarting it on every iteration. Where floating-point intermediate results are strictly needed on every iteration, it is often worth switching entirely to awk as the single external process that handles the whole loop in one call.
9. Best practices: which notation, when
In practice a clear split has proven itself: (( )) for conditions, increments, and assignments inside loops, $(( )) anywhere the result has to be passed along as a value or embedded into another string, and let only rarely, mostly out of habit or in older code, since (( )) offers the same functionality without the quoting pitfalls. For anything beyond integers, reaching for bc or awk remains the correct and only reliable solution.
| Form | Return value | Exit status at 0 | Typical use |
|---|---|---|---|
$(( )) |
String with the result | Always 0 | Value assignment, embedding into strings |
let |
No string | 1 when result is 0 | Rare, mostly legacy code |
(( )) |
No string | 1 when result is 0 | Conditions, incrementing |
bc -l |
String with a decimal | 0 on success | Real floating-point calculation |
awk |
String, formattable | 0 on success | Floating point in text pipelines |
Mironsoft
Shell automation, DevOps tooling and deployment infrastructure
Shell scripts that hold up in production?
We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.
Code Review
ShellCheck analysis and manual review for critical Bash pattern violations.
Refactoring
Retrofitting error handling, logging and safe file operations.
CI Integration
Wiring ShellCheck and BATS into pipelines and building regression tests.
10. Summary
Bash Arithmetic: The Essentials at a Glance
Three forms
$(( )) returns a value, let and (( )) instead set the exit status. (( )) is considered the most modern, quoting-friendly variant.
Exit status trap
(( )) and let return exit status 1 when the result is 0. Under set -e that can terminate a script unexpectedly.
Overflow and floating point
64-bit integers silently overflow at very large numbers. For decimal math, bc -l and awk remain the only way.
Comparison trap
[[ $a < $b ]] compares strings, not numbers. Numeric comparisons belong inside (( )) or in [[ ]] with -lt/-gt/-eq.