Condiciones, bucles y códigos de salida en Bash

Lección 3 de 8: condiciones, bucles y códigos de salida en Bash. Un script casi nunca va en línea recta: comprueba si existe un archivo, repite una tarea con cada uno, lee un registro línea a línea o se detiene si algo falla. Esta lección cubre if y su relación con los códigos de salida, [ ] y [[ ]], && y ||, case, los bucles for, while y until, y cómo terminar con exit.

if: decidir según el código de salida

En la lección 1 viste que cada orden termina con un código de salida, 0 si fue bien. if se basa en eso: ejecuta una orden y, si su código es 0, entra en el bloque then; si no, en el else.

#!/usr/bin/env bash
if grep -q pan notas.txt; then
    echo "Hay que comprar pan"
else
    echo "No hace falta pan"
fi

if grep -q queso notas.txt; then
    echo "Hay que comprar queso"
elif grep -q Luis notas.txt; then
    echo "No hay queso, pero hay que llamar a Luis"
else
    echo "Nada que hacer"
fi
salidaHay que comprar pan
No hay queso, pero hay que llamar a Luis

La sintaxis es estricta: then va en la línea siguiente o detrás de un ;, elif es «si no, si», y el bloque se cierra con fi, if al revés. grep -q no escribe nada: solo busca y sale con 0 si encuentra el texto, que es justo lo que necesita if.

Cómo decide if: ejecuta la orden que va detrás. Si su código de salida es 0, ejecuta el bloque then; si es otro número, el bloque else. Los corchetes simples y dobles también son órdenes que comparan y salen con 0 si la comparación es verdadera y con 1 si es falsa, y por eso vale cualquier orden, como if grep menos q error app punto logif no evalúa una expresión: ejecuta una orden y mira su códigoif orden; thense ejecuta la orden¿código 0?mira $?síbloque thennobloque else[ ] y [[ ]] también son órdenes: comparan y salen con 0 (verdad) o 1 (mentira).Por eso sirve cualquier orden: if grep -q error app.log; then …

if no calcula verdadero o falso como en otros lenguajes: ejecuta una orden y mira su código de salida. Esquema: decodigo.com.

Comparar: test, [ ] y [[ ]]

Para comparar textos, números o comprobar archivos hay una orden, test, que también se escribe [. Sí: [ es el nombre de una orden, y por eso necesita espacios alrededor, como cualquier otra. Bash añade una versión mejorada, [[ ]], que es parte del lenguaje:

#!/usr/bin/env bash
nombre="Ana"
edad=17

if [[ "$nombre" == "Ana" ]]; then echo "1: se llama Ana"; fi
if [[ "$nombre" != "Luis" ]]; then echo "2: no es Luis"; fi
if [[ -z "$sin_definir" ]]; then echo "3: la variable está vacía"; fi
if [[ -n "$nombre" ]]; then echo "4: nombre tiene algo"; fi

if (( edad >= 18 )); then echo "5: mayor de edad"; else echo "5: menor de edad"; fi
if [[ $edad -lt 18 ]]; then echo "6: también con -lt"; fi

if [[ -f informe.txt ]]; then echo "7: informe.txt es un archivo"; fi
if [[ -d informe.txt ]]; then echo "8: es una carpeta"; else echo "8: no es una carpeta"; fi
if [[ -s vacio.txt ]]; then echo "9: vacio.txt tiene datos"; else echo "9: vacio.txt está vacío"; fi
if [[ ! -e no-existe.txt ]]; then echo "10: no-existe.txt no existe"; fi

if [[ "$nombre" == A* && $edad -gt 10 ]]; then echo "11: empieza por A y tiene más de 10"; fi
salida1: se llama Ana
2: no es Luis
3: la variable está vacía
4: nombre tiene algo
5: menor de edad
6: también con -lt
7: informe.txt es un archivo
8: no es una carpeta
9: vacio.txt está vacío
10: no-existe.txt no existe
11: empieza por A y tiene más de 10
  • Para…Se escribeEjemplo
  • Textos iguales o distintos==, !=[[ "$a" == "$b" ]]
  • Texto vacío o no vacío-z, -n[[ -z "$nombre" ]]
  • Números (dentro de [[ ]])-eq -ne -lt -le -gt -ge[[ $n -ge 10 ]]
  • Números, más legible(( )) con == != < <= > >=(( n >= 10 ))
  • Archivo, carpeta, existe-f, -d, -e[[ -d copias ]]
  • Archivo con datos, legible, escribible-s, -r, -w[[ -r config.ini ]]
  • Negar, y, o!, &&, ||[[ -f a && ! -f b ]]
  • Patrón con comodines== sin comillas a la derecha[[ $f == *.jpg ]]
  • Expresión regular=~[[ $cp =~ ^[0-9]{5}$ ]]

Dos detalles que confunden al principio. Para números, < y > solo funcionan dentro de (( )); dentro de [[ ]] comparan textos en orden alfabético, como verás en el reto 3. Y en [[ ]], lo que va a la derecha de == sin comillas es un patrón (A* es «empieza por A»); con comillas, un texto literal.

Expresiones regulares con =~

#!/usr/bin/env bash
for fecha in 2026-10-30 30/10/2026 2026-1-5; do
    if [[ $fecha =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})$ ]]; then
        echo "$fecha: año ${BASH_REMATCH[1]}, mes ${BASH_REMATCH[2]}, día ${BASH_REMATCH[3]}"
    else
        echo "$fecha: no tiene el formato AAAA-MM-DD"
    fi
