Curso de Bash desde cero, en español

Bash es el intérprete de órdenes que abre casi cualquier servidor Linux, el que viene en WSL y en Git Bash para Windows y el que corre dentro de la mayoría de los contenedores. Con él se manejan archivos, se lanzan programas y, sobre todo, se automatiza: lo que haces a mano tres veces se convierte en un script que lo hace solo, a las tres de la mañana si hace falta. Este curso empieza desde cero, usa Bash 5.3, la versión actual, y en cada lección enseña la herramienta que más errores evita: ShellCheck, que revisa tus scripts antes de que borren lo que no debían.

La ruta: ocho lecciones

Están pensadas para leerse en orden. Las dos primeras están en esta misma página y las demás tienen la suya. Cada lección tiene un recuadro «El error que te ahorra», con un fallo típico y el aviso real de ShellCheck o de Bash que lo delata, un recuadro «Ojo con la seguridad», al menos un esquema y retos al final con la solución escondida. Al terminar hay un test de 24 preguntas: adivinar qué imprime cada script.

Lección 1. Primeros pasos

Qué es Bash

Una shell es el programa que lee lo que escribes en la terminal, lo interpreta y lanza los programas que pides. La primera shell de Unix que se parecía a las de hoy fue la de Stephen Bourne, en los Laboratorios Bell, que llegó con Unix Version 7 en 1979 y que todo el mundo conoce como sh. Diez años después, el 8 de junio de 1989, Brian Fox publicó para la Free Software Foundation la primera beta de Bash, el Bourne-Again SHell: un sustituto libre de sh, compatible con él y con muchas mejoras. Desde principios de los noventa lo mantiene Chet Ramey, y hoy es la shell por omisión de casi todas las distribuciones de Linux.

No es la única. En macOS la shell por omisión es zsh desde Catalina (2019), aunque el sistema sigue trayendo un Bash 3.2 de 2007. En Debian y Ubuntu, /bin/sh no es Bash sino dash, una shell más pequeña y rápida que solo entiende lo que exige el estándar POSIX; Ubuntu hizo el cambio en 2006 y Debian en 2011. Por eso importa qué intérprete ejecuta tu script, y lo vemos enseguida con el shebang. Las diferencias entre versiones están en Versiones y parches.

Abrir una terminal

  • Linux: cualquier terminal (GNOME Terminal, Konsole…) abre Bash. Comprueba la versión con bash --version.
  • macOS: la app Terminal abre zsh. Para seguir el curso, instala un Bash moderno con Homebrew (brew install bash) y ábrelo escribiendo bash; los scripts funcionarán igual porque llevan su propio shebang.
  • Windows: lo más fiel es WSL, que instala un Ubuntu completo con wsl --install. Git Bash, que viene con Git para Windows, también sirve para casi todo el curso.
  • Con Docker, sin instalar nada: docker run --rm -it ubuntu:26.04 bash.

Lo primero, saber qué tienes. En los bloques de terminal, lo que va detrás de $ es lo que se teclea, y debajo, lo que contesta el sistema:

terminal$ bash --version | head -1
GNU bash, version 5.3.9(1)-release (x86_64-pc-linux-gnu)
$ echo "$BASH_VERSION"
5.3.9(1)-release
$ whoami
ana
$ pwd
/home/ana/proyecto

whoami dice con qué usuario estás y pwd (print working directory), en qué carpeta. Todo el curso se ejecutó como la usuaria ana, dentro de /home/ana/proyecto.

Órdenes y argumentos

Una orden es una palabra seguida de sus argumentos, separados por espacios. Los que empiezan por guion suelen ser opciones que cambian lo que hace la orden:

terminal$ type echo ls cd
echo is a shell builtin
ls is /usr/bin/ls
cd is a shell builtin
$ ls
notas.txt
tareas.txt
$ ls -l --time-style=+ notas.txt
-rw-r--r-- 1 ana ana 56  notas.txt
$ wc -l notas.txt tareas.txt
 3 notas.txt
 2 tareas.txt
 5 total
$ echo Hola,     mundo
Hola, mundo

Fíjate en dos cosas. echo recibió dos argumentos, Hola, y mundo, y los escribió con un solo espacio: los espacios de más no llegan a la orden, porque Bash los usa para separar palabras. Y type cuenta de dónde sale cada orden: echo y cd son órdenes internas (builtins) de Bash, y ls es un programa que está en /usr/bin. Bash lo encontró porque esa carpeta está en la variable PATH. El esquema resume lo que hace Bash cada vez que pulsas Intro:

