CRLF vs LF Line Endings: What Actually Breaks
CRLF vs LF is a difference of one byte. LF is a single \n (0x0A) and ends lines on Linux and macOS. CRLF is two bytes, \r\n (0x0D 0x0A), and ends lines on Windows.
Now the part most articles get wrong. A shell script with CRLF line endings usually does not fail. It runs, prints what you expect, and exits 0. What breaks is the value sitting in a variable, because the assignment kept the trailing \r and nothing complained about it.
Four modes came out of the testing, ordered by how hard they are to notice:
- Silent success. The output looks right and the exit code is 0, while a variable carries an invisible
\r. - An error that changes nothing. A blank line prints
: command not found, then the script runs to the end and exits 0. - Syntax error.
if,forand function definitions break, exit code 2. bad interpreter. The shebang carries the\r, exit code 126.
Before anything else, run file yourfile. One line of output tells you which side of CRLF vs LF you are on.
Everything below was measured on macOS (Darwin arm64) with bash 3.2.57(1)-release, zsh 5.9, dash, git 2.55.0, node v26.7.0 and Python 3.14.6, cross-checked on Linux through
docker run --rm bash:5, which is GNU bash 5.3.15(1)-release (aarch64-unknown-linux-musl).
1. CRLF vs LF vs CR: the bytes behind the names
CRLF is short for carriage return line feed. It is two characters, one after the other:
| Name | Escape | Byte |
|---|---|---|
| Carriage return | \r | 0x0D |
| Line feed | \n | 0x0A |
| CRLF | \r\n | 0x0D 0x0A |
Which one a platform writes:
| Platform | Line ending |
|---|---|
| Windows | CRLF (\r\n) |
| Linux, modern macOS | LF (\n) |
| Mac Classic, OS 9 and earlier | CR (\r) on its own |
The names are mechanical. On a teletype, carriage return moved the print head back to the left margin and line feed rolled the paper up one row. Windows kept both motions as two bytes; Unix decided one was enough. Lone CR still turns up in old exports, and a file full of them looks like one enormous line to most Unix tools.
One command tells you which one you have
file reads the bytes and names the terminator:
$ 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
The third case is the one worth learning. A file with mixed line endings means two tools with different settings wrote to it in turn: an editor saved LF, then a script appended CRLF, or a merge stitched two versions together. od -c shows the split byte by byte:
$ od -c d3.txt
0000000 a \r \n b \n
0000005
Line endings live at the byte layer, next door to character encoding; the UTF-8 and UTF-16 encoding guide covers what happens one level up.
2. The four failure modes, from silent success to exit 126
Same \r\n file, five different outcomes depending on what the line contains:
| Script content | What actually happens | Exit |
|---|---|---|
echo hello and other simple commands | Silent success, output is correct | 0 |
X=abc assignment | Silent success, but the value ends in \r | 0 |
Blank lines, trailing \ continuations | : command not found, script keeps running to the end | 0 |
if/fi, for/do/done, f() { | syntax error near unexpected token | 2 |
Shebang line with \r | bad interpreter: No such file or directory | 126 |
The first two rows are the ones nobody warns you about. “CRLF causes command not found” gets repeated everywhere, and it is not what happens. Simple commands do not complain at all.
Silent success is the dangerous one
$ printf 'echo hello\r\necho world\r\n' > win.sh && bash win.sh
hello
world
[exit=0]
Two lines in, two lines out, exit 0. Nothing to debug and nothing to grep the log for. Give the same script a comparison to make and the result changes:
$ 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 holds 1.2.3\r, not 1.2.3. Identical result on macOS and Linux. Version gates, if [ "$ENV" = "prod" ], feature flags read from a file: each of them takes the wrong branch, quietly, with exit code 0. This is the hardest line-ending bug to find in CI, because the build is green and the log is clean.
The error that stops nothing
A line containing only \r is what a blank line looks like inside a CRLF file, and the shell treats it as a command to run. It fails, prints a message, and the script moves on to the next line and finishes with exit 0. The exact wording depends on your shell.
Trailing backslash continuations break the same way. The \r sits between the backslash and the newline, so the continuation stops being a continuation and the following line runs on its own.
Syntax errors, and the reason fi is not fi
c_if.sh: line 5: syntax error: unexpected end of file
[exit=2]
Linux bash 5.3.15 says more about the same file:
i.sh: line 5: syntax error: unexpected end of file from `if' command on line 2
Loops and function definitions fail on line 1 instead:
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() {'
One mechanism explains the whole class. Bash reads the closing word as fi\r, not fi. fi\r is not the keyword fi, so the if block never closes and bash keeps reading until the file runs out, which is why the error points at the last line instead of the broken one.
bad interpreter and exit 126
$ ./s.sh
bash: ./s.sh: /bin/bash^M: bad interpreter: No such file or directory
[exit=126]
macOS and Linux print the same text and both exit 126. Read the path in the message: /bin/bash^M. The kernel takes everything after #! up to the newline as the interpreter path, and the \r is part of it. No such file exists, so the exec fails before a single line of your script runs.
Exit 126 also covers “found, but not executable”, so a script that refuses to start is not automatically a line-ending problem; file permissions produce failures from the same family. The ^M inside the path is what tells the two apart.
Why you never see the \r
Reading the output does not help, because the byte gives you nothing to look at or select. You have to force it into view:
$ 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
Two \r bytes, invisible in normal output, both of them inside a string that is about to be used as a hostname. Sometimes the byte travels into an argument and an unrelated tool takes the blame:
$ bash v.sh # the script pipes through: ... | head -2
head: illegal line count -- 2\r
head is behaving correctly. The script handed it 2\r. Any error message with a stray \r in the value is this bug wearing someone else’s name.
3. Why your error message looks nothing like the one online
One file, printf 'echo a\r\n\r\necho b\r\n', where line 2 is a blank line containing only \r, run under four shells:
| Shell | Version | Exact message |
|---|---|---|
| bash (bundled with macOS) | 3.2.57(1)-release | s_blank.sh: line 2: : command not found |
| bash (mainstream Linux) | 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 |
Nearly every search result quotes the second row. Bash 4 and 5 print non-printable characters using ANSI-C quoting, which turns the carriage return into $'\r'. The bash that ships with macOS is 3.2 and does not do that, so you get a colon, a space and nothing in between. zsh prints ^M. dash drops the word “command” altogether.
One fault, four messages. If you pasted your exact error into a search box and got nothing useful back, that is the reason. The bare : command not found is the same bug as the famous one.
4. Git: what core.autocrlf stores in your repository
Most explanations of git autocrlf stop at the definitions. Two questions matter more: what ends up in the commit, and what do teammates get when they check it out? Measured with git cat-file -p HEAD:f.txt for the stored blob and rm f.txt && git checkout -- f.txt for the working copy:
core.autocrlf | Source file | Blob in the repository | Working copy after 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 |
Three conclusions fall out of that table:
trueandinputboth guarantee LF in the repository. The only difference is checkout:trueconverts back to CRLF,inputleaves the file alone.- Only
falseputs CRLF into a commit. When someone asks who committed the carriage returns, this row is the answer. - Row two is the surprise. Under
true, a file that was LF on disk comes back CRLF after checkout.
Git announces the rewrite before it happens, in one of two forms:
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
“I didn’t change anything, but git says the whole file changed”
Row two again. With git config core.autocrlf true, checkout rewrites LF files to CRLF in the working directory. Every line now differs from the blob by one byte, so git diff reports every line as modified and the pull request shows a file nobody touched as fully rewritten. The checkout filter did it.
The mirror image produces the same noise: a teammate on false commits CRLF, you are on input, and files you have never opened show up in your diff.
5. .gitattributes is the team-level answer
core.autocrlf is a setting on one machine, invisible to everyone else. .gitattributes is a file inside the repository, so it travels with every clone. When the two disagree, attributes win. All four forms tested:
.gitattributes | core.autocrlf | Source | Blob | After checkout | Winner |
|---|---|---|---|---|---|
* text=auto | false | CRLF | LF | LF | attributes |
* text eol=crlf | input | LF | LF | CRLF | attributes |
* -text | true | CRLF | CRLF | CRLF | attributes |
* text eol=lf | true | CRLF | LF | LF | attributes |
Row three is worth remembering: -text disables conversion entirely and still beats core.autocrlf=true. That is how you protect files whose bytes must survive untouched.
A .gitattributes you can copy
* 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 normalizes whatever Git detects as text to LF in the repository. The explicit eol=lf lines cover files that must be LF regardless of who wrote them, because a .sh with CRLF is an exit 126 waiting to happen. Windows script types get eol=crlf for the mirror reason, and binary patterns get -text so nothing is converted at all.
core.safecrlf and core.eol
Two settings that turn up next to core.autocrlf and do something different:
core.safecrlfis a guard, not a converter. When a conversion would not round-trip (a mixed file, where normalizing loses information),truerejects the operation andwarnlets it through with a warning. It never changes which bytes get stored; it only refuses to do the lossy ones silently.core.eolchooses which ending Git writes in the working directory for files marked astext, whencore.autocrlfisfalse. Values arelf,crlfandnative.core.autocrlfoverrides it, which is why settingcore.eolon a machine with autocrlf still on tends to look like it did nothing.
Changing .gitattributes does not fix the files already committed
Attributes apply when Git writes or reads a file, so existing content stays as it is until something rewrites it. Force that pass yourself:
$ git add --renormalize .
$ git commit -m "Normalize line endings"
Expect one enormous diff, which is the point. Do it on its own branch, merge it in a single commit, and warn everyone before it lands.
6. Converting CRLF to LF, and back
Four ways, all verified to strip the \r on Darwin:
| Command | Result |
|---|---|
tr -d '\r' < f > f.out | works |
perl -pi -e 's/\r\n/\n/g' f | works |
sed -i '' -e 's/\r$//' f (BSD form) | works |
sed -i -e 's/\r$//' f (GNU form, run on macOS) | works, and leaves a junk file behind |
The GNU sed -i trap on macOS
BSD sed requires -i to be followed by a backup suffix. Copy a Linux tutorial verbatim and BSD sed swallows the -e as that suffix. Your edit still happens, and so does this:
a5.txt
a5.txt-e
a5.txt-e is a backup copy, created because sed read -e as the suffix you asked for. The correct macOS form passes an explicit empty string: sed -i '' -e 's/\r$//' f. A repository with -e files committed to it is a repository where someone ran a GNU one-liner on a Mac.
macOS has no dos2unix
command -v dos2unix returns nothing on a stock macOS system; the binary comes from brew install dos2unix. That is why the most-copied answer on the internet fails on the machine a lot of developers are typing on. tr -d '\r' needs no installation and does the same job.
Going the other way, unix2dos has the same availability problem, and sed -e 's/$/\r/' works as a replacement.
Once a file is back to clean LF it is safe to treat as a list of lines again. That matters for anything comparing lines as whole strings: sort text lines and remove duplicate lines both read value\r and value as two different lines, so one stray carriage return quietly defeats de-duplication.
In your editor
VS Code shows the current file’s line ending as CRLF or LF in the status bar at the bottom right, and clicking it switches the file. The files.eol setting controls the default for new files, and setting it per workspace keeps a mixed team consistent. Other editors expose the same two controls under other names. The part people miss is that the per-file indicator and the default setting are separate: changing one does not touch the other.
7. Handling line endings in code
One file, line1\r\nline2\r\n, read through seven entry points:
| Entry point | What you get | \r |
|---|---|---|
Node fs.readFileSync(f,"utf8") | "line1\r\nline2\r\n" | kept |
Node, same value + .split("\n") | ["line1\r","line2\r",""] | kept on every line |
Node readline with crlfDelay:Infinity | ["line1","line2"] | stripped |
Python open(f), default mode | 'line1\nline2\n' | converted |
Python open(f).readlines() | ['line1\n','line2\n'] | converted |
Python open(f, newline="") | 'line1\r\nline2\r\n' | kept |
Python open(f,"rb") | b'line1\r\nline2\r\n' | kept |
That table settles a familiar bug report: it works in Python and breaks in Node. Python’s default text mode applies universal newlines and translates \r\n to \n before you ever see it. Node hands you the bytes as they are. Neither is wrong, but the two disagree the moment they read the same file.
rstrip("\n") leaves the \r behind
original: 'line1\r\n' | rstrip("\n"): 'line1\r' | strip(): 'line1'
rstrip("\n") removes exactly the characters you listed, and \r was not on the list. The result then compares unequal to everything it should match, which is the honest answer to “I stripped it and it still isn’t equal”. Use strip(), or rstrip() with no argument, and all trailing whitespace goes including the carriage return.
Splitting lines safely on both platforms
In JavaScript, split on a pattern that tolerates both endings: text.split(/\r?\n/). In Python, either stay in default text mode and let universal newlines handle it, or call splitlines(), which copes with \r\n, \n and a lone \r.
Writing is the other half. Node writes the bytes you give it, so build strings with \n and let .gitattributes decide what lands on disk. Python’s open(path, "w") translates \n to the platform ending unless you pass newline="", which is why CSV writers ask for that flag.
A carriage return at the end of a line and a byte-order mark at the start of a file are the same category of bug from opposite ends: an invisible byte that survives copy-paste and breaks an equality check. The UTF-8 BOM troubleshooting guide covers that end.
8. Where else line endings bite
CSV is one of the few places where CRLF is correct rather than a defect: RFC 4180 specifies it as the record separator. Parsers written for LF-only input leave a \r on the last field of every row, so if values from a CSV to JSON conversion look right but compare wrong, check that byte first.
In Docker, a .sh with CRLF copied into an image is failure mode four. COPY preserves bytes, the shebang keeps its \r, and the container exits 126. One *.sh text eol=lf line in .gitattributes prevents the whole class.
In a pull request, a file marked as fully changed with no visible edit is section 4’s mechanism arriving in code review. Comparing the two versions with text diff confirms it in seconds, and the text diff guide walks through reading the result.
And when file reports ASCII text, with CRLF, LF line terminators, two writers with different settings have written to the same file. Normalize all of it rather than the lines you happened to edit, or the next diff will be just as noisy.
FAQ
What is the difference between CRLF and LF?
CRLF is two bytes, \r\n (0x0D 0x0A). LF is one byte, \n (0x0A). Windows writes CRLF, Linux and macOS write LF, and both mark the end of a line. The text looks identical in an editor; the difference only shows up in bytes, string comparisons and diffs.
My script has CRLF line endings. Why is there no error?
Because simple commands survive the extra byte. echo hello with a trailing \r runs and exits 0. Errors appear only where the parser cares: a blank line, a keyword such as fi, or the shebang. Assignments are the dangerous case, since they succeed and store the \r in the variable.
What does $'\r': command not found mean, and why don’t I see it?
It means the shell tried to run a line containing only a carriage return. Bash 4 and 5 print that character with ANSI-C quoting, giving $'\r'. The bash 3.2 bundled with macOS prints nothing between the colons, and zsh prints ^M instead. One fault, three different messages.
Should core.autocrlf be true, input or false?
Use input on Linux and macOS, true on Windows, and prefer .gitattributes over both. In testing, true and input both stored LF in the repository, and only false let CRLF into a commit. true additionally rewrites LF files to CRLF in your working copy on checkout.
If .gitattributes and core.autocrlf disagree, which wins?
.gitattributes wins. All four forms tested (* text=auto, * text eol=crlf, * -text and * text eol=lf) overrode the local core.autocrlf value. That is the argument for using it: attributes are committed and apply to everyone, while core.autocrlf is a per-machine setting you cannot see or enforce.
How do I check whether a file uses CRLF or LF?
Run file yourfile. CRLF prints ASCII text, with CRLF line terminators, plain LF prints ASCII text with no suffix at all, and a mixed file prints with CRLF, LF line terminators. For byte-level certainty, run od -c and look for \r sitting before each \n.
When should you actually use CRLF?
When a format or protocol requires it. RFC 4180 defines CRLF as the record separator for CSV, and HTTP headers and SMTP do the same on the wire. Windows batch and PowerShell files are safer with CRLF too. Everywhere else, including source code, shell scripts and configuration files, use LF.