Rarezas de Bash: lo que la terminal hace sin avisar

Rarezas de Bash: veinte comportamientos que sorprenden incluso a quien lleva años usando la terminal. Espacios que convierten una asignación en una orden, variables que se pierden al salir de una tubería, set -e que no salta, números que de pronto son octales y comodines que no desaparecen, todo comprobado con Bash 5.3 y explicado con lo que ShellCheck habría avisado.

Bash arrastra casi cincuenta años de compatibilidad con el Bourne shell, y muchas de sus reglas se decidieron cuando una terminal era una máquina de escribir conectada a un ordenador del tamaño de un armario. El resultado es un lenguaje que hace cosas sorprendentes sin dar ningún error. Estas son las rarezas que más tropiezos causan, todas ejecutadas con Bash 5.3. Casi todas tienen algo en común: ShellCheck las habría detectado.

Variables y comillas

1. Un espacio alrededor del = lo cambia todo

#!/usr/bin/env bash
nombre = "Ana"
echo "Hola, $nombre"
salida./asignacion.sh: line 2: nombre: command not found
Hola, 

ShellCheck lo habría avisado antes de ejecutar nada:

shellcheckIn asignacion.sh line 2:
nombre = "Ana"
       ^-- SC2283 (error): Remove spaces around = to assign (or use [ ] to compare, or quote '=' if literal).

En casi cualquier lenguaje, los espacios alrededor del = dan igual. En Bash, nombre = "Ana" significa «ejecuta la orden nombre con los argumentos = y Ana». Como no existe esa orden, falla, y la variable se queda vacía. Las asignaciones van siempre pegadas: nombre="Ana".

2. Sin comillas, los espacios desaparecen

#!/usr/bin/env bash
mensaje="hola      mundo"
echo $mensaje
echo "$mensaje"
salidahola mundo
hola      mundo

ShellCheck lo habría avisado antes de ejecutar nada:

shellcheckIn espacios.sh line 3:
echo $mensaje
     ^------^ SC2086 (info): Double quote to prevent globbing and word splitting.

Sin comillas, Bash parte el valor de la variable en palabras por los espacios (word splitting) y se las pasa a echo como argumentos separados, que echo une con un solo espacio. Con un mensaje no pasa nada grave; con un nombre de archivo como Mis documentos, el script opera sobre dos archivos que no existen. La regla de oro del curso: pon siempre comillas dobles a las variables.

