Redirecciones, tuberías y procesos en Bash

Lección 5 de 8: redirecciones, tuberías y procesos en Bash. La potencia de la terminal está en conectar órdenes: guardar su salida, separar los errores, pasar el resultado de una a otra y ejecutar varias a la vez. Esta lección cubre los tres canales de todo proceso, >, >>, 2>&1 y /dev/null, los here-docs, las tuberías con tee y xargs, el código de salida de una tubería, las subshells, el segundo plano y las señales.

Tres canales: entrada, salida y errores

Cada orden que lanzas, y cada script, arranca con tres canales abiertos, numerados: la entrada estándar (0, stdin), de donde lee; la salida estándar (1, stdout), donde escribe los resultados, y la salida de errores (2, stderr), donde escribe los avisos y los fallos. En una terminal, la entrada es el teclado y las dos salidas van a la pantalla, por eso parecen lo mismo. No lo son, y separarlas es la base de todo lo que sigue.

Un proceso con sus tres canales: la entrada estándar, número 0, que viene del teclado o de un archivo con menor que; la salida estándar, número 1, que va a la pantalla o a un archivo con mayor que; y la salida de errores, número 2, que también va a la pantalla o a un archivo con 2 mayor queTodo proceso nace con tres canales abiertostu ordenls, grep, tu script…tecladoo < archivo0 stdinpantallao > archivo1 stdoutpantallao 2> archivo2 stderrPor omisión, la salida y los errores van a la misma pantalla, pero son canales distintos.

Los tres canales de un proceso y cómo se redirige cada uno. Esquema: decodigo.com.

Para verlo, le pedimos a ls un archivo que existe y otro que no, y redirigimos cada canal a un sitio distinto. Esto es una sesión de terminal: cada línea con $ es una orden tecleada, seguida de lo que escribió:

terminal$ ls notas.txt no-existe.txt
ls: cannot access 'no-existe.txt': No such file or directory
notas.txt
$ ls notas.txt no-existe.txt > salida.txt
ls: cannot access 'no-existe.txt': No such file or directory
$ cat salida.txt
notas.txt
$ ls notas.txt no-existe.txt 2> errores.txt
notas.txt
$ cat errores.txt
ls: cannot access 'no-existe.txt': No such file or directory
$ ls notas.txt no-existe.txt > todo.txt 2>&1
$ cat todo.txt
ls: cannot access 'no-existe.txt': No such file or directory
notas.txt
$ ls notas.txt no-existe.txt 2> /dev/null
notas.txt
$ echo "revisar el correo" >> notas.txt
$ cat notas.txt
comprar pan
llamar a Luis
revisar el correo

Al redirigir la salida con >, el resultado fue a salida.txt y en la pantalla solo quedó el error; con 2>, al revés. > todo.txt 2>&1 manda las dos cosas al mismo archivo: se lee «la salida, a todo.txt; y los errores (2), a donde vaya la salida (&1)». /dev/null es un archivo especial que se traga todo lo que le llega: sirve para descartar lo que no te interesa. Y >> añade al final en lugar de sobrescribir.

  • RedirecciónQué hace
  • orden > fLa salida, a f (lo vacía antes)
  • orden >> fLa salida, al final de f
  • orden 2> fLos errores, a f
  • orden > f 2>&1Salida y errores, a f (el orden importa)
  • orden &> fLo mismo, abreviado (solo Bash)
  • orden 2> /dev/nullDescarta los errores
  • orden < fLa entrada sale de f en lugar del teclado
  • echo "aviso" >&2Escribe en el canal de errores

El error que te ahorra: 2>&1 en el orden equivocado

#!/usr/bin/env bash

ls notas.txt no-existe.txt 2>&1 > todo.txt
echo "--- contenido de todo.txt:"
cat todo.txt
salidals: cannot access 'no-existe.txt': No such file or directory
--- contenido de todo.txt:
notas.txt

El error no ha ido al archivo. Las redirecciones se aplican de izquierda a derecha: 2>&1 copió el destino que tenía la salida en ese momento (la pantalla) y después > todo.txt cambió solo la salida. Por eso 2>&1 va siempre al final. ShellCheck lo sabe:

shellcheckIn orden.sh line 3:
ls notas.txt no-existe.txt 2>&1 > todo.txt
                           ^--^ SC2069 (warning): To redirect stdout+stderr, 2>&1 must be last (or use '{ cmd > file; } 2>&1' to clarify).

For more information:
  https://www.shellcheck.net/wiki/SC2069 -- To redirect stdout+stderr, 2>&1 m...

Escribir avisos en stderr

Tus scripts deberían seguir la misma regla que las órdenes del sistema: los resultados, a la salida; los mensajes de error y de progreso, a stderr con >&2. Así, quien redirija tu script a un archivo obtiene solo los datos, y los avisos siguen apareciendo en su pantalla:

#!/usr/bin/env bash

aviso() {
    echo "[aviso] $*" >&2
}

aviso "Empiezo a contar líneas"
wc -l < notas.txt
aviso "Terminado"
salida[aviso] Empiezo a contar líneas
2
[aviso] Terminado

Aquí ves las tres líneas porque el ejecutor junta los dos canales, como una terminal; con ./avisos.sh > total.txt, en total.txt solo estaría el 2. Fíjate también en wc -l < notas.txt: al leer de la entrada estándar en lugar de recibir el nombre del archivo, wc escribe solo el número, sin el nombre detrás.

Here-docs y here-strings: texto en línea

Un here-doc mete un bloque de texto como entrada de una orden, sin crear un archivo. Empieza con <<PALABRA y termina en la línea que solo contiene esa palabra. Las variables se expanden dentro, salvo que la palabra vaya entre comillas. Es la forma cómoda de generar archivos de configuración, correos o SQL desde un script:

#!/usr/bin/env bash

usuario="ana"
puerto=8080

cat <<EOF
# Configuración generada para $usuario
servidor.puerto = $puerto
servidor.raiz   = /home/$usuario/web
EOF

echo "---"
cat <<'EOF'
Con la palabra entre comillas no se expande nada: $usuario, $(date), $((1 + 1))
EOF

echo "---"
# here-string: una sola cadena como entrada
wc -w <<< "cuenta las palabras de esta frase"
read -r nombre apellido <<< "Luis Pérez"
echo "Nombre: $nombre / Apellido: $apellido"
salida# Configuración generada para ana
servidor.puerto = 8080
servidor.raiz   = /home/ana/web
---
Con la palabra entre comillas no se expande nada: $usuario, $(date), $((1 + 1))
---
6
Nombre: Luis / Apellido: Pérez

La palabra de cierre (EOF es una costumbre, puede ser cualquiera) tiene que estar sola en su línea, sin espacios delante. El here-string, <<<, es la versión de una sola línea: pasa una cadena como entrada sin necesidad de echo … |, y combinado con read sirve para repartir una frase en variables.

Tuberías: encadenar órdenes

La barra vertical | conecta la salida de una orden con la entrada de la siguiente. Es la idea central de Unix: herramientas pequeñas que hacen una sola cosa bien y se combinan. Con un registro de accesos de un servidor web (direcciones de ejemplo), estas cinco órdenes sacan las tres IP que más peticiones han hecho:

Una tubería de cinco órdenes: cut saca la IP de cada línea, sort las ordena para juntarlas, uniq -c cuenta las repetidas, sort -rn las ordena de más a menos y head -3 se queda con las tres primerasUna tubería: la salida de cada orden es la entrada de la siguientecut -d' ' -f1la IP decada líneasortlas ordena(juntas)uniq -ccuenta lasrepetidassort -rnde más amenoshead -3se quedacon 3Las cinco órdenes se ejecutan a la vez; los datos fluyen entre ellas sin archivos intermedios.

Cada orden de la tubería transforma el texto y se lo pasa a la siguiente. Esquema: decodigo.com.
#!/usr/bin/env bash

echo "Las 3 IP con más peticiones:"
cut -d' ' -f1 accesos.log | sort | uniq -c | sort -rn | head -3

echo "Peticiones que acabaron en 404:"
grep -c '" 404 ' accesos.log
salidaLas 3 IP con más peticiones:
      4 192.0.2.10
      3 203.0.113.42
      2 198.51.100.7
Peticiones que acabaron en 404:
3

