Saltos de línea CRLF y LF: qué se rompe en realidad
La diferencia entre CRLF y LF es de un solo byte. LF es un único \n (0x0A) y termina las líneas en Linux y macOS. CRLF son dos bytes, \r\n (0x0D 0x0A), y termina las líneas en Windows.
Ahora viene la parte que casi todos los artículos cuentan mal. Un script de shell con saltos de línea CRLF normalmente no falla. Se ejecuta, imprime lo que esperabas y sale con código 0. Lo que se rompe es el valor guardado en una variable, porque la asignación se quedó con el \r final y nadie se quejó.
Lo medido son cuatro modos, empezando por el que más cuesta notar:
- Éxito silencioso: la salida se ve bien, una variable carga un
\rinvisible, código de salida 0. - Un error que no cambia nada:
: command not founden una línea en blanco, el script llega hasta el final, código de salida 0. - Error de sintaxis:
if,fory las definiciones de funciones se rompen, código de salida 2. bad interpreter: el shebang arrastra el\r, código de salida 126.
Antes que nada, ejecuta file yourfile. Una sola línea de salida te dice de qué lado de CRLF vs LF estás.
Todo lo que sigue se midió en macOS (Darwin arm64) con bash 3.2.57(1)-release, zsh 5.9, dash, git 2.55.0, node v26.7.0 y Python 3.14.6, y se verificó de nuevo en Linux mediante
docker run --rm bash:5, que es GNU bash 5.3.15(1)-release (aarch64-unknown-linux-musl).
1. CRLF, LF y CR: qué bytes son exactamente
CRLF es la abreviatura de carriage return line feed (retorno de carro y avance de línea), y eso son literalmente dos caracteres pegados:
| Nombre | Escape | Byte |
|---|---|---|
| Retorno de carro | \r | 0x0D |
| Avance de línea | \n | 0x0A |
| CRLF | \r\n | 0x0D 0x0A |
Cuál escribe cada plataforma:
| Plataforma | Salto de línea |
|---|---|
| Windows | CRLF (\r\n) |
| Linux, macOS moderno | LF (\n) |
| Mac Classic, OS 9 y anteriores | CR (\r) por sí solo |
Los nombres son mecánicos. En un teletipo, el retorno de carro devolvía el cabezal de impresión al margen izquierdo y el avance de línea hacía subir el papel una fila. Windows conservó los dos movimientos como dos bytes; Unix decidió que con uno bastaba. El CR solitario todavía aparece en exportaciones antiguas, y un archivo lleno de ellos se ve como una única línea enorme para casi cualquier herramienta Unix.
Un solo comando te dice cuál tienes
file lee los bytes y nombra el terminador:
$ file d1.txt
d1.txt: ASCII text, with CRLF line terminators
$ file d2.txt
d2.txt: ASCII text ← no suffix means pure LF
$ file d3.txt
d3.txt: ASCII text, with CRLF, LF line terminators ← mixed, file lists both
El tercer caso es el que vale la pena aprender. Un archivo con saltos de línea mixtos significa que dos herramientas con configuraciones distintas escribieron en él por turnos: un editor guardó LF, un script añadió CRLF, una fusión cosió dos versiones. od -c muestra la división byte por byte:
$ od -c d3.txt
0000000 a \r \n b \n
0000005
Los saltos de línea viven en la capa de bytes, justo al lado de la codificación de caracteres; la guía de codificación UTF-8 y UTF-16 cubre lo que pasa un nivel más arriba.
2. Los cuatro modos de fallo, del éxito silencioso al código 126
El mismo archivo \r\n, cinco resultados distintos según lo que contenga la línea:
| Contenido del script | Qué pasa en realidad | Código |
|---|---|---|
echo hello y otros comandos simples | Éxito silencioso, la salida es correcta | 0 |
Asignación X=abc | Éxito silencioso, pero el valor termina en \r | 0 |
Líneas en blanco, continuaciones con \ al final | : command not found, el script sigue hasta el final | 0 |
if/fi, for/do/done, f() { | syntax error near unexpected token | 2 |
Línea shebang con \r | bad interpreter: No such file or directory | 126 |
Las dos primeras filas son las que casi nunca se cuentan. «CRLF provoca command not found» se repite en todas partes, y no es lo que pasa. Los comandos simples no se quejan en absoluto.
El éxito silencioso es el peligroso
$ printf 'echo hello\r\necho world\r\n' > win.sh && bash win.sh
hello
world
[exit=0]
Dos líneas dentro, dos líneas fuera, código 0. No hay nada que depurar ni nada que buscar en el log. Dale al mismo script una comparación que hacer y la cosa cambia:
$ printf 'V=1.2.3\r\ntest "$V" = "1.2.3" && echo MATCH || echo NO-MATCH\r\n' > v.sh
$ bash v.sh
NO-MATCH
[exit=0]
$V contiene 1.2.3\r, no 1.2.3. Resultado idéntico en macOS y en Linux. Controles de versión, if [ "$ENV" = "prod" ], feature flags leídos de un archivo: cada uno toma la rama equivocada, en silencio, con código de salida 0. Este es el error de saltos de línea más difícil de encontrar en CI, porque la compilación queda en verde y el log está limpio.
El error que no detiene nada
Una línea que contiene solo \r es lo que parece una línea en blanco dentro de un archivo CRLF, y el shell la trata como un comando que hay que ejecutar. Falla, imprime un mensaje, y el script pasa a la línea siguiente y termina con código 0. El texto exacto depende de qué shell tengas, y ahí no hay dos que coincidan.
Las continuaciones con barra invertida al final se rompen igual. El \r queda entre la barra invertida y el salto de línea, así que la continuación deja de ser una continuación y la línea que sigue se ejecuta por su cuenta.
Errores de sintaxis, y por qué fi no es fi
c_if.sh: line 5: syntax error: unexpected end of file
[exit=2]
El bash 5.3.15 de Linux dice más sobre el mismo archivo:
i.sh: line 5: syntax error: unexpected end of file from `if' command on line 2
Los bucles y las definiciones de funciones, en cambio, fallan en la línea 1:
c_for.sh: line 1: syntax error near unexpected token `do'
c_for.sh: line 1: `for i in 1 2; do'
c_func.sh: line 1: syntax error near unexpected token `{'
c_func.sh: line 1: `f() {'
El mecanismo hace predecible a toda esta familia de fallos. Bash lee la palabra de cierre como fi\r, no como fi. fi\r no es la palabra clave fi, así que el bloque if nunca se cierra y bash sigue leyendo hasta que se acaba el archivo; por eso el error señala la última línea en vez de la que está rota.
bad interpreter y el código de salida 126
$ ./s.sh
bash: ./s.sh: /bin/bash^M: bad interpreter: No such file or directory
[exit=126]
macOS y Linux imprimen el mismo texto y los dos salen con 126. Lee la ruta que aparece en el mensaje: /bin/bash^M. El kernel toma como ruta del intérprete todo lo que va después de #! hasta el salto de línea, y el \r forma parte de ella. No existe ningún archivo así, de modo que el exec falla antes de que se ejecute una sola línea de tu script.
El código 126 también cubre «encontrado, pero no ejecutable», así que un script que se niega a arrancar no es automáticamente un problema de saltos de línea; los permisos de archivo producen fallos de la misma familia. El ^M dentro de la ruta es lo que distingue a los dos.
Por qué nunca ves el \r
Leer la salida no ayuda, porque ese byte no te deja nada que mirar ni que seleccionar. Tienes que forzarlo a la vista:
$ bash d.sh # script: HOST=example.com / echo "connecting to $HOST:8080"
connecting to example.com:8080 ← what you get
$ bash d.sh | cat -v
connecting to example.com^M:8080^M ← what is actually there
$ bash d.sh | od -c
0000000 c o n n e c t i n g t o e x
0000020 a m p l e . c o m \r : 8 0 8 0 \r
0000040 \n
Dos bytes \r, invisibles en la salida normal, y los dos dentro de una cadena que está a punto de usarse como nombre de host. A veces el byte viaja hasta un argumento y una herramienta que no tiene nada que ver se lleva la culpa:
$ bash v.sh # the script pipes through: ... | head -2
head: illegal line count -- 2\r
head se está portando bien. El script le pasó 2\r. Cualquier mensaje de error con un \r perdido dentro del valor entrecomillado es este mismo error disfrazado con el nombre de otro.
3. Por qué tu mensaje de error no se parece en nada al que encuentras en internet
Un mismo archivo (printf 'echo a\r\n\r\necho b\r\n', donde la línea 2 es una línea en blanco que solo contiene \r), ejecutado bajo cuatro shells:
| Shell | Versión | Mensaje exacto |
|---|---|---|
| bash (el que trae macOS) | 3.2.57(1)-release | s_blank.sh: line 2: : command not found |
| bash (Linux convencional) | 5.3.15(1)-release | t.sh: line 2: $'\r': command not found |
| zsh | 5.9 | s_blank.sh:2: command not found: ^M |
| dash | — | s_blank.sh: 2: : not found |
Casi todos los resultados de búsqueda citan la segunda fila. Bash 4 y 5 imprimen los caracteres no imprimibles con comillas al estilo ANSI-C, que convierten el retorno de carro en $'\r'. El bash que viene con macOS es el 3.2 y no hace eso, así que obtienes dos puntos, un espacio y nada entre medio. zsh imprime ^M. dash omite por completo la palabra «command».
Un solo fallo, cuatro mensajes. Si pegaste tu error exacto en un buscador y no te devolvió nada útil, esa es la razón. El escueto : command not found es el mismo error que el famoso.
4. Git: qué guarda realmente core.autocrlf en tu repositorio
Casi todas las explicaciones sobre git autocrlf se quedan en las definiciones. Lo que hace falta saber es qué termina en el commit y qué reciben tus compañeros cuando hacen checkout. Medido con git cat-file -p HEAD:f.txt para el blob almacenado y rm f.txt && git checkout -- f.txt para la copia de trabajo:
core.autocrlf | Archivo fuente | Blob en el repositorio | Copia de trabajo tras el checkout |
|---|---|---|---|
true | CRLF | LF | CRLF |
true | LF | LF | CRLF |
input | CRLF | LF | LF |
input | LF | LF | LF |
false | CRLF | CRLF | CRLF |
false | LF | LF | LF |
De esa tabla salen tres conclusiones:
trueeinputgarantizan LF en el repositorio. La única diferencia está en el checkout:trueconvierte de vuelta a CRLF,inputdeja el archivo tal cual.- Solo
falsemete CRLF dentro de un commit. Cuando alguien pregunte quién subió los retornos de carro, esta fila es la respuesta. - La segunda fila es la sorpresa. Con
true, un archivo que estaba en LF en disco vuelve como CRLF después del checkout.
Git anuncia la reescritura antes de hacerla, en una de estas dos formas:
warning: in the working copy of 'f.txt', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'f.txt', CRLF will be replaced by LF the next time Git touches it
«No cambié nada, pero git dice que el archivo entero cambió»
Otra vez la segunda fila. Con git config core.autocrlf true, el checkout reescribe los archivos LF a CRLF en el directorio de trabajo. Ahora cada línea difiere del blob en un byte, así que git diff reporta todas las líneas como modificadas y el pull request muestra como reescrito por completo un archivo que nadie tocó. Fue el filtro de checkout.
La imagen espejo produce el mismo ruido: un compañero con false sube CRLF, tú estás en input, y archivos que nunca abriste aparecen en tu diff.
5. .gitattributes es la respuesta a nivel de equipo
core.autocrlf es un ajuste de una sola máquina, invisible para los demás. .gitattributes es un archivo dentro del repositorio, así que viaja con cada clon. Cuando los dos no coinciden, ganan los atributos. Las cuatro formas probadas:
.gitattributes | core.autocrlf | Fuente | Blob | Tras el checkout | Ganador |
|---|---|---|---|---|---|
* text=auto | false | CRLF | LF | LF | atributos |
* text eol=crlf | input | LF | LF | CRLF | atributos |
* -text | true | CRLF | CRLF | CRLF | atributos |
* text eol=lf | true | CRLF | LF | LF | atributos |
La tercera fila vale la pena recordarla: -text desactiva la conversión por completo y aun así le gana a core.autocrlf=true. Así es como proteges los archivos cuyos bytes deben sobrevivir intactos.
Un .gitattributes que puedes copiar
* text=auto
*.sh text eol=lf
*.bash text eol=lf
Makefile text eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
*.png -text
*.jpg -text
*.pdf -text
*.zip -text
text=auto normaliza a LF en el repositorio todo lo que Git detecte como texto. Las líneas explícitas con eol=lf cubren los archivos que deben ser LF sin importar quién los escribió, porque un .sh con CRLF es un código 126 esperando a suceder. Los tipos de script de Windows llevan eol=crlf por la razón inversa, y los patrones binarios llevan -text para que no se convierta nada.
core.safecrlf y core.eol
Dos ajustes que aparecen junto a core.autocrlf y hacen algo distinto:
core.safecrlfes una salvaguarda, no un conversor. Cuando una conversión no daría una ida y vuelta idéntica (un archivo mixto, donde normalizar pierde información),truerechaza la operación ywarnla deja pasar con una advertencia. Nunca cambia qué bytes se guardan; solo se niega a hacer en silencio las conversiones con pérdida.core.eolelige qué final de línea escribe Git en el directorio de trabajo para los archivos marcados comotext, cuandocore.autocrlfestá enfalse. Los valores sonlf,crlfynative.core.autocrlflo anula, y por eso configurarcore.eolen una máquina que todavía tiene autocrlf activo suele parecer que no hizo nada.
Cambiar .gitattributes no arregla los archivos que ya están en el commit
Los atributos se aplican cuando Git escribe o lee un archivo, así que el contenido existente se queda como está hasta que algo lo reescriba. Fuerza tú mismo esa pasada:
$ git add --renormalize .
$ git commit -m "Normalize line endings"
Espera un diff enorme; de eso se trata. Hazlo en una rama aparte, fusiónalo en un solo commit y avisa a todo el mundo antes de que aterrice.
6. Convertir CRLF a LF, y al revés
Cuatro maneras, todas verificadas para quitar el \r en Darwin:
| Comando | Resultado |
|---|---|
tr -d '\r' < f > f.out | funciona |
perl -pi -e 's/\r\n/\n/g' f | funciona |
sed -i '' -e 's/\r$//' f (forma BSD) | funciona |
sed -i -e 's/\r$//' f (forma GNU, ejecutada en macOS) | funciona, y deja un archivo basura atrás |
La trampa del sed -i de GNU en macOS
El sed de BSD exige que a -i le siga un sufijo de respaldo. Copia un tutorial de Linux al pie de la letra y el sed de BSD se traga el -e como ese sufijo. Tu edición se hace igual, y también esto:
a5.txt
a5.txt-e
a5.txt-e es una copia de respaldo, creada porque sed leyó -e como el sufijo que pediste. La forma correcta en macOS pasa una cadena vacía explícita: sed -i '' -e 's/\r$//' f. Un repositorio con archivos -e subidos es un repositorio donde alguien ejecutó una línea de GNU en una Mac.
macOS no trae dos2unix
command -v dos2unix no devuelve nada en un macOS de fábrica; el binario viene de brew install dos2unix. Por eso la respuesta más copiada de internet falla justo en la máquina donde escriben muchos desarrolladores. tr -d '\r' no necesita instalación y hace el mismo trabajo.
En el sentido contrario, unix2dos tiene el mismo problema de disponibilidad, y sed -e 's/$/\r/' sirve como reemplazo.
Una vez que un archivo vuelve a tener LF limpio, es seguro tratarlo otra vez como una lista de líneas. Eso importa para cualquier cosa que compare líneas como cadenas completas: ordenar líneas de texto y eliminar líneas duplicadas leen value\r y value como dos líneas distintas, así que un solo retorno de carro perdido derrota en silencio a la deduplicación.
En tu editor
VS Code muestra el salto de línea del archivo actual como CRLF o LF en la barra de estado, abajo a la derecha, y al hacer clic ahí cambia el archivo. La opción files.eol controla el valor por defecto para los archivos nuevos, y ajustarla por espacio de trabajo mantiene consistente a un equipo mixto. Otros editores exponen los mismos dos controles con otros nombres. Lo que se le escapa a la gente es que el indicador por archivo y el ajuste por defecto son cosas separadas: cambiar uno no toca el otro.
7. Manejar saltos de línea desde el código
Un archivo, line1\r\nline2\r\n, leído desde siete puntos de entrada:
| Punto de entrada | Lo que obtienes | \r |
|---|---|---|
Node fs.readFileSync(f,"utf8") | "line1\r\nline2\r\n" | se conserva |
Node, el mismo valor + .split("\n") | ["line1\r","line2\r",""] | se conserva en todas las líneas |
Node readline con crlfDelay:Infinity | ["line1","line2"] | se elimina |
Python open(f), modo por defecto | 'line1\nline2\n' | se convierte |
Python open(f).readlines() | ['line1\n','line2\n'] | se convierte |
Python open(f, newline="") | 'line1\r\nline2\r\n' | se conserva |
Python open(f,"rb") | b'line1\r\nline2\r\n' | se conserva |
Esa tabla resuelve un reporte de bug conocido: funciona en Python y se rompe en Node. El modo texto por defecto de Python aplica saltos de línea universales y traduce \r\n a \n antes de que tú llegues a verlo. Node te entrega los bytes tal como están. Ninguno de los dos está mal, pero discrepan en cuanto leen el mismo archivo.
rstrip("\n") deja el \r atrás
original: 'line1\r\n' | rstrip("\n"): 'line1\r' | strip(): 'line1'
rstrip("\n") quita exactamente los caracteres que enumeraste, y \r no estaba en la lista. El resultado ya no coincide con nada de lo que debería, y esa es la respuesta a «lo limpié y sigue sin ser igual». Usa strip(), o rstrip() sin argumentos, y se va todo el espacio en blanco final, incluido el retorno de carro.
Dividir líneas de forma segura en ambas plataformas
En JavaScript, divide con un patrón que tolere los dos finales: text.split(/\r?\n/). En Python, o te quedas en el modo texto por defecto y dejas que los saltos de línea universales se encarguen, o llamas a splitlines(), que se las arregla con \r\n, \n y un \r solitario.
Escribir es la otra mitad. Node escribe los bytes que le des, así que arma las cadenas con \n y deja que .gitattributes decida qué aterriza en disco. El open(path, "w") de Python traduce \n al final de línea de la plataforma a menos que le pases newline="", la bandera que los escritores de CSV piden justo por esta razón.
Un retorno de carro al final de una línea y una marca de orden de bytes al principio de un archivo son la misma categoría de error vista desde extremos opuestos: un byte invisible que sobrevive al copiar y pegar y rompe una comparación de igualdad. La guía de solución del BOM UTF-8 cubre ese otro extremo.
8. Dónde más muerden los saltos de línea
CSV y Excel. El RFC 4180 especifica CRLF como separador de registros, lo que convierte al CSV en uno de los pocos lugares donde CRLF es correcto y no un defecto. Los parsers escritos para entradas de solo LF dejan un \r en el último campo de cada fila, así que si los valores de una conversión de CSV a JSON se ven bien pero comparan mal, revisa primero ese byte.
Docker. Un .sh con CRLF copiado dentro de una imagen es el modo de fallo número cuatro. COPY preserva los bytes, el shebang conserva su \r y el contenedor sale con 126. Una sola línea *.sh text eol=lf en .gitattributes previene toda esa familia de fallos.
Diffs y pull requests. Un archivo marcado como cambiado por completo sin una sola edición visible es el mecanismo de la sección 4 llegando a la revisión de código. Comparar las dos versiones con comparador de texto lo confirma en segundos, y la guía de comparación de texto explica cómo leer el resultado.
Archivos mixtos. ASCII text, with CRLF, LF line terminators significa dos escritores con configuraciones distintas. Normaliza el archivo entero en vez de solo las líneas que te tocó editar, o el próximo diff será igual de ruidoso.
Preguntas frecuentes
¿Cuál es la diferencia entre CRLF y LF?
CRLF son dos bytes, \r\n (0x0D 0x0A). LF es un byte, \n (0x0A). Windows escribe CRLF, Linux y macOS escriben LF, y los dos marcan el final de una línea. El texto se ve idéntico en un editor; la diferencia solo aparece en los bytes, en las comparaciones de cadenas y en los diffs.
Mi script tiene saltos de línea CRLF. ¿Por qué no aparece ningún error?
Porque los comandos simples sobreviven al byte extra. echo hello con un \r al final se ejecuta y sale con 0. Los errores aparecen solo donde al parser le importa: una línea en blanco, una palabra clave como fi, o el shebang. Las asignaciones son el caso peligroso, porque tienen éxito y guardan el \r dentro de la variable.
¿Qué significa $'\r': command not found y por qué a mí no me sale así?
Significa que el shell intentó ejecutar una línea que solo contenía un retorno de carro. Bash 4 y 5 imprimen ese carácter con comillas al estilo ANSI-C, lo que da $'\r'. El bash 3.2 que trae macOS no imprime nada entre los dos puntos, y zsh imprime ^M en su lugar. El fallo es el mismo; lo que cambia es quién lo imprime.
¿core.autocrlf debería estar en true, input o false?
Usa input en Linux y macOS, true en Windows, y prefiere .gitattributes antes que cualquiera de los dos. Según lo medido, true e input guardan LF en el repositorio y solo false permite que entre CRLF en un commit. Además, true reescribe los archivos LF a CRLF en tu copia de trabajo durante el checkout.
Si .gitattributes y core.autocrlf no coinciden, ¿cuál gana?
Gana .gitattributes. Las cuatro formas probadas (* text=auto, * text eol=crlf, * -text y * text eol=lf) anularon el valor local de core.autocrlf. Ese es el argumento para usarlo: los atributos van dentro del repositorio y aplican a todo el mundo, mientras que core.autocrlf es un ajuste por máquina que no puedes ver ni imponer.
¿Cómo verifico si un archivo usa CRLF o LF?
Ejecuta file yourfile. Con CRLF imprime ASCII text, with CRLF line terminators, con LF puro imprime ASCII text sin ningún sufijo, y un archivo mixto imprime with CRLF, LF line terminators. Para tener certeza a nivel de bytes, ejecuta od -c y busca un \r justo antes de cada \n.
¿Cuándo conviene usar CRLF de verdad?
Cuando un formato o un protocolo lo exige. El RFC 4180 define CRLF como separador de registros para CSV, y las cabeceras HTTP y SMTP hacen lo mismo sobre el cable. Los archivos batch y PowerShell de Windows también son más seguros con CRLF. Para cualquier otra cosa (código fuente, scripts de shell, configuración), LF.