Llevamos todo el módulo aplazando los números. informe-diario.sh ya sabe recorrer ciudades, acumular contadores en mapas y despachar subcomandos, pero cuando llega el momento de decir qué porcentaje de envíos acabó en incidencia, o cuál es el importe medio, se rinde: hemos ido truncando céntimos con ${importe%.*} porque, y esta es la frase que resume la lección entera, Bash solo sabe hacer aritmética entera. No es una limitación menor ni un detalle: condiciona cómo se escribe cada cálculo en un script y obliga a conocer las salidas de emergencia. Aquí verás la aritmética nativa a fondo, sus trampas —incluida una que rompe scripts de fechas cada mes de agosto— y las herramientas externas para cuando hacen falta decimales de verdad.

Contenido

  1. Bash solo hace enteros
  2. $(( )) y (( )): valor frente a comando
  3. Operadores disponibles
  4. Bases numéricas y la trampa del 08
  5. declare -i, let y expr
  6. Decimales con bc
  7. Decimales con awk, y comparativa
  8. Porcentajes y redondeo
  9. RANDOM, SECONDS y aritmética de fechas
  10. Límites de 64 bits
  11. informe-diario.sh completo

  1. Bash solo hace enteros

echo $(( 10 / 4 ))       # 2   ← no 2.5; trunca hacia cero, NO redondea
echo $(( 1 / 3 ))        # 0
echo $(( 24.50 + 10 ))   # error de sintaxis: "24.50" no es un número válido

Bash trabaja con enteros con signo de 64 bits, y punto. La división trunca: 7/2 es 3, y -7/2 es -3 (hacia cero, no hacia abajo). Los literales con punto decimal ni siquiera se aceptan como entrada. Las tres consecuencias prácticas que gobiernan todo lo demás: para porcentajes y medias hay que multiplicar antes de dividir o el resultado será 0; los importes en euros se guardan mejor en céntimos (enteros) y se formatean al final; y cuando de verdad hacen falta decimales en el cálculo, se delega en bc o awk.

  1. $(( )) y (( )): valor frente a comando

Son dos construcciones distintas que comparten sintaxis interna: n=$(( 3 + 4 )) es una expansión que se sustituye por el valor (n vale 7), mientras que (( n > 5 )) es un comando que no produce salida y solo fija $? (aquí, 0 porque la comparación es cierta).

Dentro de ambas, las variables no llevan $, porque el contexto aritmético ya sabe que son variables:

total=128; incidencias=11
echo $(( incidencias * 100 / total ))     # 8   ← sin $ dentro
echo $(( $incidencias * 100 / $total ))   # 8   ← con $ funciona, pero sobra
(( total++ )); (( total += 10 ))          # 129, luego 139