uniq -c solo junta líneas repetidas consecutivas, por eso hace falta el primer sort. En la lección 6 verás grep, cut, sort y awk a fondo; aquí lo importante es el mecanismo: ninguna de estas órdenes sabe nada de las demás, solo leen y escriben texto.

tee: ver y guardar a la vez

tee copia lo que recibe en un archivo y además lo deja pasar, como una T de fontanería. Sirve para guardar un resultado intermedio sin cortar la tubería:

#!/usr/bin/env bash

grep '" 404 ' accesos.log | tee errores-404.log | wc -l
echo "Guardadas en errores-404.log:"
cut -d'"' -f2 errores-404.log
salida3
Guardadas en errores-404.log:
GET /wp-login.php HTTP/1.1
GET /.env HTTP/1.1
GET /xmlrpc.php HTTP/1.1

xargs: de líneas a argumentos

Algunas órdenes no leen de la entrada estándar sino de sus argumentos, como rm, mkdir o wc con varios archivos. xargs hace de puente: lee líneas y las pasa como argumentos. Como por omisión parte por espacios, con nombres de archivo hay que decirle que separe solo por saltos de línea (-d '\n'), o mejor por el carácter nulo (-0), que nunca aparece en un nombre:

#!/usr/bin/env bash

echo "Con xargs a secas (mal):"
printf '%s\n' "informe final.txt" resumen.txt | xargs wc -l

echo "Con -d, separando solo por saltos de línea (bien):"
printf '%s\n' "informe final.txt" resumen.txt | xargs -d '\n' wc -l

echo "Con -0, separando por el carácter nulo (lo más seguro):"
printf '%s\0' "informe final.txt" resumen.txt | xargs -0 wc -l
salidaCon xargs a secas (mal):
wc: informe: No such file or directory
wc: final.txt: No such file or directory
1 resumen.txt
1 total
Con -d, separando solo por saltos de línea (bien):
 3 informe final.txt
 1 resumen.txt
 4 total
Con -0, separando por el carácter nulo (lo más seguro):
 3 informe final.txt
 1 resumen.txt
 4 total

El primer intento partió «informe final.txt» en dos nombres que no existen, así que wc solo pudo contar resumen.txt. Con -d '\n', cada línea es un argumento entero; con -0, junto con printf '%s\0' o con find … -print0, funciona incluso con nombres que contengan un salto de línea, que en Linux son raros pero posibles.

El código de salida de una tubería

Una tubería devuelve el código de salida de su última orden. Si falla una de en medio, por omisión nadie se entera. PIPESTATUS guarda el de cada una, y la opción pipefail hace que la tubería falle si falla cualquiera de ellas:

#!/usr/bin/env bash

grep "algo" no-existe.log | sort
echo "Código de la tubería: $?"

grep "algo" no-existe.log | sort
echo "Códigos de cada orden: ${PIPESTATUS[*]}"

set -o pipefail
grep "algo" no-existe.log | sort
echo "Con pipefail: $?"
salidagrep: no-existe.log: No such file or directory
Código de la tubería: 0
grep: no-existe.log: No such file or directory
Códigos de cada orden: 2 0
grep: no-existe.log: No such file or directory
Con pipefail: 2

grep devuelve 2 cuando no puede leer el archivo, pero sort, que recibió una entrada vacía, acaba bien, y la tubería entera dice 0. Con pipefail, el fallo se propaga. Hay que leer PIPESTATUS justo después de la tubería, porque la siguiente orden lo sobrescribe. En la lección 7 verás por qué casi todos los scripts serios empiezan con set -o pipefail.

Sustitución de procesos: una orden como si fuera un archivo

Algunas órdenes necesitan nombres de archivo, no una tubería: diff compara dos archivos, por ejemplo. <( orden ) ejecuta la orden y la presenta como un archivo temporal que se puede leer. Así se comparan dos listas ya ordenadas sin crear archivos intermedios:

#!/usr/bin/env bash

echo "Diferencias entre las asistencias del lunes y del martes:"
diff <(sort lunes.txt) <(sort martes.txt)

echo "Vinieron los dos días:"
comm -12 <(sort lunes.txt) <(sort martes.txt)

echo "Qué es <( ) en realidad:" <(true)
salidaDiferencias entre las asistencias del lunes y del martes:
2d1
< Luis
4a4
> Sofía
Vinieron los dos días:
Ana
Marta
Pedro
Qué es <( ) en realidad: /dev/fd/63