done
salida2026-10-30: año 2026, mes 10, día 30
30/10/2026: no tiene el formato AAAA-MM-DD
2026-1-5: no tiene el formato AAAA-MM-DD

=~ compara con una expresión regular extendida, y lo que va entre paréntesis queda en el array BASH_REMATCH. La expresión va sin comillas: con comillas, Bash la trataría como texto literal. Las expresiones regulares se ven a fondo en la lección 6.

El error que te ahorra: los corchetes simples con una variable vacía

[ ] es la versión antigua, la que entiende cualquier shell POSIX, y tiene un problema: es una orden normal, así que sus argumentos pasan por la división en palabras. Con una variable vacía y sin comillas, la comparación se queda sin uno de sus lados:

#!/usr/bin/env bash
respuesta=""
if [ $respuesta = si ]; then
    echo "Confirmado"
else
    echo "No confirmado"
fi
salida./corchete_vacio.sh: line 3: [: =: unary operator expected
No confirmado

Bash vio [ = si ], que no tiene sentido, se quejó y, como [ falló, siguió por el else, así que el script parece funcionar. ShellCheck lo ve antes:

shellcheckIn corchete_vacio.sh line 3:
if [ $respuesta = si ]; then
     ^--------^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
if [ "$respuesta" = si ]; then

For more information:
  https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...

Hay dos arreglos: comillas, [ "$respuesta" = si ], o [[ $respuesta == si ]], que no divide en palabras. En scripts de Bash, usa siempre [[ ]]; [ ] solo hace falta en scripts para sh.

&& y ||: condiciones en una línea

orden1 && orden2 ejecuta la segunda solo si la primera fue bien, y orden1 || orden2, solo si falló. Son muy habituales para cosas cortas:

#!/usr/bin/env bash
mkdir -p copias && echo "Carpeta lista"
[[ -f config.ini ]] || echo "Aviso: no hay config.ini, uso los valores por omisión"
cd /no/existe 2>/dev/null || { echo "No pude entrar en la carpeta" >&2; exit 1; }
echo "Esta línea no se llega a ejecutar"
salida con errorCarpeta lista
Aviso: no hay config.ini, uso los valores por omisión
No pude entrar en la carpeta
código de salida: 1

La tercera línea es un patrón que verás en muchos scripts: si cd falla, se escribe un mensaje en la salida de errores (>&2, que se explica en la lección 5) y se termina con exit 1. Las llaves agrupan las dos órdenes, y el ; antes de la llave de cierre es obligatorio. Seguir trabajando después de un cd fallido, en otra carpeta, es de los errores más peligrosos de un script.

Cuidado con a && b || c. Parece un «si a entonces b, si no c», pero c también se ejecuta si a fue bien y b falló. Para un si-entonces-si-no de verdad, usa if. ShellCheck lo avisa con SC2015.

case: elegir entre varias opciones

#!/usr/bin/env bash
for archivo in foto.JPG datos.csv notas.txt copia.tar.gz script.sh; do
    case "$archivo" in
        *.jpg|*.JPG|*.png)  tipo="imagen" ;;
        *.csv|*.txt)        tipo="texto" ;;
        *.tar.gz|*.zip)     tipo="comprimido" ;;
        *)                  tipo="otro" ;;
    esac
    printf '%-14s %s\n' "$archivo" "$tipo"
done
salidafoto.JPG       imagen
datos.csv      texto
notas.txt      texto
copia.tar.gz   comprimido
script.sh      otro

Cada opción es un patrón con comodines, terminado en ); | separa alternativas, ;; cierra la opción y *) recoge todo lo demás. Se elige la primera que encaja y se cierra con esac. case es la forma más clara de validar lo que llega a un script, como verás en el recuadro de seguridad.