Lo que hace Bash con cada orden: 1, lee la línea y la divide en palabras; 2, expande variables, sustituciones de órdenes, la virgulilla y los comodines, de modo que asterisco punto txt pasa a ser la lista de archivos; 3, busca la orden entre las funciones, las órdenes internas y las carpetas del PATH; 4, la ejecuta con sus argumentos y espera; 5, guarda el código de salida en la variable interrogación: 0 si fue bien y otro número si noQué hace Bash cada vez que pulsas Intro1Lee la línea y la divide en palabrasls -l *.txt2Expande variables, $( ), ~ y comodines*.txt pasa a ser notas.txt tareas.txt3Busca la orden: función, builtin o PATHls está en /usr/bin/ls4La ejecuta con sus argumentos y esperael programa escribe en la pantalla5Guarda el código de salida en $?0 si fue bien, otro número si no

Los cinco pasos de cada orden. El segundo, la expansión, es el que explica casi todas las sorpresas de Bash. Esquema: decodigo.com.

Las órdenes más útiles del día a día, de cp y mv a find y tar, están en Los comandos de Linux que de verdad se usan. Aquí nos centramos en el lenguaje que las une.

El primer script

Un script es un archivo de texto con órdenes, una por línea, que Bash ejecuta de arriba abajo. Crea hola.sh con tu editor:

#!/usr/bin/env bash
# Mi primer script: saluda y dice dónde está.
echo "Hola, $USER"
echo "Estás en $PWD"

La primera línea es el shebang: #! seguido del intérprete que debe ejecutar el archivo. /usr/bin/env bash busca bash en el PATH, así que funciona aunque esté instalado en otra carpeta, como el de Homebrew en macOS. Las líneas que empiezan por # son comentarios. Ahora, a ejecutarlo:

terminal$ ./hola.sh
bash: ./hola.sh: Permission denied
$ bash hola.sh
Hola, ana
Estás en /home/ana/proyecto
$ chmod +x hola.sh
$ ./hola.sh
Hola, ana
Estás en /home/ana/proyecto
$ hola.sh
bash: hola.sh: command not found

Tres lecciones en cinco órdenes:

  • Un archivo recién creado no tiene permiso de ejecución, y ./hola.sh falla con Permission denied. bash hola.sh sí funciona, porque el que se ejecuta es Bash y el script solo es un archivo que lee.
  • chmod +x le da ese permiso. A partir de ahí, ./hola.sh funciona y el sistema usa el shebang para saber con qué ejecutarlo.
  • Sin el ./ delante, Bash busca hola.sh en las carpetas del PATH, y la carpeta actual no está entre ellas: command not found. Es a propósito, para que nadie pueda colarte un programa llamado ls en una carpeta que visitas.

echo y printf

#!/usr/bin/env bash
nombre="Ana"
echo "Hola, $nombre"
echo -n "Sin salto de línea... "
echo "y ahora sí"
printf '%s tiene %d tareas y un %.1f de nota\n' "$nombre" 3 8.75
printf '%-10s|%5s|\n' "izquierda" "der"
salidaHola, Ana
Sin salto de línea... y ahora sí
Ana tiene 3 tareas y un 8.8 de nota
izquierda |  der|

echo escribe sus argumentos y un salto de línea. Es cómodo, pero sus opciones cambian de una shell a otra (-n y -e no funcionan igual en dash ni en el Bash de macOS). printf es más fiable: el primer argumento es un formato, con %s para texto, %d para enteros y %.1f para decimales, y no añade el salto de línea si no pones \n. Para cualquier cosa que no sea un mensaje sencillo, usa printf.

El código de salida

Cada orden termina con un número, su código de salida, que Bash guarda en la variable $?. Hay que leerlo justo después, porque la orden siguiente lo sobrescribe; por eso aquí va en la misma línea, tras un ;, que separa dos órdenes. 0 significa que todo fue bien y cualquier otro número, que algo falló. Es al revés que en muchos lenguajes, donde 0 es «falso», y es la base de todas las condiciones de Bash, como verás en la lección 3:

terminal$ ls notas.txt; echo "código: $?"
notas.txt
código: 0
$ ls no-existe.txt; echo "código: $?"
ls: cannot access 'no-existe.txt': No such file or directory
código: 2
$ grep -q pan notas.txt; echo "código: $?"
código: 0
$ grep -q queso notas.txt; echo "código: $?"
código: 1

Cada programa decide sus códigos: ls devuelve 2 si no encuentra el archivo y grep, 1 si no encuentra el texto. Un script devuelve el código de la última orden que ejecutó, o el que le digas con exit.

