Módulo 3: Tuberías, redirección y composición
7. Códigos de salida y encadenamiento: $?, &&, || y ;
Descripción
Al terminar esta lección vas a poder leer el número que cualquier comando deja al terminar,
decidir con ese número si el siguiente paso de una cadena debe ejecutarse o no, y elegir entre
;, && y || según lo que en verdad quieres que pase cuando algo falla — en vez de asumir
que todo salió bien porque la terminal no mostró nada en rojo.
Esta es la habilidad que separa un one-liner de una automatización real. Un script de
despliegue que hace git pull; npm run build; systemctl restart myapp sin revisar nada entre
paso y paso reinicia el servicio aunque el build anterior haya fallado a mitad de camino. La
misma cadena escrita con && se detiene en el primer fallo. La diferencia entre esas dos
líneas —un carácter— es la diferencia entre un despliegue seguro y una madrugada arreglando
producción.
Conexión con el módulo: ya sabes construir tuberías que cuentan, ordenan y transforman texto (lección anterior). Ahora vas a aprender a preguntarle a cada eslabón de esa tubería, o a cualquier comando, "¿de verdad funcionaste?", y a reaccionar según la respuesta. Es la pieza que te falta antes del proyecto de este módulo, y la base de todo lo que vas a scriptear en el módulo 5.
El modelo mental: todo comando entrega un recibo, no solo un resultado
Cuando sacas dinero de un cajero automático, la pantalla te muestra los billetes, pero también te entrega, siempre, un recibo: aprobado, fondos insuficientes, tarjeta rechazada. Casi nunca lo lees porque casi siempre dice "aprobado". Pero está ahí, con un código específico, cada vez que usas el cajero.
Todo comando que corres en la terminal hace lo mismo: además de lo que imprime en pantalla (o de no imprimir nada), le entrega al shell un número entre 0 y 255 al terminar. Ese número es su código de salida (exit code o exit status). Por convención universal en Unix, 0 significa éxito y cualquier valor distinto de 0 significa que algo falló, y con frecuencia el número específico te dice qué falló.
El shell guarda siempre el código de salida del último comando en la variable $?. La
consultas con echo $? inmediatamente después del comando que te interesa: cualquier otra
cosa que corras en el medio, incluido otro echo, pisa ese valor con el suyo propio.
Ejemplo trabajado
grep es un buen punto de partida porque distingue, con precisión, tres situaciones distintas
usando tres códigos:
grep "localhost" /etc/hosts
echo $?
Qué esperar:
127.0.0.1 localhost
::1 localhost
0
Encontró coincidencias: código 0. Ahora una búsqueda que no encuentra nada:
grep "not-present-in-this-file" /etc/hosts
echo $?
Qué esperar: ninguna línea impresa por grep —no hay coincidencias— y luego:
1
No es un error de verdad: grep hizo su trabajo perfectamente, buscó y no encontró. El código
1 es su forma de decírtelo. Ahora un error real, un archivo que no existe:
grep "localhost" /no/such/file.log
echo $?
Qué esperar:
grep: /no/such/file.log: No such file or directory
2
Tres preguntas, tres respuestas distintas: 0 (encontré), 1 (busqué y no había) y 2 (ni
siquiera pude buscar). Ningún mensaje en pantalla te da esa distinción con la misma precisión
que $?.
; — encadena sin hacer preguntas
; corre un comando después de otro sin importar cómo terminó el primero. Es, literalmente,
"haz esto, y luego esto otro, pase lo que pase".
mkdir project; cd project; git init
Si mkdir falla porque el directorio ya existe, el shell igual intenta cd project, y
git init corre ahí de todos modos. Cuando los pasos son independientes, o el fallo de uno
no cambia lo que quieres del siguiente, ; es exactamente lo que necesitas.
El problema aparece cuando los pasos sí dependen uno del otro, y usar ; en vez de &&
puede destruir datos:
cd /var/www/old-project-v1; rm -rf *
Qué esperar si el directorio no existe (typo, ya se borró, permisos distintos a los que creías): algo como
cd: /var/www/old-project-v1: No such file or directory
Y después de ese mensaje, rm -rf * igual se ejecuta, porque ; no revisa si cd
funcionó. Solo que ya no estás en /var/www/old-project-v1: sigues en el directorio donde
estaba parada tu terminal antes de correr la línea. Si esa era tu carpeta de trabajo, tu
home, o cualquier lugar con archivos que te importan, rm -rf * los borra a todos, sin
preguntar y sin papelera. Este es, literalmente, uno de los patrones de error de terminal más
documentados y más caros de la historia de Unix.
La corrección es una palabra de dos caracteres:
cd /var/www/old-project-v1 && rm -rf *
&& — solo si el anterior funcionó
&& corre el segundo comando únicamente si el primero terminó con código 0. En el
ejemplo de arriba: si cd falla (código distinto de 0), rm -rf * nunca se ejecuta. El
shell corta la cadena en el primer eslabón roto.
cd /var/www/old-project-v1 && rm -rf *
Qué esperar si el directorio no existe: el mismo mensaje de error de cd de antes, y ahí
termina. Ningún archivo se toca, porque rm nunca llegó a correr.
&& es también la forma correcta de encadenar una automatización de varios pasos donde cada
uno depende del anterior:
git pull && npm run build && systemctl restart myapp
Qué esperar si git pull falla (sin conexión, conflicto de merge): ni npm run build ni
systemctl restart se ejecutan. El servicio sigue corriendo con el código que ya tenía, en
vez de reiniciarse con una build incompleta o inexistente.
|| — solo si el anterior falló
|| es el espejo de &&: corre el segundo comando únicamente si el primero terminó con un
código distinto de 0. Es la herramienta para reaccionar a un fallo: un plan B, una alerta,
un mensaje de diagnóstico.
tar -czf backup.tar.gz /data || echo "ERROR: backup failed" >> alert.log
Qué esperar si tar funciona: nada se agrega a alert.log; el echo nunca corre, porque
tar terminó con código 0.
Qué esperar si tar falla (disco lleno, permisos, ruta inexistente): la línea de error
queda registrada en alert.log, justo en el momento en que ocurrió el problema, en vez de que
lo descubras horas después por casualidad.
Agrupar con () y { }
A veces necesitas que varios comandos cuenten como una sola unidad frente a un &&, un
||, o una redirección. Bash y zsh te dan dos formas de agrupar, y no son intercambiables.
() agrupa en una subshell: los comandos corren en un proceso hijo aparte. Cualquier cd
o variable que cambies ahí adentro no afecta a tu shell actual una vez que el grupo
termina.
pwd
(cd /tmp && pwd)
pwd
Qué esperar:
/Users/your-user
/tmp
/Users/your-user
Entraste a /tmp solo dentro del paréntesis; tu shell real nunca se movió de donde estaba.
Es útil cuando quieres "ir, hacer algo, y volver" sin acordarte de escribir un cd de
regreso.
{ } agrupa en tu shell actual: sin crear un proceso nuevo, así que un cd o una
variable definida adentro sí persiste afuera. La sintaxis exige un espacio después de {
y un ; antes de }; sin eso, el shell no reconoce dónde termina el bloque.
pwd
{ cd /tmp && pwd; }
pwd
Qué esperar:
/Users/your-user
/tmp
/tmp
Esta vez el cd sí quedó: tu shell terminó parado en /tmp.
Ambas formas comparten algo útil: el código de salida del grupo es el del último comando que
realmente se ejecutó adentro, así que puedes encadenar un && o un || después del grupo
entero:
(mkdir build && cd build && make) || echo "build failed at some step"
Si mkdir falla, cd y make nunca corren, el grupo termina con el código de mkdir
(distinto de 0), y el echo de afuera se dispara.
Códigos de salida comunes: la tabla que necesitas
No hay un estándar universal que le asigne un significado fijo a cada número: cada programa
decide qué código devuelve en cada situación (ya viste que grep usa 2 para "ni pude
buscar", pero otros programas usan 2 para otra cosa). Sin embargo, el shell y el sistema
operativo sí reservan un puñado de códigos con un significado consistente, sin importar qué
programa los produzca:
| Código | Significado |
|---|---|
0 | Éxito. El comando hizo lo que tenía que hacer. |
1 | Fallo general. El significado exacto depende del programa (para grep, "no hubo coincidencias"; para muchos otros, un error genérico). |
2 | Uso incorrecto del comando, o un error antes de siquiera empezar (argumento inválido, archivo inexistente). |
126 | El comando se encontró, pero no se pudo ejecutar; típicamente le falta el permiso de ejecución. |
127 | El comando no se encontró: un typo, algo no instalado, o algo fuera del PATH. |
130 | El proceso terminó porque le mandaste Ctrl+C (señal SIGINT). |
Los últimos tres se pueden reproducir de memoria:
printf '#!/bin/bash\necho "done"\n' > deploy.sh
chmod -x deploy.sh
./deploy.sh
echo $?
Qué esperar (en zsh, el shell por defecto de macOS):
zsh: permission denied: ./deploy.sh
126
(En bash el mensaje dice bash: ./deploy.sh: Permission denied; el número es el mismo.)
notarealcommand123
echo $?
Qué esperar:
zsh: command not found: notarealcommand123
127
Y 130 puedes verlo tú mismo ahora, en tu propia terminal: no se puede capturar en un
comando de una sola línea porque necesita que interrumpas algo en marcha.
sleep 20
Presiona Ctrl+C antes de que termine, y luego corre echo $?. Qué esperar: 130. No es
arbitrario: la convención es 128 + número de la señal, y SIGINT (la señal que manda
Ctrl+C) es la señal número 2 → 128 + 2 = 130.
El hábito profesional: verificar en vez de suponer
La diferencia entre quien automatiza con confianza y quien automatiza con miedo no es cuánta
sintaxis se sabe de memoria. Es un hábito: antes de encadenar un paso destructivo o
irreversible al resultado de otro comando, pregúntate qué código de salida esperas, y
enlázalos con &&, nunca con ;.
Un pipeline de CI/CD que hace run_tests.sh; deploy.sh despliega aunque los tests hayan
fallado. Un script de limpieza que hace cd $TARGET_DIR; rm -rf * puede borrar el directorio
equivocado si la variable $TARGET_DIR nunca se definió: sin comillas, un cd con una
variable vacía equivale a un cd sin argumento, que te manda directo a tu home, y ahí sí
que hay algo que perder. Ninguno de estos dos ejemplos es hipotético: son la razón por la que
"revisa el código de salida antes de continuar" aparece en casi cualquier checklist de
despliegue seguro.
Errores comunes
1. Creer que A && B || C es un if/then/else de verdad (conceptual). Qué pasa: escribes
migrate_db && echo "Migration OK" || echo "Migration FAILED" esperando que el mensaje de
error solo aparezca si migrate_db falla, y ves "FAILED" incluso cuando la migración
funcionó bien. Por qué: || no sabe nada sobre A; solo mira el código de salida del comando
inmediatamente anterior, que en este caso es el echo "Migration OK". Si por cualquier
motivo ese echo falla (por ejemplo, porque el archivo al que redirige no tiene permiso de
escritura), || interpreta eso como el fallo y dispara C, sin que migrate_db haya tenido
ningún problema. Cómo detectarlo: ves el mensaje de error de la rama || en un caso donde
sabes con certeza que el paso original funcionó. Cómo corregirlo: si de verdad necesitas
condiciones tipo if/then/else, usa la construcción if comando; then ...; else ...; fi que
vas a ver en el módulo de scripting; es explícita y no depende de que la rama "then" nunca
falle.
2. Encadenar con ; pasos que dependen uno del otro. Qué pasa: escribes una secuencia
como cd carpeta; rm -rf * o docker stop app; docker rm app; docker run ... asumiendo que
si el primer comando falla, el resto simplemente "no tendrá efecto". Por qué: ; no verifica
nada; corre el segundo comando exista o no exista razón para hacerlo, incluso en el
directorio o estado equivocado. Cómo detectarlo: pregúntate, para cada ; en una línea que
escribiste, "¿me importaría si el comando de la izquierda hubiera fallado en silencio?". Si la
respuesta es sí, ese ; es un candidato a bug. Cómo corregirlo: cambia ; por && cada vez
que el paso siguiente solo tiene sentido si el anterior funcionó.
3. Consultar $? después de que algo más lo pisó. Qué pasa: corres un comando, imprimes
un mensaje de diagnóstico de por medio (echo "Checking result..."), y después consultas
$?, y el valor que ves es el del echo, no el del comando que en verdad querías inspeccionar.
Por qué: $? siempre refleja el código de salida del último comando ejecutado, sin excepción;
no "recuerda" nada de antes. Cómo detectarlo: si $? da 0 cuando estás seguro de que algo
falló, sospecha que algo se ejecutó en el medio. Cómo corregirlo: guarda el valor apenas te
importa, en una variable propia (result=$?), y usa esa variable después, en vez de volver a
consultar $? una vez que ya pasó otro comando.
Ejercicios
1. Predice qué pasa exactamente con este comando si /tmp/does-not-exist no existe en tu
sistema, y en qué directorio termina creado marker.txt:
cd /tmp/does-not-exist; touch marker.txt
Ver solución
cd /tmp/does-not-exist falla e imprime algo como cd: /tmp/does-not-exist: No such file or directory, pero como el separador es ;, no &&, el shell igual ejecuta touch marker.txt. Como el cd nunca tuvo efecto, sigues parado en el directorio desde el que
corriste la línea, y marker.txt se crea ahí, no en /tmp/does-not-exist, que ni siquiera
existe.
Por qué funciona: ; nunca revisa el código de salida del comando anterior; encadena
incondicionalmente. Si la intención era que touch solo corriera dentro de
/tmp/does-not-exist, la línea correcta era cd /tmp/does-not-exist && touch marker.txt, que
aborta si el cd falla.
2. Esta línea de despliegue tiene un problema serio:
git pull; npm run build; systemctl restart myapp
¿Cuál es el peor escenario posible si npm run build falla? Reescribe la línea para que sea
segura.
Ver solución
El peor escenario: git pull trae código nuevo, npm run build falla a mitad de camino (por
ejemplo, por un error de sintaxis) y deja una carpeta de build corrupta o incompleta, y aun
así systemctl restart myapp se ejecuta, reiniciando el servicio con una build rota o
inexistente y tumbando la aplicación en producción sin ninguna advertencia previa.
La versión segura encadena cada paso a que el anterior haya funcionado:
git pull && npm run build && systemctl restart myapp
Por qué funciona: con &&, si git pull o npm run build terminan con un código
distinto de 0, el resto de la cadena nunca se ejecuta. El servicio sigue corriendo con la
versión anterior, que al menos funciona, en vez de con una a medio construir.
3. Un compañero corre un script y ve este mensaje:
zsh: command not found: deploy-app
Consulta $? y ve 127. Te pregunta qué pasaría si en cambio el archivo deploy-app
existiera en el directorio actual pero no tuviera permiso de ejecución. ¿Qué código vería, y
cómo se corrige?
Ver solución
Vería 126, no 127. La diferencia: 127 significa que el shell no encontró el comando
en absoluto (typo, no está instalado, no está en el PATH); 126 significa que sí lo
encontró, pero no pudo ejecutarlo porque le falta el permiso de ejecución. La corrección para
126 es agregar el permiso con chmod +x deploy-app (y, si el script está en el directorio
actual y no en el PATH, correrlo como ./deploy-app).
Por qué funciona: chmod +x le da al archivo el bit de ejecución que el sistema exige
antes de poder correrlo como programa; sin eso, el kernel rechaza la ejecución aunque el
archivo exista y tenga el contenido correcto.
4. Escribe un solo comando que entre a /var/log, cuente cuántos archivos .log hay ahí
con ls *.log | wc -l, y deje tu shell exactamente en el directorio donde estaba antes de
correrlo, sin un cd explícito de regreso al final.
Ver solución
(cd /var/log && ls *.log | wc -l)
Por qué funciona: los paréntesis corren el grupo en una subshell, un proceso hijo aparte.
El cd sí ocurre, pero solo dentro de ese proceso hijo; cuando el grupo termina, el proceso
hijo desaparece junto con su cambio de directorio, y tu shell real nunca se movió de donde
estaba. El && de adentro además evita que ls *.log | wc -l corra si el cd a /var/log
llegara a fallar.
Resumen y siguiente paso
Ya sabes leer el recibo que deja cada comando ($?, 0 para éxito, cualquier otro número
para un fallo específico), y elegir con criterio entre encadenar sin condiciones (;),
encadenar solo si el anterior funcionó (&&), reaccionar solo si falló (||), y agrupar
varios comandos como una sola unidad con () o { } cuando lo necesitas.
Todo lo que viste en este módulo —los tres flujos, la redirección, las tuberías, el kit de texto, y ahora los códigos de salida— son las piezas sueltas de un lenguaje. Lo que sigue es armarlas juntas en un solo problema real: una tubería de análisis de logs que responda varias preguntas sobre un archivo de decenas de miles de líneas, guardando el reporte solo si cada paso funcionó.
Antes de avanzar deberías poder:
- explicar, sin mirar esta lección, por qué
cd carpeta; rm -rf *es peligroso ycd carpeta && rm -rf *no lo es; - decir de memoria qué código de salida corresponde a "comando no encontrado" y cuál a "comando encontrado pero sin permiso de ejecución";
- escribir una cadena de dos o tres comandos usando
&&de forma que un fallo a mitad de camino detenga todo lo que viene después.
Recursos
- Exit Status — Bash Reference Manual — la referencia oficial de bash sobre qué es el código de salida y cómo se determina.
- Lists of Commands — Bash Reference Manual
— documentación oficial de
;,&&,||y su precedencia. - Command Grouping — Bash Reference Manual
— la diferencia exacta entre
()(subshell) y{ }(shell actual), con su sintaxis. - Appendix E. Exit Codes With Special Meanings — Advanced Bash-Scripting Guide — la tabla de referencia de los códigos reservados (126, 127, 130, entre otros) y por qué existen.