for: repetir con una lista

#!/usr/bin/env bash
for color in rojo verde azul; do
    echo "Color: $color"
done

for n in {1..5}; do printf '%s ' "$n"; done; echo
for n in {0..20..5}; do printf '%s ' "$n"; done; echo

for (( i = 3; i > 0; i-- )); do printf '%s... ' "$i"; done; echo "¡ya!"

for registro in *.log; do
    lineas=$(wc -l < "$registro")
    echo "$registro, líneas: $lineas"
done
salidaColor: rojo
Color: verde
Color: azul
1 2 3 4 5 
0 5 10 15 20 
3... 2... 1... ¡ya!
abril.log, líneas: 4
enero.log, líneas: 2
febrero.log, líneas: 1
marzo.log, líneas: 3

for variable in lista repite el bloque una vez por elemento. La lista puede ser literal, un rango con llaves ({1..5}, o {0..20..5} de 5 en 5), o un comodín como *.log, que Bash expande a los nombres de archivo, con sus espacios intactos. También existe el for al estilo de C, con (( )). Fíjate en que los archivos salen en orden alfabético, abril antes que enero, y no en el de los meses.

No recorras líneas con for linea in $(cat archivo). Sin comillas, $( ) se parte por los espacios, no por las líneas, y además expande los comodines que haya en el texto. Para leer un archivo línea a línea está while read, justo debajo.

Un comodín que no encuentra nada

#!/usr/bin/env bash
for f in *.pdf; do
    echo "Encontrado: $f"
done

shopt -s nullglob
for f in *.pdf; do
    echo "Con nullglob: $f"
done
echo "Fin"
salidaEncontrado: *.pdf
Fin

Si no hay ningún .pdf, Bash deja el patrón tal cual y el bucle se ejecuta una vez con el texto *.pdf, que no es ningún archivo. shopt -s nullglob hace que un comodín sin resultados desaparezca, y el bucle no se ejecuta.

while y until: repetir mientras algo se cumpla

#!/usr/bin/env bash
intentos=0
while (( intentos < 3 )); do
    intentos=$(( intentos + 1 ))
    echo "Intento $intentos"
done

n=1
until (( n > 100 )); do
    n=$(( n * 2 ))
done
echo "La primera potencia de 2 mayor que 100 es $n"

num=0
while IFS= read -r producto || [[ -n "$producto" ]]; do
    num=$(( num + 1 ))
    echo "$num: [$producto]"
done < compras.txt
salidaIntento 1
Intento 2
Intento 3
La primera potencia de 2 mayor que 100 es 128
1: [pan]
2: [leche con avena]
3: [  huevos]
4: [manzanas]

while repite mientras la orden salga con 0, y until, mientras salga con otro número. La última forma es la manera correcta de leer un archivo línea a línea, y cada pieza tiene su motivo: IFS= conserva los espacios del principio, -r respeta las barras invertidas, < compras.txt manda el archivo a la entrada del bucle y || [[ -n "$producto" ]] recoge la última línea aunque no termine en salto de línea, como pasa con muchos archivos escritos a mano. Hay más casos en Leer un archivo línea a línea.

break y continue

#!/usr/bin/env bash
for n in {1..10}; do
    if (( n % 2 == 0 )); then
        continue
    fi
    if (( n > 7 )); then
        break
    fi
    printf '%s ' "$n"
done
echo
salida1 3 5 7 

continue salta a la siguiente vuelta y break sale del bucle. Los dos aceptan un número para afectar a bucles anidados: break 2 sale de dos niveles.

exit: terminar con un código

Un script bien hecho comprueba lo que recibe y, si algo no cuadra, termina con un mensaje en la salida de errores y un código distinto de 0, para que quien lo llame, sea una persona, otro script o cron, sepa que falló:

#!/usr/bin/env bash
if (( $# != 1 )); then
    echo "Uso: $0 NOMBRE" >&2
    exit 2
fi
echo "Hola, $1"
terminal$ chmod +x saludo.sh
$ ./saludo.sh Ana; echo "código: $?"
Hola, Ana
código: 0
$ ./saludo.sh; echo "código: $?"
Uso: ./saludo.sh NOMBRE
código: 2
$ ./saludo.sh Ana && echo "Todo bien"
Hola, Ana
Todo bien

$# es el número de argumentos y $1, el primero (en la lección 4 están todos). Por convención, exit 0 es éxito, exit 1 un error general y exit 2 un mal uso. Si no escribes exit, el script devuelve el código de su última orden.

Ojo con la seguridad: valida lo que recibe tu script con una lista blanca

Un script que usa un argumento para formar una ruta, un nombre de usuario o una orden debe comprobar antes que el valor es exactamente lo que espera. Lo más seguro es una lista blanca: aceptar solo los caracteres permitidos y rechazar todo lo demás, en vez de intentar adivinar qué es peligroso.

#!/usr/bin/env bash
mostrar_informe() {
    local nombre=$1
    if [[ ! $nombre =~ ^[a-z0-9_-]{1,32}$ ]]; then
        echo "Nombre de informe no válido: $nombre" >&2
        return 1
    fi
    echo "Abriría informes/$nombre.txt"
}

for pedido in ventas_2026 "../../etc/passwd" "ventas; rm -rf ~" ""; do
    mostrar_informe "$pedido" || echo "    (rechazado, sigo con el siguiente)"
done
salidaAbriría informes/ventas_2026.txt
Nombre de informe no válido: ../../etc/passwd
    (rechazado, sigo con el siguiente)
Nombre de informe no válido: ventas; rm -rf ~
    (rechazado, sigo con el siguiente)
Nombre de informe no válido: 
    (rechazado, sigo con el siguiente)

La expresión regular solo deja pasar minúsculas, cifras, guion y guion bajo, de 1 a 32 caracteres, así que los intentos de salir de la carpeta (../) o de colar otra orden se quedan fuera sin necesidad de preverlos uno a uno. Las funciones y local se ven en la lección siguiente.

Más en Los fallos de seguridad típicos de los scripts de Bash.

Retos

Reto 1. Escribe el clásico FizzBuzz del 1 al 15: para cada número, escribe «Fizz» si es múltiplo de 3, «Buzz» si es múltiplo de 5, «FizzBuzz» si lo es de los dos, y el número en cualquier otro caso.

Ver la solución
#!/usr/bin/env bash
for n in {1..15}; do
    if (( n % 15 == 0 )); then
        echo "FizzBuzz"
    elif (( n % 3 == 0 )); then
        echo "Fizz"
    elif (( n % 5 == 0 )); then
        echo "Buzz"
    else
        echo "$n"
    fi
done
salida1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz

El orden importa: hay que preguntar primero por 15, porque si no, el 15 se quedaría en «Fizz». (( )) devuelve 0 cuando la cuenta es verdadera, así que encaja directamente con if.

Reto 2. Cuenta cuántos archivos de la carpeta actual son imágenes (.png o .jpg), cuántos registros (.log) y cuántos otra cosa, con un for y un case.

Ver la solución
#!/usr/bin/env bash
shopt -s nullglob
imagenes=0 registros=0 otros=0
for f in *; do
    [[ -f $f ]] || continue
    case "$f" in
        *.png|*.jpg) imagenes=$(( imagenes + 1 )) ;;
        *.log)       registros=$(( registros + 1 )) ;;
        *)           otros=$(( otros + 1 )) ;;
    esac
done
echo "Imágenes: $imagenes, registros: $registros, otros: $otros"
salidaImágenes: 2, registros: 2, otros: 2

El script se cuenta a sí mismo entre los «otros», porque también está en la carpeta. [[ -f $f ]] || continue salta las carpetas.

Reto 3. ¿Qué escribe este script? Piénsalo antes de mirar: if [[ 10 < 9 ]]; then echo "10 es menor"; else echo "9 es menor"; fi.

Ver la solución
#!/usr/bin/env bash
if [[ 10 < 9 ]]; then
    echo "10 es menor"
else
    echo "9 es menor"
fi
salida10 es menor

Escribe «10 es menor». Dentro de [[ ]], < compara textos en orden alfabético, y «1» va antes que «9», igual que «abeja» va antes que «zorro». Para números hay que usar -lt o (( 10 < 9 )). ShellCheck lo detecta:

shellcheckIn reto3.sh line 2:
if [[ 10 < 9 ]]; then
         ^-- SC2071 (error): < is for string comparisons. Use -lt instead.
         ^-- SC2050 (warning): This expression is constant. Did you forget the $ on a variable?

For more information:
  https://www.shellcheck.net/wiki/SC2071 -- < is for string comparisons. Use ...
  https://www.shellcheck.net/wiki/SC2050 -- This expression is constant. Did ...

Todos los scripts de esta lección se ejecutaron con Bash 5.3 en Ubuntu 26.04, en un contenedor limpio sin acceso a la red, y se revisaron con ShellCheck 0.11 antes de publicarla; las salidas están copiadas de esa ejecución. ¿Algo se comporta distinto? Mira tu versión con bash --version: macOS trae de serie Bash 3.2, y en Windows hay que usar WSL o Git Bash.