SSD Nodes Learn 🎉 VPS desde $4.99/mes
Guías Matt ConnorPor Matt Connor · Actualizado 2026-08-07

Sustitución de comandos en Bash: $() o comillas invertidas

Entienda por qué $(...) pierde cd y variables en un subshell, por qué las comillas invertidas se anidan mal y cuándo usar process substitution.

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, August 4, 2026.

Qué hace la sustitución de comandos de Bash

La sustitución de comandos de Bash reemplaza $(command) por el texto que ese comando escribió en la salida estándar. La sintaxis antigua con acentos graves hace lo mismo. Todo lo que suele sorprender se explica por dos hechos: el comando se ejecuta en un proceso independiente llamado subshell y se elimina cada salto de línea al final de su salida.

mkdir -p /tmp/subst-demo
cd /tmp/subst-demo
printf 'alpha\nbeta\ngamma\n' > three.txt
count=$(wc -l < three.txt)
echo "$count"
3

Eso es todo lo que hace esta función. wc -l escribió 3 seguido de un salto de línea, se eliminó el salto de línea y count contiene los dos caracteres que quería. Observe que wc -l < three.txt muestra sólo un número porque GNU wc lee la entrada estándar y no tiene ningún nombre de archivo que mostrar. Escriba wc -l three.txt en su lugar y capturará 3 three.txt, que es una cadena diferente y una causa frecuente de errores posteriores en operaciones aritméticas.

El resto de esta guía trata del comportamiento que nadie espera, porque un subshell es un proceso independiente y un proceso independiente no puede modificar el shell en el que está escribiendo.

Use $() en lugar de las comillas invertidas

Ambas formas son válidas. $() forma parte de POSIX, por lo que dash, ash y busybox sh la admiten. Ya no existe ninguna razón de portabilidad para seguir escribiendo comillas invertidas, y hay dos razones concretas para no hacerlo.

Las comillas invertidas no se pueden anidar

echo "$(echo "$(echo hi)")"
echo "`echo `echo hi``"
hi
echo hi

La segunda línea imprimió las palabras literales echo hi. El shell busca la siguiente comilla invertida no escapada, por lo que la segunda comilla invertida que escribió cerró la primera. El comando que realmente ejecutó fue echo sin argumentos. Este imprimió una línea vacía, a la que después se le eliminó el salto de línea hasta dejarla en nada. Las palabras echo hi quedaron como texto normal, y el último par de comillas invertidas ejecutó un comando vacío.

Para anidar comillas invertidas debe escapar todas las comillas internas:

echo "`echo \`echo hi\``"
hi

Cada nivel adicional vuelve a duplicar el escape. $() no necesita nada de esto, porque el analizador empareja paréntesis en lugar de buscar un carácter delimitador.

Las comillas invertidas modifican las barras inversas antes de ejecutar el comando

echo "$(echo 'a\\b')"
echo "`echo 'a\\b'`"
a\\b
a\b

El mismo comando interno produjo una salida diferente. Dentro de las comillas invertidas, el shell elimina una capa de escapes de barra inversa antes de analizar el texto interno, por lo que las comillas simples no protegieron nada. Dentro de $(), el texto entre paréntesis se analiza como un script normal, por lo que las comillas simples se comportan como espera. Esto causa más problemas en los comandos de una línea de sed y awk, donde una barra inversa perdida convierte un patrón funcional en otro distinto sin mostrar ningún error.

Las comillas también empiezan de nuevo dentro de $(), lo que permite anidar comillas dobles sin escaparlas:

path=/etc/nginx/nginx.conf
echo "$(dirname "$path")"
/etc/nginx

La forma equivalente con comillas invertidas necesita \" alrededor de $path. Cada escape es un posible punto de error.

Por qué cd dentro de $() no cambia el directorio de la shell

Porque $(...) crea un proceso nuevo. La subshell recibe una copia de las variables y una copia del directorio de trabajo. Cambia su copia, muestra algo y termina. La copia desaparece con ella.

cd /tmp/subst-demo
pwd
target=$(cd /etc && pwd)
echo "$target"
pwd
/tmp/subst-demo
/etc
/tmp/subst-demo

No se produjo ningún error. cd funcionó y pwd dentro de la subshell realmente mostró /etc. Simplemente no existe ninguna ruta para que ese cambio regrese, porque la única información que una subshell entrega a su proceso padre es su salida estándar y un estado de salida.

Las asignaciones de variables se comportan igual:

count=0
msg=$(count=99; echo "inside: $count")
echo "$msg"
echo "outside: $count"
inside: 99
outside: 0

