We have spent the whole module postponing numbers. daily-report.sh already knows how to walk cities, accumulate counters in maps and dispatch subcommands, but when the moment comes to say what percentage of shipments ended in an issue, or what the average amount is, it gives up: we have been truncating cents with ${amount%.*} because, and this is the sentence that sums up the entire lesson, Bash only knows how to do integer arithmetic. This is not a minor limitation or a detail: it shapes how every calculation in a script is written and forces you to know the emergency exits. Here you will see native arithmetic in depth, its traps —including one that breaks date scripts every August— and the external tools for when you really need decimals.
Contents
- Bash does integers only
$(( ))and(( )): value versus command- Available operators
- Number bases and the
08trap declare -i,letandexpr- Decimals with
bc - Decimals with
awk, and a comparison - Percentages and rounding
RANDOM,SECONDSand date arithmetic- 64-bit limits
- The complete
daily-report.sh
- Bash does integers only
echo $(( 10 / 4 )) # 2 ← not 2.5; it truncates toward zero, it does NOT round
echo $(( 1 / 3 )) # 0
echo $(( 24.50 + 10 )) # syntax error: "24.50" is not a valid numberBash works with 64-bit signed integers, full stop. Division truncates: 7/2 is 3, and -7/2 is -3 (toward zero, not downward). Literals with a decimal point are not even accepted as input. The three practical consequences that govern everything else: for percentages and averages you have to multiply before dividing or the result will be 0; euro amounts are better stored in cents (integers) and formatted at the end; and when you really need decimals in the calculation, you delegate to bc or awk.
$(( )) and (( )): value versus command
$(( )) and (( )): value versus commandThey are two different constructs sharing the same internal syntax: n=$(( 3 + 4 )) is an expansion replaced by the value (n holds 7), whereas (( n > 5 )) is a command that produces no output and only sets $? (here, 0 because the comparison is true).
Inside both, variables carry no $, because arithmetic context already knows they are variables:
total=128; issues=11
echo $(( issues * 100 / total )) # 8 ← no $ inside
echo $(( $issues * 100 / $total )) # 8 ← with $ it works, but it is redundant
(( total++ )); (( total += 10 )) # 129, then 139The $ is only indispensable for positional parameters ($1) and special ones ($#), and for associative array elements with a variable key: (( SHIPMENTS[$c]++ )).
Now the counterintuitive part. As a command, (( )) inverts C's criterion:
| Expression | Arithmetic value | $? (exit code) |
In an if |
|---|---|---|---|
(( 1 )) / (( 5 )) |
1 / 5 | 0 | true |
(( 0 )) / (( 3 > 5 )) |
0 | 1 | false |
It is consistent: in arithmetic, "non-zero" is true; in the shell, success is 0. (( )) translates between both worlds. But that creates a real trap with set -e (you will see it in 05-03), which aborts the script on any failing command: with counter=0, the post-increment in (( counter++ )) evaluates to the previous value, that is 0, so $? is 1 and the script dies. The solutions, in order of preference:
(( ++counter )) (pre-increment: evaluates to 1, code 0), (( counter++ )) || true, or counter=$(( counter + 1 )), which, being an expansion, never fails. Pocket rule: use $(( )) to compute and (( )) for conditions; if you use (( )) to increment under set -e, put the ++ in front or add || true.
- Available operators
| Category | Operators | Example |
|---|---|---|
| Arithmetic | + - * / % ** |
$(( 2 ** 10 )) → 1024 |
| Increment and assignment | ++ --, = += -= *= /= %= |
(( ++i )), (( sum += amt )) |
| Comparison | == != < <= > >= |
(( total > 100 )) |
| Logical and ternary | && || !, c ? a : b |
$(( n > 0 ? n : -n )) |
| Bitwise | & | ^ ~ << >> |
$(( 1 << 3 )) → 8 |
Examples: echo $(( 17 % 5 )) gives the remainder 2; max=$(( a > b ? a : b )) keeps the greater of two; and (( errors > 0 && verbose )) && show_detail combines two numeric conditions. Inside (( )) you can safely use < and >, something impossible in [ ] where they would be redirections. That is why to compare numbers, always (( )), leaving [[ ]] for strings and files. It is the same recommendation as in 03-03, now justified.
- Number bases and the
08 trap
08 trapIn arithmetic context, Bash interprets a number's prefix:
| Written as | Base | Value |
|---|---|---|
42 / 0x2A |
Decimal / hexadecimal | 42 |
052 |
Octal (leading zero) | 42 |
2#101010 |
Binary (base#number) |
42 |
10#08 |
Forced decimal | 8 |
The third one is the bomb. A leading zero means octal, and in octal the digits 8 and 9 do not exist, so month="08"; echo $(( month + 1 )) does not print 9: it aborts with bash: 08: value too great for base (error token is "08").
This error shows up exactly where it hurts most: processing dates and times, which naturally come with leading zeros (08 for August, 09 for September, the hour 08:00). A script that worked for eleven months blows up in August. The fix is to force the base with 10#:
month="08"; echo $(( 10#$month + 1 )) # 9 ✓
hour="09"
(( 10#$hour >= 8 && 10#$hour < 20 )) && echo "Delivery hours"Note that here the $ is mandatory: 10#$month textually builds 10#08 before evaluating it. The alternative is to strip the zero with an expansion from 04-04, ${month#0}, although it fails if the value is 00. Apply 10# to every number coming from outside —from date, from a file, from an argument— because you do not control whether it carries leading zeros.
declare -i, let and expr
declare -i, let and exprdeclare -i marks a variable as an integer: any assignment is evaluated as arithmetic.
declare -i counter=0
counter+=5; counter="3 * 4" # 5 (adds, does not concatenate), then 12 (the string is evaluated)
counter="hello" # 0: anything non-numeric is worth zero, with NO warningThat last silent behavior is why many style guides advise against it: they prefer normal variables and explicit $(( )), which keeps visible where the computing happens. Use it for obvious accumulators (declare -i total=0) and not for input data.
let "n = 3 + 4" and n=$(expr 3 + 4) are legacy ways of writing n=$(( 3 + 4 )). expr is especially bad: it creates a process per operation, demands spaces around every operator and forces you to escape * as \*; it exists only for compatibility with old shells (08-07). let is a builtin and correct, but (( )) does the same with better syntax. Write (( )) and $(( )); recognize let and expr when you see them in old scripts.
- Decimals with
bc
bcbc is an arbitrary-precision calculator. It reads expressions from standard input and writes the result:
bc <<< "24.50 * 3" # 73.50 ← here-string, shorter than echo | bc
echo "scale=2; 10 / 4" | bc # 2.50
total=1487; delivered=1362
printf 'Deliveries: %.2f%%\n' "$(bc -l <<< "scale=2; $delivered*100/$total")" # 91.59%The key points:
- Without
scale, division is integer:echo "10/4" | bcgives2. You have to setscale=Nat the start of the expression, or use-l(which loads the math library and setsscale=20); if with-lyou only want two decimals, combinebc -l <<< "scale=2; 10/4"or format afterwards withprintf '%.2f'(where%%prints a literal%). - Bash variables are expanded first, so they carry
$and go quoted:bc <<< "scale=2; $a / $b". - Careful with the decimal separator:
bcuses a dot, always. If your data comes with a comma, convert it with${v/,/.}(04-04). bcis not a builtin: every call is a process. Inside a loop over 10,000 lines, that shows.
- Decimals with
awk, and a comparison
awk, and a comparisonawk also computes in floating point, and it has one advantage: it can read the file and compute at the same time, in a single pass and a single process. awk -F, 'NR>1 {s += $6; n++} END {printf "%.2f\n", s/n}' shipments.csv returns 26.34, the average amount, with no Bash loop in between.
| Criterion | Native $(( )) |
bc |
awk |
|---|---|---|---|
| Decimals | No | Yes | Yes |
| Processes launched | 0 | 1 per call | 1 per call |
| Speed in loops | Maximum | Low | Low (but a single pass) |
| Precision | 64-bit integers | Arbitrary | Floating point (double) |
| Can read files | No | No | Yes |
Selection criterion: integers → $(( )); a one-off decimal calculation → bc; aggregating a whole file → awk. The last one is the star of 06-01, where you will see that many loops from this module collapse into one line.
- Percentages and rounding
With integers, the technique is to multiply by 100 before dividing:
issues=11; total=128
echo $(( issues * 100 / total )) # 8 ✓ correct
echo $(( issues / total * 100 )) # 0 ✗ the division truncates to 0 before multiplyingTo get one decimal without leaving Bash, multiply by 1000 and build the string by hand; and to round to the nearest integer instead of truncating, add half the divisor before dividing:
p=$(( issues * 1000 / total )); printf '%d.%d%%\n' $(( p/10 )) $(( p%10 )) # 8.5%
echo $(( (issues * 100 + total / 2) / total )) # 9 instead of 8: integer roundingAnd the clean version with two decimals, which is the one we will use: printf 'Issues: %.2f%%\n' "$(bc -l <<< "scale=4; $issues * 100 / $total")" prints Issues: 8.59%. Note the scale=4 followed by %.2f: you compute with more precision than needed and round when formatting, which is the correct way to avoid accumulated rounding errors.
RANDOM, SECONDS and date arithmetic
RANDOM, SECONDS and date arithmeticRANDOM (seen in 03-02) returns a pseudorandom integer between 0 and 32767 on each read. With the modulo it is bounded to a range, useful for sampling:
With the % operator the low values are ever so slightly favored; for a sampling audit that is irrelevant, for cryptography RANDOM is no use at all (use /dev/urandom).
SECONDS counts the seconds since the shell started or since the last time a value was assigned to it, so SECONDS=0; work; printf 'Processed in %d s\n' "$SECONDS" times a block with nothing else. For finer measurements, date +%s gives the Unix timestamp in seconds (and %s%3N in milliseconds), so start=$(date +%s) followed by $(( $(date +%s) - start )) measures the exact duration.
Date arithmetic. Do not do it by hand: months have different lengths and leap years exist. date -d (GNU) understands relative expressions:
yesterday=$(date -d 'yesterday' +%F) # 2026-08-02
days_ago_7=$(date -d '7 days ago' +%F) # 2026-07-27
tomorrow=$(date -d "$report_date + 1 day" +%F) # on a specific date
days=$(( ( $(date -d "$d2" +%s) - $(date -d "$d1" +%s) ) / 86400 )) # days between datesConverting both dates to Unix seconds and subtracting is the universal idiom for "how many days have passed". You will use it in 07-01 to schedule tasks and in 07-03 to delete backups older than N days.
- 64-bit limits
Bash uses 64-bit signed integers: from -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807. When you go past that, it overflows silently: echo $(( 9223372036854775807 + 1 )) prints -9223372036854775808. No error, no warning: the number wraps around. In practice you will not hit it adding amounts, but it can show up with nanosecond timestamps or byte sizes multiplied several times. If you suspect a calculation may approach that order of magnitude, use bc, which has arbitrary precision and never overflows.
- The complete
daily-report.sh
daily-report.shWe add the calculations that were missing. Amounts are accumulated in cents —integers— and only converted to euros when printing:
# accumulate_day stores AMOUNT[city] in CENTS (integer)
accumulate_day() {
local report_date="${1:?}" f city status amount euros cents
declare -gA SHIPMENTS ISSUES AMOUNT
while IFS=, read -r _ f city _ status amount; do
[[ "$f" == "$report_date" ]] || continue
euros="${amount%.*}"; cents="${amount#*.}" # 04-04
(( SHIPMENTS["$city"]++ ))
(( AMOUNT["$city"] += 10#$euros * 100 + 10#$cents )) # 10#: in case of "05"
[[ "$status" == "issue" ]] && (( ISSUES["$city"]++ ))
done < <(tail -n +2 "$CSV_PATH")
}
# percentage — Computes a/b*100. Usage: percentage <part> <total>
percentage() { # the guard avoids division by zero
(( ${2:?} == 0 )) && { echo "0.00"; return 0; }; bc -l <<< "scale=4; $1 * 100 / $2"
}
cities_table() {
local c shipments issues amount total_shipments=0 total_issues=0
printf '%-10s %9s %7s %8s %10s %9s\n' CITY SHIPMENTS ISSUES PERCENT AMOUNT AVERAGE
for c in "${CITIES[@]}"; do
[[ -v SHIPMENTS[$c] ]] || continue
shipments="${SHIPMENTS[$c]}"; issues="${ISSUES[$c]:-0}"; amount="${AMOUNT[$c]}"
printf '%-10s %9d %7d %7.2f%% %9.2f€ %8.2f€\n' \
"$c" "$shipments" "$issues" "$(percentage "$issues" "$shipments")" \
"$(bc -l <<< "scale=4; $amount/100")" \
"$(bc -l <<< "scale=4; $amount/100/$shipments")"
(( total_shipments += shipments, total_issues += issues ))
done
printf '%-10s %9d %7d %7.2f%%\n' TOTAL "$total_shipments" "$total_issues" \
"$(percentage "$total_issues" "$total_shipments")"
printf 'Correct deliveries: %.2f%%\n' \
"$(percentage "$(( total_shipments-total_issues ))" "$total_shipments")"
}Output:
CITY SHIPMENTS ISSUES PERCENT AMOUNT AVERAGE Valencia 128 11 8.59% 3204.55€ 25.04€ Madrid 152 14 9.21% 4013.75€ 26.41€ TOTAL 450 41 9.11% Correct deliveries: 90.89%
And the variation against the previous day, which compares two runs:
variation() { # returns "n/a" if there were no shipments yesterday
(( ${2:?} == 0 )) && { echo "n/a"; return 0; }; bc -l <<< "scale=2; ($1-$2)*100/$2"
}
yesterday_shipments=$(grep -c ",$(date -d yesterday +%F)," "$CSV_PATH")
printf 'Volume variation: %+.2f%%\n' "$(variation "$total_shipments" "$yesterday_shipments")" # +3.45%Three design decisions worth underlining: amounts live as integers in cents throughout the calculation, bc is invoked once per cell and not inside the reading loop (which processes thousands of lines), and percentage() checks the divisor before dividing, because a division by zero in bc does not abort but does pollute the output.
Common Mistakes and Tips
- Dividing before multiplying.
a / b * 100gives 0 almost always. Alwaysa * 100 / b. - The octal
08. Every number coming fromdateor from a file:10#$n. (( counter++ ))withset -e. The post-increment returns code 1 the first time and kills the script. Use++counter.- Forgetting
scaleinbc. Without it the division is integer, just like in Bash, and you will wonder why10/4gives 2. - Not checking the divisor. A day with no shipments makes
totalequal 0; in$(( ))that aborts with "division by 0". - Calling
bcinside a loop of thousands of iterations. Accumulate in integers and convert at the end, or hand the whole job toawk. - Tip: store money, times and magnitudes in the smallest integer unit (cents, seconds, bytes). All the arithmetic becomes exact and only the final
printfneeds decimals.
Exercises
Exercise 1. Write int_percentage(), which computes the rounded (not truncated) percentage of $1 over $2 using only native arithmetic and returns 0 if the total is zero.
Exercise 2. A script receives an hour in HH format (for example 08 or 19) and must print Delivery hours if it is between 8 and 19 inclusive, and Outside hours otherwise. Write it avoiding the octal trap.
Exercise 3. Compute the average amount per shipment of shipments.csv with two decimals in two ways: accumulating in cents with Bash + bc, and with a single line of awk. Measure how long each one takes with SECONDS.
Solutions
Solution 1.
int_percentage() {
local part="${1:?}" total="${2:?}"
(( total == 0 )) && { echo 0; return 0; }
echo $(( (part * 100 + total / 2) / total ))
}
int_percentage 11 128 # 9 (8.59 rounded)The + total / 2 before dividing is the standard integer rounding trick: it adds half a unit of the final result.
Solution 2.
hour="${1:?hour missing}"
if (( 10#$hour >= 8 && 10#$hour <= 19 )); then echo "Delivery hours"
else echo "Outside hours"; fiWithout the 10#, the input 08 would trigger value too great for base and the script would die every morning at eight. With it, it works equally well with 08, 8 and 19.
Solution 3.
readonly CSV=/srv/veloz/data/shipments.csv
SECONDS=0; sum=0; n=0
while IFS=, read -r _ _ _ _ _ amount; do
(( sum += 10#${amount%.*} * 100 + 10#${amount#*.} )); (( ++n ))
done < <(tail -n +2 "$CSV")
printf 'Bash: %.2f€ in %d s\n' "$(bc -l <<< "scale=4; $sum/100/$n")" "$SECONDS"
SECONDS=0
printf 'awk: %s€ in %d s\n' \
"$(awk -F, 'NR>1 {s+=$6; n++} END {printf "%.2f", s/n}' "$CSV")" "$SECONDS"Both give 26.34€. With a few thousand lines both take less than a second, but as the file grows the difference shoots up in awk's favor: a single pass, a single process, native floating point. It is the tool you will start using in 06-01.
Conclusion
Bash computes with 64-bit integers and nothing else: division truncates, decimals are not accepted, and that is why every monetary calculation is done in cents and every percentage multiplies before dividing. $(( )) produces the value and (( )) acts as a command with an inverted exit code —arithmetic zero means failure—, inside both the variables carry no $, and 10#$n saves you from an 08 coming from a date blowing up the script. When you really need decimals, bc -l with its scale solves the one-off calculation and awk solves the whole file; printf %.2f applies the final format. RANDOM, SECONDS and date -d complete the instrument set for sampling, measuring and comparing dates.
This closes Module 4 and, above all, it closes the transformation of daily-report.sh: what in Module 3 was a linear script you had to invoke once per city is today a program with loops, documented functions, counter maps, printf formatting, subcommands dispatched by case and statistics with decimals. It has structure.
What it still does not have is robustness. If the CSV is half-written, if the disk fills up, if someone launches it twice at once, the script will do strange things without saying why. In Module 5 we take that leap: find, xargs, tar and safe temporary files with mktemp (05-01); process and signal management (05-02); set -euo pipefail, trap and debugging with set -x (05-03); real regular expressions with =~ (05-04); file descriptors and here-documents (05-05); and finally the lib/common.sh library we have been promising since 04-02, where your functions will stop living inside a single script and become the shared Veloz Envíos toolkit (05-06).
Bash Programming Course
Module 1: Introduction to Bash
- What Is Bash?
- Setting Up Your Environment
- Basic Command Line Navigation
- Understanding the Shell
- Finding Help: man, help and --help
Module 2: Basic Bash Commands
- File and Directory Operations
- Text Processing Commands
- File Permissions and Ownership
- Redirection and Piping
- Wildcards and Path Expansion
- History and Keyboard Shortcuts
Module 3: Scripting Fundamentals
- Creating and Running a Script
- Variables and Constants
- Basic Operators
- Conditional Statements
- Arguments and User Input
- Quoting, Expansion and Substitution
Module 4: Intermediate Scripting
- Loops in Bash
- Functions in Bash
- Arrays and Associative Arrays
- String Manipulation
- The case Statement and Interactive Menus
- Arithmetic and Numeric Calculations
Module 5: Advanced Scripting Techniques
- Advanced File Operations
- Process Management
- Error Handling and Debugging
- Regular Expressions
- Advanced I/O: Descriptors and Here-Documents
- Modular Scripts and Reusable Libraries
Module 6: Working with External Tools
Module 7: Automation and Scheduling
- Cron Jobs
- Automating Tasks
- Backup and Restore Scripts
- Monitoring and Logging
- Services and Timers with systemd
- Remote Automation with SSH
Module 8: Best Practices and Optimization
- Writing Readable Code
- Optimizing Bash Scripts
- Security Considerations
- Version Control with Git
- Static Analysis with ShellCheck and shfmt
- Automated Testing with Bats
- Portability: POSIX sh versus Bashisms