3. [ es una orden, y por eso necesita espacios

#!/usr/bin/env bash
type [
color="rojo"
if [ "$color" = "rojo"]; then
    echo "Es rojo"
fi
echo "fin"
salida[ is a shell builtin
./corchete.sh: line 4: [: missing `]'
fin

[ no es sintaxis del lenguaje, sino una orden (un builtin, y también un programa en /usr/bin/[) que recibe argumentos y exige que el último sea ]. Sin el espacio, el último argumento es rojo] y la orden protesta. La condición se toma como falsa y el script sigue como si nada.

4. [ con una variable vacía y sin comillas

#!/usr/bin/env bash
respuesta=""
if [ $respuesta = "si" ]; then echo "dijo que sí"; fi
if [[ $respuesta = "si" ]]; then echo "dijo que sí"; else echo "no dijo nada"; fi
salida./vacia.sh: line 3: [: =: unary operator expected
no dijo nada

ShellCheck lo habría avisado antes de ejecutar nada:

shellcheckIn vacia.sh line 3:
if [ $respuesta = "si" ]; then echo "dijo que sí"; fi
     ^--------^ SC2086 (info): Double quote to prevent globbing and word splitting.

Con la variable vacía y sin comillas, [ recibe solo = si ], que no tiene sentido. [[ ]] es sintaxis de Bash, no una orden, y no parte las variables en palabras: por eso funciona. En Bash, usa [[ ]]; si tienes que usar [ (scripts POSIX), pon comillas.

5. Dentro de [[ ]], lo que va sin comillas es un patrón

#!/usr/bin/env bash
archivo="informe.pdf"
buscado="*.pdf"
[[ $archivo == $buscado ]] && echo "sin comillas: coincide"
[[ $archivo == "$buscado" ]] || echo "con comillas: no coincide"
salidasin comillas: coincide
con comillas: no coincide

ShellCheck lo habría avisado antes de ejecutar nada:

shellcheckIn patron.sh line 4:
[[ $archivo == $buscado ]] && echo "sin comillas: coincide"
               ^------^ SC2053 (warning): Quote the right-hand side of == in [[ ]] to prevent glob matching.

A la derecha de == dentro de [[ ]], una variable sin comillas se interpreta como un patrón de comodines, y *.pdf casa con cualquier PDF. Con comillas se compara el texto literal. Es justo al revés de lo que dice la intuición, y es el origen de comprobaciones que dejan pasar cualquier cosa.

6. La tilde entre comillas no es tu carpeta

#!/usr/bin/env bash
echo ~
echo "~"
echo "$HOME"
salida/home/ana
~
/home/ana

~ solo se convierte en tu carpeta personal si va sin comillas y al principio de una palabra. Entre comillas es un carácter más, y cd "~/proyectos" busca una carpeta que se llama literalmente ~. Dentro de comillas, usa "$HOME/proyectos".

7. $( ) se come todos los saltos de línea del final

#!/usr/bin/env bash
texto=$(printf 'hola\n\n\n')
echo "Longitud: ${#texto}"
printf '[%s]\n' "$texto"
salidaLongitud: 4
[hola]

La sustitución de órdenes quita todos los saltos de línea del final, no solo uno. Casi siempre es lo que quieres, pero si el contenido importa byte a byte (una firma, un archivo que debe terminar en línea vacía), se pierde información.

8. read sin -r se come las barras invertidas

#!/usr/bin/env bash
printf 'C:\\temp\\nuevo\n' | { read ruta; echo "sin -r: $ruta"; }
printf 'C:\\temp\\nuevo\n' | { read -r ruta; echo "con -r: $ruta"; }
salidasin -r: C:tempnuevo
con -r: C:\temp\nuevo

ShellCheck lo habría avisado antes de ejecutar nada:

shellcheckIn read_r.sh line 2:
printf 'C:\\temp\\nuevo\n' | { read ruta; echo "sin -r: $ruta"; }
                               ^--^ SC2162 (info): read without -r will mangle backslashes.

Sin -r, read trata la barra invertida como carácter de escape y la quita. Por eso todos los bucles de lectura del curso son while IFS= read -r linea: -r respeta las barras y IFS= conserva los espacios del principio y del final.

9. «$@» y $* no son lo mismo

#!/usr/bin/env bash
contar() { echo "$# argumentos"; }
mostrar() {
    contar $*
    contar "$*"
    contar "$@"
}
mostrar "Ana María" "José"
salida3 argumentos
1 argumentos
2 argumentos

ShellCheck lo habría avisado antes de ejecutar nada:

shellcheckIn arroba.sh line 4:
    contar $*
           ^-- SC2048 (warning): Use "$@" (with quotes) to prevent whitespace problems.
           ^-- SC2086 (info): Double quote to prevent globbing and word splitting.

Con dos argumentos, uno de ellos con espacio: $* sin comillas los parte en tres palabras; "$*" los junta en uno; y "$@" los conserva tal cual, dos. Para pasar los argumentos a otra orden, siempre "$@".

Tuberías y subshells

10. La variable que se pierde al salir de una tubería

#!/usr/bin/env bash
total=0
printf '%s\n' 10 20 30 | while read -r n; do
    total=$(( total + n ))
done
echo "Total con tubería: $total"

total=0
while read -r n; do
    total=$(( total + n ))
done < <(printf '%s\n' 10 20 30)
echo "Total con < <( ): $total"
salidaTotal con tubería: 0
Total con < <( ): 60

ShellCheck lo habría avisado antes de ejecutar nada:

shellcheckIn tuberia.sh line 4:
    total=$(( total + n ))
    ^---^ SC2030 (info): Modification of total is local (to subshell caused by pipeline).

Cada parte de una tubería se ejecuta en una subshell, una copia de la shell que desaparece al terminar. El while suma bien, pero sobre su copia de total. La solución es no usar tubería: redirigir la entrada desde una sustitución de procesos <( ) o desde un archivo, o activar shopt -s lastpipe (lo cuenta la página de versiones).

11. cd dentro de paréntesis no te mueve

#!/usr/bin/env bash
cd /home/ana || exit
( cd /tmp && echo "Dentro: $PWD" )
echo "Fuera: $PWD"
{ cd /tmp && echo "Con llaves: $PWD"; }
echo "Después: $PWD"
salidaDentro: /tmp
Fuera: /home/ana
Con llaves: /tmp
Después: /tmp

Los paréntesis crean una subshell: lo que pase dentro (un cd, una variable, un set -e) no sale. Las llaves agrupan sin crear subshell. Es útil a propósito: ( cd carpeta && make ) compila sin cambiar de carpeta al resto del script.

12. local x=$(orden) oculta el error

#!/usr/bin/env bash
leer_config() {
    local contenido=$(cat /no/existe.conf 2>/dev/null)
    echo "código tras local: $?"
    local otro
    otro=$(cat /no/existe.conf 2>/dev/null)
    echo "código separado: $?"
}
leer_config
salidacódigo tras local: 0
código separado: 1

ShellCheck lo habría avisado antes de ejecutar nada:

shellcheckIn local.sh line 3:
    local contenido=$(cat /no/existe.conf 2>/dev/null)
          ^-------^ SC2034 (warning): contenido appears unused. Verify use (or export if used externally).
          ^-------^ SC2155 (warning): Declare and assign separately to avoid masking return values.

$? es el código de la última orden, y en la primera línea la última orden es local, que siempre funciona. El fallo de cat se pierde, y con set -e tampoco detendría el script. Declara la variable en una línea y asígnala en otra.

Errores que no detienen el script

13. set -e no salta donde crees

#!/usr/bin/env bash
set -e
comprobar() {
    false
    echo "  sigo dentro de comprobar, aunque false falló"
}
if comprobar; then echo "comprobar «funcionó»"; fi
false || echo "con || tampoco se detiene"
comprobar
echo "esta línea ya no se ejecuta"
salida con error  sigo dentro de comprobar, aunque false falló
comprobar «funcionó»
con || tampoco se detiene

set -e detiene el script cuando una orden falla… salvo si esa orden forma parte de una condición: un if, un while, un lado de && o ||. Y la excepción se hereda: dentro de una función llamada desde un if, set -e queda desactivado para todo su cuerpo. Solo en la última llamada, fuera de cualquier condición, el script se detiene. set -e ayuda, pero no sustituye a comprobar los errores (lo vemos en Scripts robustos).

14. ((i++)) con i en cero mata el script

#!/usr/bin/env bash
set -e
i=0
echo "Antes de incrementar"
((i++))
echo "Después: i = $i"
salida con errorAntes de incrementar
código de salida: 1

(( )) devuelve código 1 cuando la expresión vale cero, como en C. Y i++ devuelve el valor de antes de incrementar: 0. Así que ((i++)) «falla» la primera vez y set -e detiene el script, aunque i sí se incrementó. Usa i=$((i + 1)) o ((++i)), que devuelve el valor nuevo.

15. 0 es verdadero y [ false ] también

#!/usr/bin/env bash
true; echo "true devuelve $?"
false; echo "false devuelve $?"
if [ false ]; then echo "[ false ] es verdadero"; fi
if [ 0 ]; then echo "[ 0 ] también"; fi
if false; then echo "no se ve"; else echo "false, sin corchetes, es falso"; fi
salidatrue devuelve 0
false devuelve 1
[ false ] es verdadero
[ 0 ] también
false, sin corchetes, es falso

ShellCheck lo habría avisado antes de ejecutar nada:

shellcheckIn verdad.sh line 4:
if [ false ]; then echo "[ false ] es verdadero"; fi
     ^---^ SC2158 (error): [ false ] is true. Remove the brackets.

En la shell, el código de salida 0 significa éxito y cualquier otro, fallo: justo al revés que en la aritmética. Y [ texto ] solo comprueba si el texto no está vacío: false y 0 son textos no vacíos, así que son verdaderos. Para usar true y false como órdenes, sin corchetes.

Números

16. Un cero delante convierte el número en octal

#!/usr/bin/env bash
echo $(( 010 ))
mes="08"
echo $(( mes + 1 ))
echo "después del error"
salida8
./octal.sh: line 4: 08: value too great for base (error token is "08")
después del error

En aritmética, un número que empieza por cero está en base 8, como en C: 010 vale 8, y 08 o 09 son errores porque esos dígitos no existen en octal. Pasa a menudo con meses, días y horas que vienen de date. La solución es forzar la base 10: $(( 10#$mes + 1 )).

#!/usr/bin/env bash
mes="08"
echo $(( 10#$mes + 1 ))
salida9

17. La división entera no redondea, trunca

#!/usr/bin/env bash
echo $(( 7 / 2 ))
echo $(( -7 / 2 ))
echo $(( 7 % -3 ))
echo "scale=2; 7 / 2" | bc
salida3
-3
1
3.50

La aritmética de Bash solo trabaja con enteros de 64 bits: 7 / 2 es 3, y con negativos trunca hacia cero (-3, no -4). Para decimales hay que llamar a bc o a awk. Bash 5.3 trae además un builtin cargable, fltexpr, para aritmética con decimales, aunque no viene activado por omisión.

18. $RANDOM se puede repetir

#!/usr/bin/env bash
RANDOM=42
echo "$RANDOM $RANDOM $RANDOM"
RANDOM=42
echo "$RANDOM $RANDOM $RANDOM"
salida17772 26794 1435
17772 26794 1435

Asignar un valor a RANDOM fija la semilla, y a partir de ahí la secuencia es siempre la misma: útil para pruebas reproducibles, terrible para cualquier cosa que deba ser impredecible, como una contraseña o un nombre de archivo temporal. Para eso están $SRANDOM (Bash 5.1 y posteriores), mktemp y /dev/urandom.

Comodines y alias

19. Un comodín que no encuentra nada se queda tal cual

#!/usr/bin/env bash
for f in *.pdf; do
    echo "Procesando: $f"
done
shopt -s nullglob
for f in *.pdf; do
    echo "Con nullglob: $f"
done
echo "fin"
salidaProcesando: *.pdf
fin

En la carpeta solo hay un .txt. Si un comodín no casa con ningún archivo, Bash lo deja como texto, y el bucle se ejecuta una vez con el valor literal *.pdf. Con shopt -s nullglob, un comodín sin resultados desaparece y el bucle no se ejecuta. Otra opción es comprobar dentro del bucle que el archivo existe: [[ -e $f ]] || continue.

20. Los alias no funcionan en los scripts

#!/usr/bin/env bash
alias saluda='echo hola'
saluda
shopt -s expand_aliases
alias saluda='echo hola'
saluda
salida./alias.sh: line 3: saluda: command not found
hola

Los alias son para la terminal interactiva: en un script no se expanden, salvo que actives expand_aliases, e incluso entonces solo funcionan a partir de la línea siguiente a su definición. En un script, usa funciones, que funcionan siempre y aceptan argumentos.

Lo que hay detrás

Casi todas estas rarezas salen de dos ideas: Bash es ante todo un intérprete de órdenes, así que todo (hasta [) es una orden con argumentos separados por espacios; y antes de ejecutar cada orden, Bash expande la línea en varias fases (llaves, tilde, variables, sustituciones, aritmética, división en palabras y comodines) en un orden fijo. Las comillas son la forma de decidir qué fases se aplican. Con eso en la cabeza y ShellCheck a mano, la mayoría de las sorpresas dejan de serlo. Y si quieres saber cuánto has entendido, prueba el test de Bash.

Todos los scripts de esta página se ejecutaron con Bash 5.3 (5.3.9) en Ubuntu 26.04, en un contenedor limpio sin red; las salidas y los avisos de ShellCheck 0.11 están copiados de esa ejecución.