La misma regla explica la variante de este error que ocurre con mucha más frecuencia, en la que se usa una canalización en lugar de una sustitución:

n=0
cat three.txt | while read -r line; do n=$((n+1)); done
echo "$n"
0

Cada etapa de una canalización se ejecuta en su propia subshell, por lo que el bucle while incrementó una copia de n y después terminó. Sustituya la canalización por una redirección para que el bucle se ejecute en la shell actual:

n=0
while read -r line; do n=$((n+1)); done < three.txt
echo "$n"
3

Bash puede ejecutar la última etapa de una canalización en la shell actual con shopt -s lastpipe, pero sólo cuando el control de trabajos está desactivado, lo que nunca ocurre en una shell interactiva. Use la redirección.

¿Dónde quedó el indicador? Comandos interactivos dentro de $()

La sustitución de comandos redirige la salida estándar a una tubería y deja intacta la entrada estándar. Un programa que muestra su pregunta en la salida estándar y después espera una respuesta deja de mostrar la pregunta, pero sigue esperando. El terminal parece bloqueado.

ask() { printf 'Username: '; read -r u; printf '%s\n' "$u"; }
ask
Username: deploy
deploy

La palabra deploy de la primera línea es la que escribió. Ahora ejecute la misma función dentro de una sustitución:

v=$(ask)
deploy

Sólo aparecen las teclas que pulsa, repetidas por el controlador del terminal y no por el programa. El indicador terminó en otro lugar:

echo "$v"
Username: deploy

Ahora el indicador está dentro de la variable, unido a la respuesta, porque $() capturó todo lo que la función escribió en la salida estándar. Lo que escribe sigue llegando a read, porque la entrada estándar nunca se modificó. Esta es la firma exacta del error que indica que «mi script se bloquea y no muestra nada».

Algunas herramientas escriben los indicadores en el error estándar o directamente en /dev/tty para que sigan apareciendo. Muchas no lo hacen. Si una función debe permanecer dentro de una sustitución, envíe su indicador al error estándar:

ask() { printf 'Username: ' >&2; read -r u; printf '%s\n' "$u"; }
v=$(ask)
echo "$v"
Username: deploy
deploy

El error estándar no se captura, por lo que el indicador llega al terminal y sólo la respuesta termina en v.

¿Por qué ls tiene un aspecto diferente dentro de $()?

Porque ls llama a isatty en el descriptor de archivo 1 y cambia el formato de salida según la respuesta. En el indicador, ese descriptor es el terminal, por lo que ls distribuye los nombres en columnas a lo largo de la línea. Dentro de una sustitución, es una tubería, por lo que ls cambia a un nombre por línea.

mkdir -p /tmp/tty-demo
cd /tmp/tty-demo
touch alpha beta delta gamma
echo "$(ls)"
alpha
beta
delta
gamma

La misma comprobación desactiva el color en grep --color=auto y el paginador en git. Es una característica. Permite que un script obtenga una salida estable y legible por máquinas sin tener que solicitarla explícitamente.

Esto también responde a una pregunta habitual sobre las tuberías. ls | sort en el indicador y $(ls | sort) muestran lo mismo porque ls tenía una tubería en su salida en ambos casos. Lo que cambia dentro de una sustitución es la última etapa de la tubería. sort nunca comprueba si hay un terminal, por lo que su salida nunca cambia. Si coloca un comando que detecta el terminal al final de la tubería, la salida sí cambia. Por eso una tubería que probó visualmente puede comportarse de otra forma en cuanto la incluye en $().

De aquí se deriva una advertencia: no analice la salida de ls en un script aunque parezca práctico. Los nombres de archivo pueden contener espacios y saltos de línea. Use un patrón glob o find -print0 con read -d ''.

Los saltos de línea finales que desaparecen silenciosamente

La sustitución de comandos elimina todos los saltos de línea que aparecen al final de la salida. No sólo el último. Los elimina todos.

cd /tmp/subst-demo
printf 'hello\n\n\n' > blanks.txt
wc -c < blanks.txt
v=$(cat blanks.txt)
printf '%s' "$v" | wc -c
8
5

El archivo contiene hello y tres saltos de línea, por lo que ocupa 8 bytes. La variable contiene hello, por lo que ocupa 5 bytes. Tres bytes desaparecieron sin ninguna advertencia.

La eliminación es intencionada y suele ser útil. Es lo que permite que stamp=$(date -u +%Y%m%dT%H%M%SZ) genere un fragmento de nombre de archivo utilizable en lugar de un nombre que contenga un salto de línea. Por eso el patrón es seguro en algo como un script de copia de seguridad programada de restic:

