El shell puede redirigir texto hacia los comandos sin que ese texto pase por un archivo en disco. Eso es exactamente lo que hacen los here-documents (<<), los here-strings (<<<) y la sustitución de procesos (<(...)): cada uno es un mecanismo distinto para que stdin (o un argumento que espera un fichero) reciba datos que viven solo en memoria, o en un descriptor de fichero efímero.
La razón de que existan tres variantes distintas no es capricho histórico: son herramientas calibradas para volúmenes y contextos diferentes. Un here-doc está pensado para texto multilínea largo —configuraciones, plantillas, bloques SQL. Un here-string es para una sola línea cuando escribir echo "..." | sería ruido innecesario. La sustitución de procesos resuelve el caso en que un comando exige un nombre de fichero como argumento, no stdin, pero tú quieres pasarle el output de otro comando sin ensuciar /tmp.
Si los mezclas mal, los síntomas son sutiles: variables que no se expanden cuando deberían (o que sí se expanden cuando no quieres), pipelines que fallan porque un comando no acepta stdin, o scripts que dejan ficheros temporales y condiciones de carrera.
La diferencia crítica con <<EOF frente a <<'EOF' es la expansión. Con comillas simples en el delimitador, el cuerpo del here-doc es literal: ni $variable ni backticks se tocan. Sin comillas (o con comillas dobles), Bash expande todo igual que dentro de una cadena entre comillas dobles. Esto importa cuando estás generando código o configuración que contiene su propio $, como un script de otro lenguaje o una plantilla de Ansible.
La sustitución de comandos $(...) captura el stdout de un subshell y lo convierte en string. Los backticks `...` hacen lo mismo pero con dos problemas: no se anidan limpiamente (hay que escapar los backticks internos con \) y son visualmente ambiguos. No hay razón para usarlos en código nuevo.
La sustitución de procesos <(...) es diferente: Bash crea un FIFO o un descriptor /dev/fd/N y le pasa ese path al comando exterior. Para el comando, parece un fichero; en realidad es el stdout de un subshell. Funciona en Bash y Zsh, pero no en dash ni en sh de POSIX estricto —si tu script tiene #!/bin/sh, esto no existe.
Ejemplo completo
#!/usr/bin/env bash
# Genera un informe de diferencias entre dos listas de paquetes instalados
# en dos máquinas distintas, usando datos que ya tienes en variables.
set -euo pipefail
# ── 1. Here-string: stdin de una sola línea ────────────────────────────
# Más limpio que: echo "linux-image-6.1.0-21-amd64" | grep -o '[0-9].*'
kernel_version=$(grep -o '[0-9].*' <<< "linux-image-6.1.0-21-amd64")
echo "Kernel parseado: $kernel_version" # → 6.1.0-21-amd64
# ── 2. Here-doc con expansión (sin comillas en el delimitador) ─────────
# Las variables $kernel_version y $HOSTNAME se expanden dentro del bloque.
informe=$(cat << EOF
Informe de sistema
==================
Host : $HOSTNAME
Kernel : $kernel_version
Fecha : $(date +%Y-%m-%d)
EOF
)
echo "$informe"
# ── 3. Here-doc sin expansión (delimitador entre comillas simples) ──────
# Útil cuando el contenido tiene $, como una plantilla para otro script.
cat << 'EOF' > /tmp/plantilla_check.sh
#!/usr/bin/env bash
# Este $1 y este $(uname -r) NO se han expandido al escribir la plantilla.
echo "Argumento recibido: $1"
echo "Kernel del sistema destino: $(uname -r)"
EOF
# Verifica que el fichero contiene los literales, no valores expandidos:
head -3 /tmp/plantilla_check.sh
# ── 4. Sustitución de comandos: capturar stdout como variable ──────────
fecha=$(date +%Y%m%d)
snapshot="backup_${fecha}.tar.gz"
echo "Nombre del snapshot: $snapshot" # → backup_20240315.tar.gz
# ── 5. Sustitución de procesos: diff sobre output en memoria ───────────
# Simula dos listas de paquetes de máquinas distintas.
lista_prod="nginx
postgresql-15
openssh-server
curl"
lista_staging="nginx
postgresql-15
openssh-server
curl
strace"
# diff espera dos nombres de fichero. <(...) le da un fd que parece fichero.
# Así evitamos crear /tmp/lista_prod.txt y /tmp/lista_staging.txt.
# El ||true impide que set -e aborte el script cuando diff encuentra diferencias
# (diff devuelve exit code 1 si hay diferencias, no es un error real aquí).
diff \
<(sort <<< "$lista_prod") \
<(sort <<< "$lista_staging") \
|| true
# Para ver qué fichero "ve" diff realmente:
echo "El descriptor que recibe diff: /dev/fd/N (FIFO en kernel antiguo)"
# ── Limpieza ────────────────────────────────────────────────────────────
rm -f /tmp/plantilla_check.sh
Lo que está pasando debajo
El paso 1 usa <<< en lugar de echo ... | para evitar un subshell innecesario. Cuando escribes echo "cadena" | grep ..., Bash crea dos procesos en una pipe; con <<<, grep recibe la cadena directamente como stdin en el mismo proceso. La diferencia de rendimiento es trivial en un comando aislado, pero en un bucle de miles de iteraciones o en un script que se ejecuta en sistemas con fork lento (containers muy ajustados), se nota.
En el paso 2, el here-doc sin comillas en el delimitador (<< EOF) hace que $kernel_version, $HOSTNAME y $(date +%Y-%m-%d) se expandan igual que dentro de una cadena entre comillas dobles. El resultado se captura en $informe mediante sustitución de comandos $(cat << EOF ... EOF). Fíjate que el delimitador de cierre EOF debe estar en la primera columna de la línea, sin espacios ni tabuladores antes —si usas <<- EOF en lugar de << EOF, Bash sí permite indentar el cuerpo con tabuladores (no espacios), lo que mejora la legibilidad dentro de funciones.
En el paso 3, el 'EOF' con comillas simples desactiva toda expansión. El fichero resultante contiene el texto exactamente como lo escribiste, con $1 y $(uname -r) literales. Si necesitas combinar expansión parcial —expandir algunas variables pero proteger otras— lo haces escapando los signos que quieres proteger: \$variable_que_no_expande.
El paso 4 es sustitución de comandos estándar: $(date +%Y%m%d) crea un subshell, ejecuta date, captura su stdout (sin el newline final, que Bash elimina automáticamente) y lo sustituye como string. La eliminación silenciosa del newline final es el comportamiento correcto en casi todos los casos, pero puede sorprenderte si el comando produce múltiples líneas y quieres preservarlas —en ese caso necesitas el truco var=$(cmd; echo x); var="${var%x}", que es de nivel avanzado y sale del alcance de aquí.
El paso 5 es donde la sustitución de procesos gana claramente. diff no acepta stdin para ninguno de sus dos operandos simultáneamente; necesita dos paths. <(sort <<< "$lista_prod") hace que Bash abra un descriptor en /dev/fd/63 (o similar) respaldado por un subshell que ejecuta sort. diff recibe ese path como argumento, lo abre como si fuera un fichero, y lee el output de sort desde ahí. El kernel gestiona el buffer; no hay nada en /tmp. La expresión <(sort <<< "$lista_staging") hace lo mismo para el segundo operando. El resultado es exactamente el que obtendrías con dos ficheros temporales, sin la gestión de limpieza ni los posibles conflictos de nombres.
Una restricción práctica: en un script con #!/bin/sh o en sistemas donde /bin/sh apunta a dash (como en Debian por defecto), la sustitución de procesos no existe. Si necesitas portabilidad POSIX estricta, vuelves a los ficheros temporales con mktemp. En scripts que controlas tú y que corren en Bash —lo que es el caso en la mayoría de scripts de administración reales— <(...) es la elección correcta.
N° 43