1. printf a fondo
Desde el tema 4, el curso usa printf para mostrar datos. Su primer argumento es un formato, con marcas que indican dónde y cómo va cada dato; los demás argumentos son los datos. Estas son las marcas que más se usan en informes:
| Marca | Muestra | Ejemplo | Resultado |
|---|---|---|---|
%s | Un texto. | printf '%s' hola | hola |
%-10s | Un texto alineado a la izquierda, en 10 posiciones. | printf '[%-10s]' hola | [hola ] |
%10s | Alineado a la derecha. | printf '[%10s]' hola | [ hola] |
%d, %5d | Un entero, con ancho opcional. | printf '%5d' 42 | 42 |
%05d | Un entero completado con ceros. | printf '%05d' 42 | 00042 |
%.2f, %8.2f | Un decimal con dos cifras, con ancho opcional. | printf '%8.2f' 3.14159 | 3.14 |
%x, %o | Un entero en hexadecimal o en octal. | printf '%x' 255 | ff |
%% | Un signo de porcentaje. | printf '%d %%' 84 | 84 % |
%*s | El ancho viene como argumento. | printf '[%*s]' 6 x | [ x] |
Tres comportamientos de printf resultan muy útiles en informes:
- Si hay más datos que marcas, el formato se repite.
printf '%s=%s\n' a 1 b 2produce dos líneas. Con un array,printf ' - %s\n' "${lista[@]}"muestra cada elemento como una viñeta. - Si hay menos datos que marcas, las que faltan quedan vacías (o en cero, para los números), sin error.
- Con
-v, el resultado va a una variable en lugar de a la salida, sin crear un proceso, como vimos en el tema 19. Combinado con una expansión, dibuja líneas de cualquier largo:printf -v linea '%*s' 40 ''; echo "${linea// /-}".
Y recuerda la advertencia del tema 7: el printf de Bash respeta la configuración regional para los decimales. En un sistema en español, %.2f espera una coma decimal en el dato; si el número viene con punto, conviene ejecutarlo con LC_NUMERIC=C o dar formato en awk.
2. Anchos de columna y letras acentuadas
Los anchos de printf tienen un límite que se nota enseguida en español: el printf de Bash cuenta bytes, no caracteres. En UTF-8, una letra acentuada o una eñe ocupan dos bytes, de modo que una palabra con tilde recibe un espacio menos de relleno y la columna se desalinea:
$ printf '[%-8s]\n' anio año
[anio ]
[año ]
Para textos que pueden tener acentos, como nombres, descripciones o los propios encabezados de las columnas, hay dos soluciones: dejar la columna con texto libre al final de la línea, donde no importa su ancho, como hicieron los scripts de los temas anteriores con los nombres de archivo; o dejar que alinee column, que en Linux sí cuenta caracteres.
3. Tablas con column
column -t recibe líneas con campos separados y las alinea en columnas, calculando el ancho de cada una a partir de su contenido. Con -s se indica el separador; un tabulador es la elección más segura, porque no aparece en los datos:
$ printf 'Archivo\tTamaño\tDías\n' > tabla.tsv
$ printf 'web-2026-10-14.tar.gz\t45.7 MiB\t0\nnotas.zip\t50.7 KiB\t20\n' >> tabla.tsv
$ column -t -s $'\t' tabla.tsv
Archivo Tamaño Días
web-2026-10-14.tar.gz 45.7 MiB 0
notas.zip 50.7 KiB 20
La ventaja sobre printf es que no hay que conocer los anchos de antemano: column lee todo y ajusta cada columna al valor más largo. La contracara es que necesita tener toda la tabla antes de mostrar la primera línea, y que descarta las líneas vacías. Por eso se usa solo para la tabla, no para el informe entero, como hace el script de la sección 12. El separador por tabulador permite además que los campos tengan espacios, como 45.7 MiB.
4. Documentos en línea
Para escribir un bloque de varias líneas, como el encabezado de un informe, un mensaje de ayuda o una página HTML, una sucesión de echo es incómoda. Un documento en línea (here document) pasa un bloque de texto, escrito directamente en el script, como entrada estándar de un comando:
cat <<FIN
Informe de respaldos — $(hostname)
Generado el $(date '+%Y-%m-%d %H:%M') por $USER.
FIN
<<FIN indica que lo que sigue, hasta una línea que contenga solo FIN, es la entrada del comando. La palabra de cierre puede ser cualquiera; FIN y EOF son las habituales. Ya usamos esta forma para los mensajes de uso en los temas 13, 20 y 21.
La línea de cierre debe contener la palabra sola, sin espacios antes ni después. Un espacio de más, o una sangría, hace que Bash no la reconozca y siga leyendo el documento hasta el final del archivo, con un aviso del tipo here-document delimited by end-of-file.
5. Expandir o no expandir
Dentro de un documento en línea, Bash expande las variables, las sustituciones de comandos y la aritmética, como dentro de comillas dobles. Si se entrecomilla la palabra de apertura, el texto se toma literalmente:
| Forma | Efecto | Uso típico |
|---|---|---|
<<FIN | Expande $var, $( ) y $(( )). | Informes, mensajes y plantillas con datos. |
<<'FIN' | Texto literal: nada se expande. | Escribir un script, código o texto con muchos $. |
<<-FIN | Quita los tabuladores al comienzo de cada línea. | Sangrar el documento dentro de una función o un if. |
$ nombre=Ana
$ cat <<FIN
> Hola, $nombre. Es $(date +%Y).
> FIN
Hola, Ana. Es 2026.
$ cat <<'FIN'
> Hola, $nombre. Es $(date +%Y).
> FIN
Hola, $nombre. Es $(date +%Y).
La forma literal es la adecuada cuando un script genera otro script, como hizo nuevo-script en el tema 2, o un archivo de configuración con variables que deben quedar escritas tal cual. Dentro de un documento que sí expande, un $ literal se escribe \$.
La forma <<- solo quita tabuladores, no espacios. Como la guía de estilo del curso usa espacios para la sangría, en la práctica es más simple escribir el documento sin sangría, aunque esté dentro de una función.
6. A dónde enviar un documento en línea
Un documento en línea es la entrada estándar de un comando, y puede ser la de cualquiera. Algunos usos habituales:
cat > /etc/app/config.ini <<FIN # escribir un archivo
[servidor]
puerto = $PUERTO
FIN
ssh servidor 'bash -s' <<'FIN' # ejecutar un bloque en otra máquina
df -h /
systemctl is-active nginx
FIN
psql -d ventas <<FIN # consultas a una base de datos
SELECT count(*) FROM pedidos WHERE fecha = '$hoy';
FIN
mail -s "Informe del $(date +%F)" admin@example.com < informe.txt # o con un archivo
El ejemplo de psql tiene la misma trampa que vimos con sed en el tema 29: insertar una variable en el texto de una consulta es seguro solo si su valor está controlado. Si viene del usuario, puede alterar la consulta; las bases de datos tienen mecanismos propios, como las variables de psql, para pasar valores aparte.
7. Plantillas
Una plantilla es un texto con huecos que se completan con datos: la configuración de un servidor web para cada sitio, un mensaje de bienvenida para cada usuario, un archivo de servicio para cada aplicación. Hay tres formas de manejarlas en Bash, según dónde esté la plantilla:
| Forma | La plantilla está | Cuándo |
|---|---|---|
| Un documento en línea con variables | Dentro del script. | Plantillas breves, que cambian junto con el script. |
envsubst | En un archivo aparte, con ${VARIABLE}. | Plantillas que mantiene otra persona, o que son largas. |
Reemplazos con ${texto//marca/valor} | En un archivo, con marcas propias como {{NOMBRE}}. | Cuando la plantilla tiene $ que no deben tocarse. |
generar_sitio() {
local dominio="$1" raiz="$2"
cat <<FIN
server {
listen 80;
server_name $dominio;
root $raiz;
access_log /var/log/nginx/$dominio.log;
}
FIN
}
generar_sitio ejemplo.com /srv/ejemplo > /etc/nginx/sites-available/ejemplo.com
Lo que no debe hacerse es leer una plantilla de un archivo y evaluarla con eval para que se expandan sus variables: eval ejecutaría también cualquier $(…) que contenga, con el mismo riesgo que source en el tema 30.
8. envsubst
envsubst, del paquete gettext, lee un texto y reemplaza las referencias $VARIABLE o ${VARIABLE} por los valores de las variables de entorno. No ejecuta nada: solo reemplaza, de modo que es la forma segura de completar una plantilla guardada en un archivo.
Hola, ${NOMBRE}:
Tu cuenta en ${SERVIDOR} ya está lista. El costo mensual es de $5.
$ NOMBRE=Ana SERVIDOR=web01 envsubst '$NOMBRE $SERVIDOR' < bienvenida.tmpl
Hola, Ana:
Tu cuenta en web01 ya está lista. El costo mensual es de $5.
Dos detalles: las variables deben estar en el entorno, exportadas o definidas para ese comando, como en el ejemplo; y conviene indicar, como primer argumento, la lista de variables a reemplazar. Sin esa lista, envsubst reemplaza cualquier $algo, y una variable que no existe se convierte en vacío: el $5 del ejemplo desaparecería. Con la lista, el resto de los $ quedan intactos.
9. Generar HTML sin romperlo
Un informe en HTML se ve bien en un navegador, se puede enviar por correo y se puede publicar en un servidor web. Pero tiene una exigencia: los datos que se insertan en él deben escaparse. Un nombre de archivo como notas <viejas> & más.zip, insertado tal cual, rompe la página, porque el navegador interpreta <viejas> como una etiqueta. Y si los datos vienen de otras personas, insertarlos sin escapar permite inyectar código en la página.
Escapar consiste en reemplazar los caracteres especiales de HTML por sus entidades:
# escapar_html TEXTO — reemplaza los caracteres especiales de HTML.
escapar_html() {
local t="$1"
t="${t//&/\&}" # primero el &, para no escapar los que agregamos
t="${t//</\<}"
t="${t//>/\>}"
t="${t//\"/\"}"
printf '%s' "$t"
}
$ escapar_html 'a < b & "c" > d'; echo
a < b & "c" > d
Desde Bash 5.2, en el reemplazo de ${var//patrón/reemplazo} un & significa «lo que coincidió», como en sed. Sin la barra, ${t//</<} produciría <lt; en lugar de <. La barra lo vuelve literal, y funciona igual en las versiones anteriores, que no tenían este comportamiento. Es el detalle que señalamos en el tema 6.
El orden importa: el & se escapa primero, porque si no, los & de las entidades que se agregan después se escaparían otra vez. Para Markdown la situación es más benigna: el texto se ve tal cual, aunque caracteres como | pueden romper una tabla, y * o _ pueden activar formatos.
10. Separar los datos de la presentación
Un informe tiene dos partes que conviene no mezclar: obtener y calcular los datos, y presentarlos en un formato. Mezclarlas lleva a scripts donde cada línea de cálculo está pegada a un printf, y agregar un segundo formato obliga a duplicar todo.
- Reunir los datos en variables y arrays: las filas de la tabla, los totales, las fechas. Sin mostrar nada. calcular
- Presentarlos, con una función por formato:
informe_texto,informe_md,informe_html. Cada una solo lee los datos ya reunidos y les da forma. presentar - Entregar el resultado: a la salida, a un archivo o por correo. entregar
Con esa estructura, agregar un formato es agregar una función, y la elección se resuelve en una línea: si las funciones se llaman informe_ seguido del nombre del formato, "informe_$formato" ejecuta la que corresponde. Como el formato se validó antes contra una lista cerrada, no hay riesgo de ejecutar otra cosa.
11. Entregar el informe
Un informe que va a un archivo, especialmente uno que publica un servidor web o que lee otro programa, debe escribirse de forma atómica, como en el tema 24: en un temporal del mismo directorio, que se renombra al terminar. Así, nadie lee nunca un informe a medio escribir, y si el script falla, queda el informe anterior.
Para enviarlo por correo, el comando mail (del paquete mailutils o bsd-mailx), si el equipo tiene configurado el envío, recibe el cuerpo por la entrada estándar:
informe-respaldos /respaldos | mail -s "Respaldos de $(hostname) — $(date +%F)" admin@example.com
En muchos casos ni siquiera hace falta: como veremos en el tema 34, cron envía por correo todo lo que una tarea escribe en su salida. Un informe programado en cron llega solo.
12. Un script completo
Este script genera un informe de los respaldos de un directorio: nombre, tamaño, antigüedad y estado de cada uno, marcando los que superan una antigüedad, y un resumen. Puede producirlo en texto para la terminal, en Markdown o en HTML, y escribirlo en un archivo de forma atómica.
#!/usr/bin/env bash
#
# informe-respaldos — informe de los respaldos de un directorio, en texto, Markdown o HTML.
#
# Uso: informe-respaldos [-f texto|md|html] [-d DÍAS] [-o ARCHIVO] DIRECTORIO
# -f FORMATO formato del informe (texto)
# -d DÍAS antigüedad a partir de la cual un respaldo se marca como viejo (7)
# -o ARCHIVO escribir el informe en ARCHIVO en lugar de la salida estándar
#
set -euo pipefail
uso() {
echo "Uso: ${0##*/} [-f texto|md|html] [-d DÍAS] [-o ARCHIVO] DIRECTORIO" >&2
exit 2
}
# a_legible BYTES — «1.5 KiB», «2.7 MiB»… (tema 19)
a_legible() {
local bytes="$1"
local -a unidades=(B KiB MiB GiB TiB)
local i=0 decimas=$(( bytes * 10 ))
while (( decimas >= 10240 && i < ${#unidades[@]} - 1 )); do
decimas=$(( decimas / 1024 ))
i=$(( i + 1 ))
done
if (( i == 0 )); then
printf '%d B\n' "$bytes"
else
printf '%d.%d %s\n' $(( decimas / 10 )) $(( decimas % 10 )) "${unidades[i]}"
fi
}
# escapar_html TEXTO — reemplaza los caracteres especiales de HTML.
escapar_html() {
local t="$1"
t="${t//&/\&}"
t="${t//</\<}"
t="${t//>/\>}"
t="${t//\"/\"}"
printf '%s' "$t"
}
formato=texto
dias=7
salida=""
while getopts ":f:d:o:" opcion; do
case "$opcion" in
f) formato="$OPTARG" ;;
d) dias="$OPTARG" ;;
o) salida="$OPTARG" ;;
*) uso ;;
esac
done
shift $(( OPTIND - 1 ))
(( $# == 1 )) || uso
dir="$1"
[[ "$formato" =~ ^(texto|md|html)$ ]] || uso
[[ "$dias" =~ ^[0-9]+$ ]] || uso
[[ -d "$dir" ]] || { echo "${0##*/}: no existe el directorio $dir" >&2; exit 1; }
# 1. Reunir los datos: una fila por respaldo, del más nuevo al más viejo.
ahora=$(date +%s)
total=0
viejos=0
tabla=() # nombre, tamaño, días y estado, separados por tabuladores
while IFS=$'\t' read -r epoca bytes nombre; do
edad=$(( (ahora - ${epoca%.*}) / 86400 ))
estado=ok
if (( edad > dias )); then
estado=viejo
viejos=$(( viejos + 1 ))
fi
total=$(( total + bytes ))
tabla+=("$nombre"$'\t'"$(a_legible "$bytes")"$'\t'"$edad"$'\t'"$estado")
done < <(find "$dir" -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.tgz' -o -name '*.zip' \) \
-printf '%T@\t%s\t%f\n' | sort -rn)
titulo="Respaldos en $dir"
resumen="${#tabla[@]} respaldos, $(a_legible "$total") en total, $viejos con más de $dias días."
generado=$(date '+%Y-%m-%d %H:%M')
# 2. Presentarlos: una función por formato.
informe_texto() {
printf '%s\n\n' "$titulo"
{
printf 'Archivo\tTamaño\tDías\tEstado\n'
(( ${#tabla[@]} == 0 )) || printf '%s\n' "${tabla[@]}"
} | column -t -s $'\t'
printf '\n%s\nGenerado el %s.\n' "$resumen" "$generado"
}
informe_md() {
local fila nombre tam edad estado
printf '# %s\n\n| Archivo | Tamaño | Días | Estado |\n|---|---:|---:|---|\n' "$titulo"
for fila in "${tabla[@]}"; do
IFS=$'\t' read -r nombre tam edad estado <<< "$fila"
printf '| %s | %s | %s | %s |\n' "$nombre" "$tam" "$edad" "$estado"
done
printf '\n%s \nGenerado el %s.\n' "$resumen" "$generado"
}
informe_html() {
local fila nombre tam edad estado
cat <<FIN
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>$(escapar_html "$titulo")</title>
<style>
body { font-family: sans-serif; margin: 2rem; }
th, td { padding: .3rem .8rem; text-align: left; }
.viejo { color: #b00; }
</style>
</head>
<body>
<h1>$(escapar_html "$titulo")</h1>
<table>
<tr><th>Archivo</th><th>Tamaño</th><th>Días</th><th>Estado</th></tr>
FIN
for fila in "${tabla[@]}"; do
IFS=$'\t' read -r nombre tam edad estado <<< "$fila"
printf '<tr class="%s"><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>\n' \
"$estado" "$(escapar_html "$nombre")" "$tam" "$edad" "$estado"
done
cat <<FIN
</table>
<p>$(escapar_html "$resumen")<br>Generado el $generado.</p>
</body>
</html>
FIN
}
# 3. Entregarlos: en la salida, o en un archivo de forma atómica.
if [[ -n "$salida" ]]; then
tmp=$(mktemp "$salida.XXXXXX")
trap 'rm -f "$tmp"' EXIT
"informe_$formato" > "$tmp"
chmod 644 "$tmp"
mv -f -- "$tmp" "$salida"
echo "Informe escrito en $salida" >&2
else
"informe_$formato"
fi
$ informe-respaldos /respaldos
Respaldos en /respaldos
Archivo Tamaño Días Estado
web-2026-10-14.tar.gz 45.7 MiB 0 ok
web-2026-10-13.tar.gz 44.8 MiB 1 ok
notas <viejas> & más.zip 50.7 KiB 20 viejo
3 respaldos, 90.6 MiB en total, 1 con más de 7 días.
Generado el 2026-10-14 13:14.
$ informe-respaldos -f html -o /srv/web/respaldos.html /respaldos
Informe escrito en /srv/web/respaldos.html
$ grep 'class="viejo"' /srv/web/respaldos.html
<tr class="viejo"><td>notas <viejas> & más.zip</td><td>50.7 KiB</td><td>20</td><td>viejo</td></tr>
- Tres partes separadas: el bucle reúne los datos en el array
tablay en los totales; las tres funciones los presentan; el final del script los entrega. Agregar un formato CSV, por ejemplo, sería agregar una funcióninforme_csvy la palabracsven la validación. - Las filas se guardan con tabuladores, que sirven directamente como separador para
columnen el formato de texto, y se vuelven a separar conIFS=$'\t' readen los otros dos. - El nombre de archivo problemático, con
<,>y&, aparece tal cual en el texto y en Markdown, y escapado en HTML, donde la página no se rompe. - Los documentos en línea escriben el comienzo y el final de la página HTML de una vez, con las expansiones que hacen falta, y
printfescribe cada fila. find -printf '%T@'da la fecha de modificación en segundos, con decimales;${epoca%.*}los quita para la aritmética entera.
13. Errores frecuentes
| Escribiste | Qué pasa | Corrección |
|---|---|---|
printf "$texto" | Un % o una barra en el texto se interpretan como formato. | printf '%s' "$texto" |
printf '%-10s' con palabras acentuadas | La columna se desalinea. | Texto libre al final, o column -t. |
| La palabra de cierre con sangría o espacios | El documento no termina hasta el final del archivo. | Sola, al comienzo de la línea. |
<<FIN para generar un script | Sus variables se expanden al generarlo. | <<'FIN'. |
<<-FIN con sangría de espacios | Solo quita tabuladores. | Escribir el documento sin sangría. |
eval para completar una plantilla | Ejecuta cualquier $(…) que contenga. | envsubst con una lista de variables. |
| Datos sin escapar en HTML | La página se rompe, o se inyecta código. | escapar_html. |
${t//</<} en Bash 5.2 | Produce <lt;. | ${t//</\<} |
| Escribir el informe directamente en su destino | Otros pueden leerlo a medio escribir. | Temporal y mv. |
14. Resumen
printfda formato con marcas como%-10s,%5dy%8.2f; repite el formato si hay más datos, y con-vescribe en una variable.- Los anchos de
printfcuentan bytes: las letras acentuadas desalinean las columnas. column -t -s $'\t'alinea una tabla calculando el ancho de cada columna.- Un documento en línea,
<<FIN, pasa un bloque de texto como entrada de un comando, y expande variables y sustituciones. <<'FIN'toma el texto literalmente;<<-FINquita tabuladores iniciales.- Un documento en línea puede escribir un archivo, ejecutarse en otra máquina o alimentar una base de datos.
- Las plantillas se completan con documentos en línea, con
envsubsty una lista de variables, o con reemplazos de marcas; nunca coneval. - Los datos que van a HTML se escapan, empezando por el
&; en Bash 5.2, el&del reemplazo se escribe\&. - Un informe se construye en tres pasos: reunir los datos, presentarlos con una función por formato y entregarlos.
- Un informe que va a un archivo se escribe de forma atómica.
15. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Qué muestra printf '%-6s|%4d|\n' ab 7 cd 42?
Dos líneas, porque el formato se repite: ab | 7| y cd | 42|.
2. ¿Por qué printf "$mensaje" es peligroso?
Porque el contenido de la variable se usa como formato: un % o una barra invertida en el mensaje se interpretan. Se escribe printf '%s\n' "$mensaje".
3. ¿Por qué se desalinea una columna con printf '%-10s' cuando los datos tienen tildes?
Porque printf cuenta bytes, y en UTF-8 una letra acentuada ocupa dos. La palabra recibe un espacio menos de relleno por cada letra acentuada.
4. ¿Qué ventaja tiene column -t sobre printf para una tabla?
Calcula el ancho de cada columna a partir de los datos, sin conocerlo de antemano, y en Linux cuenta caracteres, de modo que las letras acentuadas no desalinean.
5. ¿Qué diferencia hay entre <<FIN y <<'FIN'?
Con <<FIN se expanden las variables, las sustituciones de comandos y la aritmética. Con <<'FIN', el texto se toma literalmente.
6. ¿Qué pasa si la línea de cierre de un documento en línea tiene un espacio al final?
Bash no la reconoce como cierre, y sigue leyendo el documento hasta el final del archivo, con un aviso.
7. ¿Por qué conviene pasarle a envsubst la lista de variables a reemplazar?
Porque sin ella reemplaza cualquier $algo del texto, y las variables que no existen se convierten en vacío. Con la lista, solo se reemplazan esas, y los demás $ quedan intactos.
8. ¿Por qué el & se escapa primero al escapar HTML?
Porque las entidades que se agregan después, como <, contienen un &. Si se escapara al final, esos & se convertirían otra vez en &.
9. ¿Por qué en Bash 5.2 hay que escribir \& en el reemplazo de ${var//…/…}?
Porque desde esa versión un & en el reemplazo representa el texto que coincidió con el patrón, como en sed. La barra lo vuelve literal.
10. ¿Qué ventaja tiene separar el cálculo de los datos de su presentación?
Que se puede presentar los mismos datos en varios formatos sin repetir el cálculo, y agregar un formato es agregar una función que solo da forma a datos ya reunidos.
Para practicar
- Escribe con
printfuna tabla de tres columnas con datos sin acentos, y otra con nombres acentuados. Compárala con la misma tabla pasada porcolumn -t. - Escribe un documento en línea que genere un pequeño script, con
<<'FIN', y comprueba que sus variables quedaron sin expandir. - Crea una plantilla de mensaje con
${NOMBRE}y un precio con$, y complétala conenvsubstcon y sin la lista de variables. - Prueba la función
escapar_htmlcon y sin las barras antes de&, y comprueba tu versión de Bash conecho $BASH_VERSION. - Agrega a
informe-respaldosun formatocsvcon su función. - Genera el informe en HTML, ábrelo en un navegador, y prográmalo para que se regenere cada hora en un directorio que sirva un servidor web, como el de
servidor-webdel tema 13.