stamp=$(date -u +%Y%m%dT%H%M%SZ)
printf 'backup-%s.tar.gz\n' "$stamp"
backup-20260804T031500Z.tar.gz

La marca de tiempo será diferente. Lo importante es que el nombre ocupe una sola línea.

El inconveniente es que no puede usar $() para mover los bytes exactos de un archivo. Si necesita conservar los saltos de línea finales, añada un carácter centinela dentro de la sustitución y elimínelo después:

v=$(cat blanks.txt; printf x)
v=${v%x}
printf '%s' "$v" | wc -c
8

x queda después de los saltos de línea, por lo que ya no quedan saltos de línea finales que eliminar. Después, ${v%x} elimina el centinela y conserva los bytes originales.

Hay otros dos detalles relacionados. Para leer un archivo completo, v=$(<blanks.txt) realiza el mismo trabajo sin ejecutar cat, porque bash abre el archivo directamente. También elimina los saltos de línea finales de la misma forma. En cambio, los here-strings añaden un salto de línea que no escribió:

wc -c <<< 'abc'
4

Cite el resultado o bash lo dividirá y aplicará la expansión de comodines

Una sustitución sin comillas pasa por la división en palabras y después por la expansión de nombres de ruta. Una sustitución entre comillas no pasa por ninguna de las dos.

printf 'a b\tc\nd\n' > words.txt
echo $(cat words.txt)
echo "$(cat words.txt)"
a b c d
a b	c
d

Sin comillas, bash dividió la salida usando los caracteres de IFS, que de forma predeterminada son el espacio, el tabulador y el salto de línea, y echo volvió a unir las cuatro partes con espacios simples. Entre comillas, el texto llegó como una sola palabra, con el tabulador y el salto de línea interno intactos.

La expansión de comodines es la parte más peligrosa:

mkdir -p /tmp/glob-demo
cd /tmp/glob-demo
touch one.txt two.txt
printf '*\n' > pattern.txt
p=$(cat pattern.txt)
echo $p
echo "$p"
one.txt pattern.txt two.txt
*

La asignación era segura porque las asignaciones no realizan división en palabras ni expansión de comodines. El problema se produjo en echo $p, donde * se expandió tomando como referencia el directorio actual. Un script que lee un patrón de un archivo de configuración y olvida las comillas actuará sin problemas sobre todos los archivos que pueda ver. Ponga comillas en todas las expansiones para eliminar toda esta clase de errores. Omita las comillas sólo cuando realmente necesite la división, lo que ocurre rara vez.

¿Por qué local x=$(cmd) siempre devuelve 0?

Porque local es un comando por sí mismo, y $? informa del estado de local, no del estado de la sustitución que contenía.

check_bad() { local out=$(false); echo "status: $?"; }
check_bad
status: 0

false terminó con el código 1, local se ejecutó correctamente al declarar la variable y el código 1 se descartó. declare, export, typeset y readonly se comportan de la misma forma. set -e tampoco lo detectará, porque, desde el punto de vista del shell, no falló nada.

Separe la declaración de la asignación:

check_good() { local out; out=$(false); echo "status: $?"; }
check_good
status: 1

Una asignación simple en el nivel superior ya informa del estado de su última sustitución de comandos:

out=$(exit 3)
echo $?
3

Esto es especialmente importante en una comprobación de estado que se ejecuta mediante un servicio y un temporizador de systemd, donde un código de salida oculto hace que la unidad informe de éxito en cada ejecución, aunque el trabajo que debía verificar nunca se haya realizado.

Alternativas dentro del shell que realmente necesita

La mayoría recurre a $(...) porque quiere guardar datos en una variable. A menudo, lo que realmente necesita es entrada, no captura. Estas cuatro formas mantienen el estado en el shell actual.

Redirección en el bucle

cd /tmp/subst-demo
while read -r line; do printf 'got: %s\n' "$line"; done < three.txt
got: alpha
got: beta
got: gamma

No se crea ningún proceso para la entrada, por lo que cualquier variable que establezca el cuerpo del bucle conserva su valor después del bucle.

Sustitución de procesos

while read -r line; do printf 'got: %s\n' "$line"; done < <(sort -r three.txt)
got: gamma
got: beta
got: alpha

