I opened a run log and the line I had written as Cost: $2 / MTok read Cost: / MTok. The number was simply gone.
One line above it, a command string I had pasted in for future reference had been replaced by the output of that command. And when I counted, the log had six lines where I had written seven.
Nothing in the script explained it. The only unusual thing was that the EOF in cat << EOF had no quotes around it.
The fix, before anything else
Quote the delimiter word. That is the entire diff.
# Before
cat << EOF > log.txt
Cost: $2 / MTok
EOF
# After
cat << 'EOF' > log.txt
Cost: $2 / MTok
EOFWhen the delimiter word is unquoted, the shell treats the heredoc body as something to expand. When it is quoted, the body is passed through untouched, character for character.
If that is all you needed, you are done. The rest of this article is what I measured: exactly what gets rewritten, how to fill in values without giving up the quoting, and how to audit the scripts you already have.
What the unquoted heredoc actually rewrote
I meant to write seven lines. DATE was set to 2026-09-01 beforehand.
DATE="2026-09-01"
cat << EOF > a2.txt
Date: $DATE
Generated by: `date -u +%Y-%m-%dT%H:%M`
Backup path: /var/backup/$USER/
Cost: $2 / MTok
Regex: ^\d{4}-\d{2}$
Continuation: trailing backslash \
next line
EOFHere is what landed in a2.txt:
Date: 2026-09-01
Generated by: 2026-09-01T06:06
Backup path: /var/backup/masaki/
Cost: / MTok
Regex: ^\d{4}-\d{2}$
Continuation: trailing backslash next line
wc -l reported 6. Side by side:
| What I wrote | What came out | What happened |
|---|---|---|
| Date: $DATE | Date: 2026-09-01 | Variable expanded (as intended) |
| Generated by: date in backticks | Generated by: 2026-09-01T06:06 | The command ran and was replaced by its output |
| /var/backup/$USER/ | /var/backup/masaki/ | Environment variable expanded |
| Cost: $2 / MTok | Cost: / MTok | Read as positional parameter $2, became empty |
| Regex: ^\d{4}-\d{2}$ | Regex: ^\d{4}-\d{2}$ | Survived intact (see below) |
| Line ending in a backslash, plus the next line | Joined into one line | Eaten as a line continuation, losing a line |
As an indie developer reviewing my own scripts, the fourth row is the one that worries me. $2 was not two dollars; it was the second positional argument, and since none was passed, it became an empty string. No error, no warning. A cost record just goes blank. That is the line that made me look in the first place.
The second row is nearly as unpleasant, because "paste the command in so I can check it later" is one of the most common things a log line does. Suddenly your log contains a value you never wrote, and reading the script gives you no clue where it came from.
Meanwhile the \d and the trailing $ in row five came through untouched. Inside an unquoted heredoc, a backslash only escapes $, a backtick, another backslash, or a newline. Everything else passes straight through. Some lines break and some do not, which is precisely why a quick visual review misses this.
Three ways to quote, all with the same result
I put $HOME and a backtick on one line and varied only the delimiter.
| Form | Output |
|---|---|
| << EOF | /home/masaki|RUN (expanded and executed) |
| << 'EOF' | Literal |
| << "EOF" | Literal |
| << \EOF | Literal |
Single quotes, double quotes, a backslash — all identical. If any part of the delimiter is quoted, the whole body becomes literal.
This is where the rule diverges from ordinary shell intuition. Double quotes normally expand variables, but for a heredoc delimiter the only question is quoted or not. I standardized on 'EOF' so readers do not have to think about it, though there is no reason to go rewrite existing "EOF" usages.
<<- strips leading tabs. Only tabs — spaces stayed. If your editor converts tabs to spaces, that feature silently stops working.
Filling in values without giving up the quoting
Sometimes the heredoc is unquoted on purpose, because you do want a variable expanded. If there are only two or three such values, it is safer to keep the body literal and substitute afterwards.
DATE="2026-09-01"; SLUG="my-article"
cat << 'EOF' > tmpl.txt
Date: __DATE__ / slug: __SLUG__
Cost: $2 / MTok
EOF
sed -e "s/__DATE__/${DATE}/" -e "s/__SLUG__/${SLUG}/" tmpl.txtOutput:
Date: 2026-09-01 / slug: my-article
Cost: $2 / MTok
The two placeholders filled in and $2 stayed a literal string. I use this shape whenever the text being written out is something I cannot fully predict, which is most logs.
There is a second trap here, though. If the value you substitute contains a slash, sed falls over.
P="/var/log/app"
sed "s/__P__/${P}/" <<< "path: __P__"
# => sed: -e expression #1, char 10: unknown option to `s'The delimiter in an s command is yours to choose, so use | when substituting paths:
sed "s|__P__|${P}|" <<< "path: __P__"
# => path: /var/log/appPinning the delimiter to | for path substitution is a reasonable default. If the value itself might contain a |, hand the job to python3 or awk instead of sed.
Auditing the scripts you already have
If you hit this once, the same shape is almost certainly elsewhere. This script picks out unquoted heredocs whose bodies actually contain something expandable.
import re, sys, pathlib
OPEN = re.compile(r"<<-?\s*(?P<q>['\"\\])?(?P<w>[A-Za-z_][A-Za-z0-9_]*)")
RISK = re.compile(r"`|\$\(|\$[A-Za-z_{0-9]|\\$")
def scan(path):
lines = pathlib.Path(path).read_text(errors="replace").splitlines()
hits, i = [], 0
while i < len(lines):
m = OPEN.search(lines[i])
if m and m.group("q") is None:
word, start = m.group("w"), i
body = []
i += 1
while i < len(lines) and lines[i].strip() != word:
body.append(lines[i]); i += 1
risky = [b for b in body if RISK.search(b)]
if risky:
hits.append((start + 1, word, len(body), len(risky)))
i += 1
return hits
total = 0
for p in sys.argv[1:]:
for ln, w, n, r in scan(p):
total += 1
print(f"{p}:{ln}: unquoted heredoc ({r} of {n} lines expandable)")
print(f"Found: {total}")
sys.exit(1 if total else 0)Quoted delimiters are excluded up front, and so are bodies containing no backtick, $(, $variable, or trailing backslash — without something to expand, an unquoted heredoc does no harm.
I ran it over the 272 shell scripts on a Linux box, under /usr/bin, /usr/sbin, and /etc.
| Measure | Count |
|---|---|
| Shell scripts scanned | 272 |
| Files containing a heredoc | 60 |
| Unquoted heredocs found | 138 (across 40 files) |
| Quoted heredocs found | 9 |
To be honest about what that means: most of those 138 are not bugs. Scripts that generate configuration files are unquoted deliberately, because expansion is the point. What this produces is not a bug list but a review list.
It is still worth having. Each hit carries a line number and how many body lines are expandable, so one glance at the file tells you whether it is generating config or writing a log. I now skim this output before pushing changes to anything that runs unattended.
The same failure shape — no error, just a different result — turns up elsewhere too. A One-Letter Typo in settings.json Is Ignored Without a Single Warning covers the configuration-key version of it. If you want to go one level deeper into shell quoting specifically, One Space in a Folder Name Turned 80 Checks Into Zero measures how a path containing a space quietly empties a batch run.
Why an AI tends to hand you the unquoted form
The original script came from asking Claude Code to "add something that writes out a log." What came back was a plain cat << EOF.
I think the numbers above explain it. Across those 272 scripts there were 9 quoted heredocs and 138 unquoted ones. Unquoted is the overwhelming majority in real-world shell code, and a model trained on that corpus reflects the ratio. A human reading the same code would pick up the same habit. This is less a model flaw than a consequence of the shell defaulting to expansion.
Which means one extra sentence in the request changes the output:
Add a step that writes out a log.
Always use the <<'EOF' form for heredocs (quote the delimiter),
and where a value must be filled in, leave a __PLACEHOLDER__ and substitute it with sed.
Since adding that, the generated scripts have been consistent. Putting it in your project configuration or a skill file means you do not have to say it every time.
The single thing worth doing today: search your scripts for lines starting with cat << EOF, and quote the delimiter on the ones that write logs or messages. Leave the config generators alone. That alone closes one path by which numbers and dates rewrite themselves without telling you.
I had read about this behaviour more than once before it ever cost me anything, and it took a log that was one line short to make it feel like my problem. If you have stopped in the same place, I hope this saves you the detour. Thank you for reading.