El error que te ahorra: el script escrito en Windows

Si escribes o copias un script en Windows, sus líneas acaban en \r\n en vez de \n. Bash toma ese \r invisible como parte de las palabras, y el error es de lo más confuso: dice que no encuentra un programa llamado bash\r. Aquí creamos un archivo así a propósito, con printf, para ver el fallo, cómo lo explican file y ShellCheck, y cómo se arregla:

terminal$ printf '#!/usr/bin/env bash\r\necho "Hola"\r\n' > windows.sh
$ chmod +x windows.sh
$ ./windows.sh
env: $'bash\r': No such file or directory
env: use -[v]S to pass options in shebang lines
$ file windows.sh
windows.sh: Bourne-Again shell script, ASCII text executable, with CRLF line terminators
$ shellcheck windows.sh

In windows.sh line 1:
#!/usr/bin/env bash
                   ^-- SC1017 (error): Literal carriage return. Run script through tr -d '\r' .

In windows.sh line 2:
echo "Hola"
           ^-- SC1017 (error): Literal carriage return. Run script through tr -d '\r' .

For more information:
  https://www.shellcheck.net/wiki/SC1017 -- Literal carriage return. Run scri...
$ sed -i 's/\r$//' windows.sh
$ ./windows.sh
Hola

sed quita el \r del final de cada línea (también sirve dos2unix, si lo tienes). Para que no vuelva a pasar, configura tu editor para guardar con saltos de línea de Unix (LF): en Visual Studio Code está abajo a la derecha, donde pone CRLF.

Lección 2. Variables, comillas y expansiones

Variables

Una variable se crea al darle un valor, sin declararla antes, y se lee poniendo $ delante del nombre. La regla que más tropiezos causa: sin espacios alrededor del =.

terminal$ nombre=Ana
$ echo "Hola, $nombre"
Hola, Ana
$ edad=30
$ echo "$nombre tiene $edad años"
Ana tiene 30 años
$ nombre = Luis
bash: nombre: command not found
$ echo "Archivo de $nombre: ${nombre}_notas.txt"
Archivo de Ana: Ana_notas.txt
$ echo "Archivo de $nombre: $nombre_notas.txt"
Archivo de Ana: .txt

Con espacios, Bash entiende nombre = Luis como la orden nombre con dos argumentos, = y Luis, y no existe ninguna orden con ese nombre. La última línea enseña para qué sirven las llaves: $nombre_notas busca una variable llamada nombre_notas, que no existe, y Bash la sustituye por nada, sin quejarse. ${nombre}_notas deja claro dónde acaba el nombre.

Las variables de Bash guardan siempre texto. No hay enteros ni decimales, aunque Bash sabe hacer cuentas con texto que parece un número, como verás más abajo. Por costumbre, las variables propias van en minúsculas y las del entorno, como HOME, USER o PATH, en mayúsculas.

La regla de las comillas

Este es el tema más importante de todo el curso, y el origen de la mayoría de los errores. Hay tres formas de escribir un texto, y Bash hace cosas distintas con cada una:

#!/usr/bin/env bash
archivo="Mi informe final.txt"
echo 'Comillas simples: $archivo y $(whoami)'
echo "Comillas dobles:  $archivo y $(whoami)"
printf '[%s] ' $archivo; echo
printf '[%s] ' "$archivo"; echo
patron="*.txt"
echo Sin comillas: $patron
echo "Con comillas: $patron"
salidaComillas simples: $archivo y $(whoami)
Comillas dobles:  Mi informe final.txt y ana
[Mi] [informe] [final.txt] 
[Mi informe final.txt] 
Sin comillas: Mi informe final.txt notas.txt
Con comillas: *.txt
  • Comillas simples: el texto va tal cual, sin sustituir nada. Útiles para escribir $ o * literales.
  • Comillas dobles: se sustituyen $variables y $(órdenes), pero el resultado se queda como un solo argumento, con sus espacios y sus asteriscos.
  • Sin comillas: después de sustituir, Bash parte el resultado en palabras por los espacios y expande los comodines. Por eso printf recibió tres argumentos en vez de uno, y $patron se convirtió en la lista de archivos .txt de la carpeta.