<(command) le proporciona una ruta, como /dev/fd/63, desde la que se puede leer la salida del comando. El comando sigue ejecutándose en su propio proceso. El bucle while no, y ese es el objetivo. El espacio en < <( es obligatorio: <<( se interpreta como el inicio de un documento aquí y no se puede analizar. La sustitución de procesos es una función de bash, por lo que un script con #!/bin/sh en Ubuntu o Debian se ejecuta con dash y fallará. Use #!/bin/bash.

Cadenas aquí

read -r first rest <<< 'alpha beta gamma'
echo "$first"
echo "$rest"
alpha
beta gamma

<<< envía una cadena a la entrada estándar de un comando. read se ejecuta en el shell, por lo que ambas variables quedan establecidas donde puede usarlas. read -r a b <<< "$(some-command)" es la forma habitual de extraer dos campos de una línea de salida.

mapfile para archivos completos

mapfile -t lines < three.txt
echo "${#lines[@]}"
echo "${lines[1]}"
3
beta

mapfile, también escrito como readarray, lee un archivo en un array del shell actual. -t elimina el salto de línea final de cada elemento. Necesita bash 4 o una versión posterior, y Ubuntu 24.04 incluye bash 5.2, por lo que está disponible en cualquier imagen de servidor actual.

Una lista de comprobación antes de confirmar el script

  • Escriba $(command) y protéjalo entre comillas como "$(command)", salvo que necesite dividirlo específicamente.
  • Suponga que las líneas nuevas finales han desaparecido. Añada un carácter centinela si necesita recuperarlas.
  • No incluya comandos interactivos en sustituciones, o envíe sus mensajes al error estándar.
  • Escriba local out en una línea independiente cuando importe el estado de salida de out=$(command).
  • Para establecer variables a partir de la entrada, use una redirección o una sustitución de procesos en lugar de una tubería.

Estas estructuras aparecen en los primeros scripts pequeños que se escriben al configurar un VPS nuevo, y los fallos pasan inadvertidos. Un script de copia de seguridad que capturó un mensaje en un nombre de archivo, o una comprobación de estado que ocultó un código de salida, sigue informando de que todo funciona correctamente. El coste aumenta cuando ejecuta el mismo script en varios servidores, porque la salida que nunca leyó ahora es salida que no leyó en veinte máquinas.

FAQ

¿Por qué cd dentro de $() no cambia mi directorio actual?

$(...) ejecuta el comando en un subshell, que es un proceso independiente con una copia del directorio de trabajo y de las variables. cd cambia esa copia. Después, el proceso termina y la copia se descarta. Un subshell sólo puede devolver su salida estándar y un estado de salida. Por eso, el cambio de directorio no puede llegar al shell padre. Si necesita obtener el directorio, captúrelo con target=$(cd /etc && pwd) y úselo con "$target". Si quiere que el shell cambie de directorio, ejecute cd directamente, sin incluirlo en una sustitución.

¿Cuál es la diferencia entre $() y las comillas invertidas en bash?

Producen el mismo resultado con comandos sencillos, pero se diferencian en dos aspectos importantes. $() permite anidar sustituciones directamente porque el analizador empareja los paréntesis. Las comillas invertidas necesitan una comilla invertida escapada para cada nivel de anidamiento. Además, las comillas invertidas eliminan una capa de escape con barra inversa antes de analizar el comando interno. Por eso, ` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh`, de modo que no hay ningún motivo de portabilidad para usar comillas invertidas.

¿Por qué mi script se queda bloqueado sin mostrar el indicador cuando un comando hace una pregunta?

La sustitución de comandos redirige la salida estándar a una tubería, pero mantiene la entrada estándar conectada al terminal. Un programa que muestra el indicador en la salida estándar captura ese indicador en la variable, mientras que el read que está detrás sigue esperando su respuesta. El terminal sólo muestra los caracteres que escribe, repetidos por el controlador del terminal. Mueva la pregunta fuera de la sustitución o haga que el indicador se escriba en la salida de error estándar con printf 'Username: ' >&2 para que no se capture.

¿Por qué desaparecieron las líneas en blanco del final de mi variable?

La sustitución de comandos elimina todos los saltos de línea finales, no sólo el último. printf 'hello\n\n\n' > f; v=$(cat f) deja v con cinco bytes, mientras que el archivo contiene ocho. Para conservarlos, añada un marcador al final dentro de la sustitución y elimínelo después con v=$(cat f; printf x) seguido de v=${v%x}. El marcador queda después de los saltos de línea. Por tanto, no queda nada al final que bash pueda eliminar.

¿Por qué local out=$(cmd) siempre informa de que la operación se realizó correctamente?

local es un comando independiente. $? después de esa línea informa de si local pudo declarar la variable. El estado de salida de la sustitución se consume y se descarta. Esto también significa que set -e no detendrá el script. declare, export, typeset y readonly se comportan igual. Escriba local out en una línea y out=$(cmd) en la siguiente. Entonces $? informará del estado real.

#bash#shell-scripting#linux#subshell#coreutils