La última línea lo desvela: <( … ) se convierte en una ruta como /dev/fd/63, un descriptor abierto que la orden lee como un archivo cualquiera. Es propio de Bash (y de zsh y ksh); en sh no existe.

Subshells y llaves: ( ) frente a { }

Los paréntesis ejecutan las órdenes en una subshell, una copia del proceso: lo que cambien (variables, carpeta actual, opciones) desaparece al terminar. Las llaves agrupan órdenes en la misma shell, y los cambios se quedan. Las dos sirven para redirigir de una vez la salida de varias órdenes:

#!/usr/bin/env bash

cd /home/ana/proyecto || exit 1
contador=1

( cd /tmp || exit; contador=99; echo "Dentro de ( ): $PWD, contador=$contador" )
echo "Después de ( ): $PWD, contador=$contador"

{ cd /tmp || exit; contador=99; echo "Dentro de { }: $PWD, contador=$contador"; }
echo "Después de { }: $PWD, contador=$contador"

# Agrupar para redirigir todo junto
{
    echo "Informe del día"
    date -d '2026-10-30 09:00' '+%F %H:%M'
    wc -l < /home/ana/proyecto/accesos.log
} > /tmp/informe.txt
cat /tmp/informe.txt
salidaDentro de ( ): /tmp, contador=99
Después de ( ): /home/ana/proyecto, contador=1
Dentro de { }: /tmp, contador=99
Después de { }: /tmp, contador=99
Informe del día
2026-10-30 09:00
10

Las llaves tienen una sintaxis exigente: necesitan un espacio después de { y un punto y coma (o un salto de línea) antes de }. Las subshells aparecen también sin pedirlas: $( ) y cada parte de una tubería se ejecutan en una. Por eso una variable que se cambia dentro de un while alimentado por una tubería se pierde al salir del bucle, una sorpresa clásica que explicamos en las rarezas. ShellCheck, de hecho, avisa de que el cambio de contador dentro de los paréntesis se perderá:

shellcheckIn subshell.sh line 6:
( cd /tmp || exit; contador=99; echo "Dentro de ( ): $PWD, contador=$contador" )
                   ^------^ SC2030 (info): Modification of contador is local (to subshell caused by (..) group).

In subshell.sh line 7:
echo "Después de ( ): $PWD, contador=$contador"
                                     ^-------^ SC2031 (info): contador was modified in a subshell. That change might be lost.

For more information:
  https://www.shellcheck.net/wiki/SC2030 -- Modification of contador is local...
  https://www.shellcheck.net/wiki/SC2031 -- contador was modified in a subshe...

Procesos en segundo plano: & y wait

Un & al final de una orden la lanza en segundo plano: el script sigue sin esperarla. $! guarda el número de proceso (PID) de la última orden lanzada así, y wait espera a que termine y recoge su código de salida. Con eso se pueden hacer varias cosas a la vez:

#!/usr/bin/env bash

trabajo() {
    local nombre=$1 segundos=$2 codigo=$3
    sleep "$segundos"
    echo "Trabajo $nombre terminado"
    return "$codigo"
}

trabajo A 0.6 0 &
pid_a=$!
trabajo B 0.2 3 &
pid_b=$!
echo "Lanzados dos trabajos; el script sigue"

wait "$pid_b"
echo "B acabó con código $?"
wait "$pid_a"
echo "A acabó con código $?"
salidaLanzados dos trabajos; el script sigue
Trabajo B terminado
B acabó con código 3
Trabajo A terminado
A acabó con código 0

B tarda menos y termina antes, aunque se lanzó después. wait sin argumentos espera a todos los trabajos pendientes, pero entonces no sabrás cuál falló; con el PID de cada uno, sí. En una terminal interactiva, jobs lista los trabajos en segundo plano, fg trae uno al primer plano y Ctrl+Z seguido de bg manda al fondo el que está en marcha. En el ejemplo Tareas en paralelo verás cómo limitar cuántos van a la vez.

Señales: kill y trap

Las señales son avisos que el sistema envía a un proceso: INT cuando pulsas Ctrl+C, TERM cuando se le pide que termine (lo que hace kill por omisión), KILL para matarlo sin contemplaciones. Un script puede capturarlas con trap para limpiar antes de salir:

#!/usr/bin/env bash

sleep 30 &
pid=$!
kill "$pid"
wait "$pid"
echo "sleep terminó con el código $? (128 + 15, la señal TERM)"

limpiar() {
    echo "Recibida la señal TERM: borro los temporales y salgo"
    exit 1
}
trap limpiar TERM

echo "Me envío TERM a mí mismo"
kill -TERM $$
echo "Esta línea no llega a ejecutarse"
salida con errorsleep terminó con el código 143 (128 + 15, la señal TERM)
Me envío TERM a mí mismo
Recibida la señal TERM: borro los temporales y salgo
código de salida: 1

Cuando una señal termina un proceso, su código de salida es 128 más el número de la señal: TERM es la 15, y de ahí el 143. $$ es el PID del propio script. KILL (la 9) no se puede capturar, por eso conviene usarla solo cuando TERM no basta: el proceso no tendrá ocasión de limpiar nada. En la lección 7 usarás trap … EXIT para borrar archivos temporales pase lo que pase.

Ojo con la seguridad: > sobrescribe sin preguntar, y /tmp es de todos

Una redirección con > vacía el archivo de destino sin pedir confirmación, y si ese destino está en /tmp con un nombre fijo como /tmp/informe.txt, cualquier otro usuario del sistema puede haberlo creado antes, o haber dejado ahí un enlace simbólico que apunte a un archivo tuyo. Dos defensas: la opción noclobber, que impide sobrescribir con > (para hacerlo a propósito, >|), y mktemp, que crea un archivo con nombre aleatorio que solo puede leer su dueño:

#!/usr/bin/env bash

echo "datos importantes" > resultado.txt
set -o noclobber
echo "otra cosa" > resultado.txt
echo "Contenido: $(cat resultado.txt)"
echo "a propósito" >| resultado.txt
echo "Contenido: $(cat resultado.txt)"

tmp=$(mktemp)
[[ $tmp == /tmp/tmp.* ]] && echo "mktemp creó un archivo en /tmp con nombre aleatorio"
echo "Permisos: $(stat -c '%a (%A)' "$tmp")"
rm -f "$tmp"
salida./noclobber.sh: line 5: resultado.txt: cannot overwrite existing file
Contenido: datos importantes
Contenido: a propósito
mktemp creó un archivo en /tmp con nombre aleatorio
Permisos: 600 (-rw-------)

Con noclobber, el segundo > falla con un error y el archivo se conserva. Y el temporal de mktemp nace con permisos 600: solo tú puedes leerlo y escribirlo.

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

Retos

Reto 1. Ejecuta ls sobre notas.txt, falta1.txt y falta2.txt de forma que lo que encuentra vaya a ok.txt y los errores a fallos.txt, y luego escribe cuántas líneas tiene cada archivo.

Ver la solución
#!/usr/bin/env bash

ls notas.txt falta1.txt falta2.txt > ok.txt 2> fallos.txt
echo "ok.txt: $(wc -l < ok.txt) línea(s)"
echo "fallos.txt: $(wc -l < fallos.txt) línea(s)"
salidaok.txt: 1 línea(s)
fallos.txt: 2 línea(s)

Cada canal a su archivo, con > y 2> en la misma orden. Y wc -l < archivo da el número a secas.

Reto 2. Con el registro accesos.log de esta lección, escribe en una sola tubería cuántas IP distintas hay.

Ver la solución
#!/usr/bin/env bash

cut -d' ' -f1 accesos.log | sort -u | wc -l
salida4

sort -u ordena y quita los duplicados de una vez, sin necesidad de uniq.

Reto 3. ¿Qué escribe esto? Piénsalo antes de mirar: { echo hola; echo adiós >&2; } 2> /dev/null | tr '[:lower:]' '[:upper:]'.

Ver la solución
#!/usr/bin/env bash

{ echo hola; echo adiós >&2; } 2> /dev/null | tr '[:lower:]' '[:upper:]'
salidaHOLA

Solo «HOLA». «adiós» va por stderr, que se descarta antes de llegar a la tubería: | solo conecta la salida estándar. Si quisieras pasar las dos, sería 2>&1 | (o |& en Bash).

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.