Tabla de lo que hace Bash según las comillas. Sin comillas: sustituye variables, ejecuta órdenes, parte en palabras por los espacios y expande asteriscos a nombres de archivo. Con comillas dobles: sustituye variables y ejecuta órdenes, pero no parte en palabras ni expande asteriscos. Con comillas simples: no hace nada de eso, el texto queda literal. Regla de oro: pon las variables entre comillas dobles salvo que sepas por qué noQué hace Bash con lo que va entre comillassin comillas$archivocomillas dobles"$archivo"comillas simples'$archivo'Sustituye $variablessísínoEjecuta $(órdenes)sísínoParte en palabras por los espaciossínonoExpande * y ? a nombres de archivosínonoRegla de oro: pon las variables entre comillas dobles salvo que sepas por qué no.

Lo que hace cada tipo de comillas. Las dos últimas filas, en rojo, son las que convierten una variable sin comillas en un error. Esquema: decodigo.com.

La regla práctica cabe en una línea: pon siempre las variables entre comillas dobles, "$archivo", salvo que quieras de verdad que se partan o que se expandan los comodines. ShellCheck avisa cada vez que se te olvida. Esto es lo que dice del script de arriba:

shellcheckIn comillas.sh line 3:
echo 'Comillas simples: $archivo y $(whoami)'
     ^-- SC2016 (info): Expressions don't expand in single quotes, use double quotes for that.

In comillas.sh line 5:
printf '[%s] ' $archivo; echo
               ^------^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
printf '[%s] ' "$archivo"; echo

In comillas.sh line 8:
echo Sin comillas: $patron
                   ^-----^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
echo Sin comillas: "$patron"

For more information:
  https://www.shellcheck.net/wiki/SC2016 -- Expressions don't expand in singl...
  https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...

El aviso SC2086 es, de lejos, el que más vas a ver. Su ficha en shellcheck.net lo explica con más ejemplos.

Ojo con la seguridad: un nombre de archivo con espacios puede borrar otro

Las comillas no son una cuestión de estilo. Aquí hay dos archivos, informe 2025.txt, que queremos borrar, y 2025.txt, que no. Mira qué hace rm sin comillas:

#!/usr/bin/env bash
borrar="informe 2025.txt"
ls
rm $borrar
echo "--- después:"
ls
salida2025.txt
informe 2025.txt
rm_sin_comillas.sh
rm: cannot remove 'informe': No such file or directory
--- después:
informe 2025.txt
rm_sin_comillas.sh

rm recibió dos argumentos, informe y 2025.txt: el primero no existía, pero el segundo sí, y lo borró. El archivo que queríamos sigue ahí. Con rm "$borrar" no habría pasado. Ahora imagina la variable con un nombre que llega de fuera, de un formulario o de otro programa.

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

Expansiones: valores por omisión, órdenes y cuentas

#!/usr/bin/env bash
nombre="Ana"
vacia=""
echo "1: ${nombre}"
echo "2: ${sin_definir:-desconocido}"
echo "3: ${vacia:-vacía}"
echo "4: ${#nombre} letras"
lineas=$(wc -l < notas.txt)
echo "5: notas.txt tiene $lineas líneas"
echo "6: $(( 7 / 2 )) y resto $(( 7 % 2 ))"
echo "7: $(( 2 ** 10 ))"
precio=1299
echo "8: $(( precio * 3 / 100 ))"
echo "9: $(echo 'scale=2; 7 / 2' | bc)"
salida1: Ana
2: desconocido
3: vacía
4: 3 letras
5: notas.txt tiene 3 líneas
6: 3 y resto 1
7: 1024
8: 38
9: 3.50
  • ${var:-valor} da valor si la variable no existe o está vacía, sin cambiarla. Es la forma de poner valores por omisión.
  • ${#var} es la longitud del texto.
  • $(orden) es la sustitución de órdenes: ejecuta la orden y pone en su lugar lo que escribió, sin el salto de línea final. Verás también la forma antigua, con comillas invertidas `orden`; la de $( ) es más legible y se puede anidar.
  • $(( )) hace cuentas con enteros: 7 / 2 da 3. Dentro, las variables no necesitan $. Para decimales hay que llamar a otro programa, como bc.

Leer del teclado

#!/usr/bin/env bash
printf '¿Cómo te llamas? '
read -r nombre
printf '¿En qué año naciste? '
read -r anio
echo
echo "Hola, $nombre. En 2026 cumples $(( 2026 - anio )) años."
salida (con «Ana» y «1990» como respuestas)¿Cómo te llamas? ¿En qué año naciste? 
Hola, Ana. En 2026 cumples 36 años.

read -r variable lee una línea entera de la entrada y la guarda en la variable. La -r evita que las barras invertidas se coman caracteres: úsala siempre. En la salida no se ven las respuestas porque al ejecutar el curso llegaron desde un archivo y no desde el teclado; en tu terminal sí las verás. Ojo: si en el año escribes letras, $(( )) no da error, las toma como el nombre de una variable vacía y calcula con 0. Validar lo que llega es parte de la lección 3.

Variables de entorno

Una variable normal solo existe en la shell donde la creas. Para que la vean los programas que lanzas desde ella hay que exportarla, y entonces pasa a ser una variable de entorno:

terminal$ color=azul
$ bash -c 'echo "Dentro: [$color]"'
Dentro: []
$ export color
$ bash -c 'echo "Dentro: [$color]"'
Dentro: [azul]
$ TZ=America/Mexico_City date -d '2026-10-30 18:00 UTC' '+%H:%M en Ciudad de México'
12:00 en Ciudad de México
$ echo "$HOME y $USER"
/home/ana y ana

La línea de TZ=… enseña un truco muy usado: poner VARIABLE=valor delante de una orden le da esa variable solo a esa orden, sin tocar la shell. Las variables de entorno más importantes son HOME (tu carpeta personal), USER, PATH (dónde se buscan las órdenes), LANG (idioma) y TZ (zona horaria).

El error que te ahorra: el espacio que convierte una asignación en una orden

En una terminal, nombre = Luis da un error que se ve. En un script, el error pasa por delante y el script sigue, con la variable vacía:

#!/usr/bin/env bash
destino = /tmp/copias
echo "Destino: [$destino]"
salida./espacios.sh: line 2: destino: command not found
Destino: []

ShellCheck lo detecta antes de ejecutar nada, y además se da cuenta de que destino nunca recibe un valor:

shellcheckIn espacios.sh line 2:
destino = /tmp/copias
        ^-- SC2283 (error): Remove spaces around = to assign (or use [ ] to compare, or quote '=' if literal).

In espacios.sh line 3:
echo "Destino: [$destino]"
                ^------^ SC2154 (warning): destino is referenced but not assigned.

For more information:
  https://www.shellcheck.net/wiki/SC2283 -- Remove spaces around = to assign ...
  https://www.shellcheck.net/wiki/SC2154 -- destino is referenced but not ass...

Retos

Reto 1. Escribe un script que salude a quien le pases como primer argumento ($1) y, si no le pasas ninguno, salude al «mundo». Una sola línea con ${…:-…} basta.

Ver la solución
#!/usr/bin/env bash
echo "Hola, ${1:-mundo}"
./reto1.shHola, mundo
./reto1.sh Luis
./reto1.sh LuisHola, Luis

$1 es el primer argumento del script; si no existe, ${1:-mundo} pone «mundo». Los argumentos se ven a fondo en la lección 4.

Reto 2. Lee una cantidad de segundos con read y escríbela en horas, minutos y segundos (por ejemplo, 3725 son 1 h 2 min 5 s).

Ver la solución
#!/usr/bin/env bash
read -r total
h=$(( total / 3600 ))
m=$(( total % 3600 / 60 ))
s=$(( total % 60 ))
echo "$total segundos son $h h $m min $s s"
salida (con 3725)3725 segundos son 1 h 2 min 5 s

La división entera y el resto hacen todo el trabajo: 3725 / 3600 da 1 hora, lo que sobra (125) entre 60 da 2 minutos y 3725 % 60, 5 segundos.

Reto 3. ¿Qué escribe este script? Piénsalo antes de mirar: a=5, b="$a+1", y después echo "$b" y echo "$(( a + 1 ))".

Ver la solución
#!/usr/bin/env bash
a=5
b="$a+1"
echo "$b"
echo "$(( a + 1 ))"
salida5+1
6

Las variables son texto: b guarda los tres caracteres 5+1, sin calcular nada. Solo dentro de $(( )) Bash hace cuentas.

Cómo está hecho este curso

Cada script se ejecutó con GNU bash, version 5.3.9(1)-release (x86_64-pc-linux-gnu) en un contenedor limpio de Ubuntu 26.04, sin acceso a la red, con la usuaria ana y la zona horaria UTC, y se revisó con ShellCheck 0.11. Las salidas, los errores de Bash y los avisos de ShellCheck están copiados de esa ejecución. Los mensajes salen en inglés, como en la mayoría de los servidores. Las órdenes de GNU (sed, date, grep) son las de Linux; en macOS algunas opciones cambian, y lo señalamos cuando importa.

¿Buscas una receta concreta en vez de una lección? En Ejemplos de Bash hay tareas resueltas: leer archivos, renombrar en lote, procesar registros, copias de seguridad, cron… Y antes de poner un script en un servidor, lee Los fallos de seguridad típicos de los scripts de Bash.