El $ solo es imprescindible para los parámetros posicionales ($1) y los especiales ($#), y para elementos de arrays asociativos con clave variable: (( ENVIOS[$c]++ )).

Ahora la parte contraintuitiva. Como comando, (( )) invierte el criterio de C:

Expresión Valor aritmético $? (código de salida) En un if
(( 1 )) / (( 5 )) 1 / 5 0 cierto
(( 0 )) / (( 3 > 5 )) 0 1 falso

Es coherente: en aritmética, "distinto de cero" es verdadero; en el shell, éxito es 0. (( )) traduce entre ambos mundos. Pero eso crea una trampa real con set -e (lo verás en 05-03), que aborta el script ante cualquier comando que falle: con contador=0, el post-incremento de (( contador++ )) evalúa al valor anterior, es decir 0, así que $? vale 1 y el script muere. Las soluciones, en orden de preferencia:

(( ++contador )) (pre-incremento: evalúa a 1, código 0), (( contador++ )) || true, o contador=$(( contador + 1 )), que al ser una expansión nunca falla. Regla de bolsillo: usa $(( )) para calcular y (( )) para condiciones; si usas (( )) para incrementar bajo set -e, pon ++ delante o añade || true.

  1. Operadores disponibles

Categoría Operadores Ejemplo
Aritméticos + - * / % ** $(( 2 ** 10 )) → 1024
Incremento y asignación ++ --, = += -= *= /= %= (( ++i )), (( suma += imp ))
Comparación == != < <= > >= (( total > 100 ))
Lógicos y ternario && || !, c ? a : b $(( n > 0 ? n : -n ))
Bits & | ^ ~ << >> $(( 1 << 3 )) → 8

Ejemplos: echo $(( 17 % 5 )) da el resto 2; max=$(( a > b ? a : b )) guarda el mayor de dos; y (( errores > 0 && verboso )) && mostrar_detalle combina dos condiciones numéricas. Dentro de (( )) puedes usar < y > con seguridad, algo imposible en [ ] donde serían redirecciones. Por eso para comparar números siempre (( )), y [[ ]] queda para cadenas y ficheros. Es la misma recomendación de 03-03, ahora justificada.

  1. Bases numéricas y la trampa del 08

En contexto aritmético, Bash interpreta el prefijo de un número:

Escritura Base Valor
42 / 0x2A Decimal / hexadecimal 42
052 Octal (cero inicial) 42
2#101010 Binaria (base#numero) 42
10#08 Decimal forzada 8

El tercero es la bomba. Un cero a la izquierda significa octal, y en octal no existen los dígitos 8 y 9, así que mes="08"; echo $(( mes + 1 )) no imprime 9: aborta con bash: 08: valor demasiado grande para la base.

Este error aparece exactamente donde más duele: procesando fechas y horas, que vienen con ceros a la izquierda de forma natural (08 de agosto, 09 de septiembre, la hora 08:00). Un script que funcionó once meses revienta en agosto. La solución es forzar la base con 10#:

mes="08"; echo $(( 10#$mes + 1 ))          # 9 ✓
hora="09"
(( 10#$hora >= 8 && 10#$hora < 20 )) && echo "Horario de reparto"

Fíjate en que aquí el $ es obligatorio: 10#$mes construye textualmente 10#08 antes de evaluarlo. La alternativa es quitar el cero con una expansión de 04-04, ${mes#0}, aunque falla si el valor es 00. Aplica 10# a todo número que venga de fuera —de date, de un fichero, de un argumento— porque no controlas si trae ceros delante.

  1. declare -i, let y expr

declare -i marca una variable como entera: cualquier asignación se evalúa como aritmética.

declare -i contador=0
contador+=5; contador="3 * 4"   # 5 (suma, no concatena), luego 12 (la cadena se evalúa)
contador="hola"                 # 0: lo no numérico vale cero, SIN avisar

Ese último comportamiento silencioso es la razón por la que muchas guías de estilo lo desaconsejan: prefieren variables normales y $(( )) explícito, que deja visible dónde hay cálculo. Úsalo para acumuladores evidentes (declare -i total=0) y no para datos de entrada.

let "n = 3 + 4" y n=$(expr 3 + 4) son formas heredadas de escribir n=$(( 3 + 4 )). expr es especialmente malo: crea un proceso por cada operación, exige espacios alrededor de cada operador y obliga a escapar el * como \*; existe solo por compatibilidad con shells antiguos (08-07). let es interno y correcto, pero (( )) hace lo mismo con mejor sintaxis. Escribe (( )) y $(( )); reconoce let y expr cuando los veas en scripts viejos.

  1. Decimales con bc

bc es una calculadora de precisión arbitraria. Lee expresiones por la entrada estándar y escribe el resultado:

bc <<< "24.50 * 3"                   # 73.50  ← here-string, más corto que echo | bc
echo "scale=2; 10 / 4" | bc          # 2.50
total=1487; entregados=1362
printf 'Entregas: %.2f%%\n' "$(bc -l <<< "scale=2; $entregados*100/$total")"  # 91.59%

Las claves:

  • Sin scale, la división es entera: echo "10/4" | bc da 2. Hay que fijar scale=N al principio de la expresión, o usar -l (que carga la librería matemática y pone scale=20); si con -l solo quieres dos decimales, combina bc -l <<< "scale=2; 10/4" o formatea después con printf '%.2f' (donde %% imprime un % literal).
  • Las variables de Bash se expanden antes, así que van con $ y entrecomilladas: bc <<< "scale=2; $a / $b".
  • Ojo con el separador decimal: bc usa punto, siempre. Si tus datos vienen con coma, conviértelos con ${v/,/.} (04-04).
  • bc no es un builtin: cada llamada es un proceso. Dentro de un bucle de 10.000 líneas, eso se nota.

  1. Decimales con awk, y comparativa

awk también calcula en coma flotante, y tiene una ventaja: puede leer el fichero y calcular a la vez, en una sola pasada y un solo proceso. awk -F, 'NR>1 {s += $6; n++} END {printf "%.2f\n", s/n}' envios.csv devuelve 26.34, el importe medio, sin bucle de Bash por medio.

Criterio $(( )) nativo bc awk
Decimales No
Procesos lanzados 0 1 por llamada 1 por llamada
Velocidad en bucles Máxima Baja Baja (pero 1 sola pasada)
Precisión 64 bits enteros Arbitraria Coma flotante (doble)
Puede leer ficheros No No

Criterio de elección: enteros → $(( )); un cálculo decimal suelto → bc; agregar un fichero entero → awk. Este último es el protagonista de 06-01, donde verás que muchos bucles de este módulo se reducen a una línea.

  1. Porcentajes y redondeo

Con enteros, la técnica es multiplicar por 100 antes de dividir:

incidencias=11; total=128
echo $(( incidencias * 100 / total ))   # 8 ✓ correcto
echo $(( incidencias / total * 100 ))   # 0 ✗ la división trunca a 0 antes de multiplicar

Para conseguir un decimal sin salir de Bash, multiplica por 1000 y monta la cadena a mano; y para redondear al entero más próximo en lugar de truncar, suma la mitad del divisor antes de dividir:

p=$(( incidencias * 1000 / total )); printf '%d.%d%%\n' $(( p/10 )) $(( p%10 ))  # 8.5%
echo $(( (incidencias * 100 + total / 2) / total ))  # 9 en vez de 8: redondeo entero

Y la versión limpia con dos decimales, que es la que usaremos: printf 'Incidencias: %.2f%%\n' "$(bc -l <<< "scale=4; $incidencias * 100 / $total")" imprime Incidencias: 8.59%. Nota el scale=4 seguido de %.2f: se calcula con más precisión de la necesaria y se redondea al formatear, que es la forma correcta de evitar errores de redondeo acumulados.

  1. RANDOM, SECONDS y aritmética de fechas

RANDOM (visto en 03-02) devuelve un entero pseudoaleatorio entre 0 y 32767 en cada lectura. Con el módulo se acota a un rango, útil para muestrear:

indice=$(( RANDOM % ${#CIUDADES[@]} ))          # una ciudad al azar del array
echo "Auditando ${CIUDADES[indice]}"

Con el operador % los valores bajos quedan ligerísimamente favorecidos; para una auditoría de muestreo es irrelevante, para criptografía RANDOM no sirve en absoluto (usa /dev/urandom).

SECONDS cuenta los segundos desde que arrancó el shell o desde la última vez que se le asignó un valor, así que SECONDS=0; trabajo; printf 'Procesado en %d s\n' "$SECONDS" cronometra un bloque sin más. Para medidas más finas, date +%s da la marca de tiempo Unix en segundos (y %s%3N en milisegundos), de modo que inicio=$(date +%s) seguido de $(( $(date +%s) - inicio )) mide la duración exacta.

Aritmética de fechas. No la hagas a mano: los meses tienen distinta duración y existen los años bisiestos. date -d (GNU) entiende expresiones relativas:

ayer=$(date -d 'yesterday' +%F)          # 2026-08-02
hace_7=$(date -d '7 days ago' +%F)       # 2026-07-27
manana=$(date -d "$fecha + 1 day" +%F)   # sobre una fecha concreta
dias=$(( ( $(date -d "$f2" +%s) - $(date -d "$f1" +%s) ) / 86400 ))  # días entre fechas

Convertir ambas fechas a segundos Unix y restar es el idioma universal para "cuántos días han pasado". Lo usarás en 07-01 para programar tareas y en 07-03 para borrar respaldos con más de N días.

  1. Límites de 64 bits

Bash usa enteros con signo de 64 bits: de -9.223.372.036.854.775.808 a 9.223.372.036.854.775.807. Al pasarse, desborda en silencio: echo $(( 9223372036854775807 + 1 )) imprime -9223372036854775808. Ningún error, ningún aviso: el número da la vuelta. En la práctica no lo tocarás sumando importes, pero sí puede aparecer con marcas de tiempo en nanosegundos o tamaños en bytes multiplicados varias veces. Si sospechas que un cálculo puede acercarse a ese orden de magnitud, usa bc, que tiene precisión arbitraria y no desborda nunca.

  1. informe-diario.sh completo

Añadimos los cálculos que faltaban. Los importes se acumulan en céntimos —enteros— y solo se convierten a euros al imprimir:

# acumular_dia guarda IMPORTE[ciudad] en CÉNTIMOS (entero)
acumular_dia() {
    local fecha="${1:?}" f ciudad estado importe euros cent
    declare -gA ENVIOS INCIDENCIAS IMPORTE
    while IFS=, read -r _ f ciudad _ estado importe; do
        [[ "$f" == "$fecha" ]] || continue
        euros="${importe%.*}"; cent="${importe#*.}"               # 04-04
        (( ENVIOS["$ciudad"]++ ))
        (( IMPORTE["$ciudad"] += 10#$euros * 100 + 10#$cent ))    # 10#: por si hay "05"
        [[ "$estado" == "incidencia" ]] && (( INCIDENCIAS["$ciudad"]++ ))
    done < <(tail -n +2 "$RUTA_CSV")
}

# porcentaje — Calcula a/b*100. Uso: porcentaje <parte> <total>
porcentaje() {   # el guard evita la división por cero
    (( ${2:?} == 0 )) && { echo "0.00"; return 0; }; bc -l <<< "scale=4; $1 * 100 / $2"
}

tabla_ciudades() {
    local c env inc imp total_env=0 total_inc=0
    printf '%-10s %7s %7s %8s %10s %9s\n' CIUDAD ENVÍOS INCID. '%INCID' IMPORTE MEDIA
    for c in "${CIUDADES[@]}"; do
        [[ -v ENVIOS[$c] ]] || continue
        env="${ENVIOS[$c]}"; inc="${INCIDENCIAS[$c]:-0}"; imp="${IMPORTE[$c]}"
        printf '%-10s %7d %7d %7.2f%% %9.2f€ %8.2f€\n' \
            "$c" "$env" "$inc" "$(porcentaje "$inc" "$env")" \
            "$(bc -l <<< "scale=4; $imp/100")" \
            "$(bc -l <<< "scale=4; $imp/100/$env")"
        (( total_env += env, total_inc += inc ))
    done
    printf '%-10s %7d %7d %7.2f%%\n' TOTAL "$total_env" "$total_inc" \
        "$(porcentaje "$total_inc" "$total_env")"
    printf 'Entregas correctas: %.2f%%\n' \
        "$(porcentaje "$(( total_env-total_inc ))" "$total_env")"
}

Salida:

CIUDAD      ENVÍOS  INCID.   %INCID    IMPORTE     MEDIA
Valencia       128      11    8.59%   3204.55€    25.04€
Madrid         152      14    9.21%   4013.75€    26.41€
TOTAL          450      41    9.11%
Entregas correctas: 90.89%

Y la variación respecto al día anterior, que compara dos ejecuciones:

variacion() {   # devuelve "n/d" si ayer no hubo envíos
    (( ${2:?} == 0 )) && { echo "n/d"; return 0; }; bc -l <<< "scale=2; ($1-$2)*100/$2"
}
ayer_env=$(grep -c ",$(date -d yesterday +%F)," "$RUTA_CSV")
printf 'Variación de volumen: %+.2f%%\n' "$(variacion "$total_env" "$ayer_env")"  # +3.45%

Tres decisiones de diseño que conviene subrayar: los importes viven como enteros en céntimos durante todo el cálculo, bc se invoca una vez por celda y no dentro del bucle de lectura (que procesa miles de líneas), y porcentaje() comprueba el divisor antes de dividir, porque una división por cero en bc no aborta pero contamina la salida.

Errores Comunes y Consejos

  • Dividir antes de multiplicar. a / b * 100 da 0 casi siempre. Siempre a * 100 / b.
  • El 08 octal. Todo número que venga de date o de un fichero: 10#$n.
  • (( contador++ )) con set -e. El post-incremento devuelve código 1 la primera vez y mata el script. Usa ++contador.
  • Olvidar scale en bc. Sin él la división es entera, igual que en Bash, y te preguntarás por qué 10/4 da 2.
  • No comprobar el divisor. Un día sin envíos hace que total valga 0; en $(( )) eso aborta con "division by 0".
  • Llamar a bc dentro de un bucle de miles de iteraciones. Acumula en enteros y convierte al final, o pasa el trabajo entero a awk.
  • Consejo: guarda dinero, tiempos y magnitudes en la unidad mínima entera (céntimos, segundos, bytes). Toda la aritmética se vuelve exacta y solo el printf final necesita decimales.

Ejercicios

Ejercicio 1. Escribe pct_entero(), que calcule el porcentaje redondeado (no truncado) de $1 sobre $2 usando solo aritmética nativa y devuelva 0 si el total es cero.

Ejercicio 2. Un script recibe una hora en formato HH (por ejemplo 08 o 19) y debe imprimir Horario de reparto si está entre 8 y 19 inclusive, y Fuera de horario en caso contrario. Escríbelo evitando la trampa del octal.

Ejercicio 3. Calcula el importe medio por envío de envios.csv con dos decimales de dos formas: acumulando en céntimos con Bash + bc, y con una sola línea de awk. Mide cuánto tarda cada una con SECONDS.

Soluciones

Solución 1.

pct_entero() {
    local parte="${1:?}" total="${2:?}"
    (( total == 0 )) && { echo 0; return 0; }
    echo $(( (parte * 100 + total / 2) / total ))
}
pct_entero 11 128                  # 9  (8.59 redondeado)

El + total / 2 antes de dividir es el truco estándar de redondeo con enteros: suma media unidad del resultado final.

Solución 2.

hora="${1:?falta la hora}"
if (( 10#$hora >= 8 && 10#$hora <= 19 )); then echo "Horario de reparto"
else echo "Fuera de horario"; fi

Sin el 10#, la entrada 08 provocaría valor demasiado grande para la base y el script moriría cada mañana a las ocho. Con él, funciona con 08, 8 y 19 igual de bien.

Solución 3.

readonly CSV=/srv/veloz/datos/envios.csv
SECONDS=0; suma=0; n=0
while IFS=, read -r _ _ _ _ _ importe; do
    (( suma += 10#${importe%.*} * 100 + 10#${importe#*.} )); (( ++n ))
done < <(tail -n +2 "$CSV")
printf 'Bash: %.2f€ en %d s\n' "$(bc -l <<< "scale=4; $suma/100/$n")" "$SECONDS"

SECONDS=0
printf 'awk:  %s€ en %d s\n' \
    "$(awk -F, 'NR>1 {s+=$6; n++} END {printf "%.2f", s/n}' "$CSV")" "$SECONDS"

Ambas dan 26.34€. Con unos pocos miles de líneas los dos tardan menos de un segundo, pero al crecer el fichero la diferencia se dispara a favor de awk: una sola pasada, un solo proceso, coma flotante nativa. Es la herramienta que estrenarás en 06-01.

Conclusión

Bash calcula con enteros de 64 bits y nada más: la división trunca, los decimales no se aceptan y por eso todo cálculo monetario se hace en céntimos y todo porcentaje multiplica antes de dividir. $(( )) produce el valor y (( )) actúa como comando con el código de salida invertido —cero aritmético significa fallo—, dentro de ambos las variables van sin $, y 10#$n te salva de que un 08 procedente de una fecha reviente el script. Cuando hacen falta decimales de verdad, bc -l con su scale resuelve el cálculo suelto y awk resuelve el fichero entero; printf %.2f pone el formato final. RANDOM, SECONDS y date -d completan el instrumental para muestrear, medir y comparar fechas.

Con esto se cierra el Módulo 4 y, sobre todo, se cierra la transformación de informe-diario.sh: lo que en el Módulo 3 era un guion lineal que había que invocar una vez por ciudad es hoy un programa con bucles, funciones documentadas, mapas de contadores, formato con printf, subcomandos despachados por case y estadísticas con decimales. Tiene estructura.

Lo que aún no tiene es robustez. Si el CSV está a medio escribir, si el disco se llena, si alguien lo lanza dos veces a la vez, el script hará cosas raras sin decir por qué. En el Módulo 5 damos ese salto: find, xargs, tar y ficheros temporales seguros con mktemp (05-01); gestión de procesos y señales (05-02); set -euo pipefail, trap y depuración con set -x (05-03); expresiones regulares de verdad con =~ (05-04); descriptores de fichero y here-documents (05-05); y por fin la librería lib/comun.sh que llevamos prometiendo desde 04-02, donde tus funciones dejarán de vivir dentro de un solo script para convertirse en el toolkit compartido de Veloz Envíos (05-06).

Curso de Programación en Bash

Módulo 1: Introducción a Bash

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados