printf writes bytes¶
Level: 101 · for anyone with a terminal
One line: printf '\xc3\xa9' puts exactly two bytes on the pipe and asks the locale nothing, which makes it the only reliable way to build a test file by hand — while echo cannot even print the string -n, and the one printf escape that names a code point gives three different answers on three machines.
Every other page in this chapter reads bytes. This one writes them, and it exists because almost every example in this library starts with a file somebody had to construct: a café in Latin-1, a lone 0xe9 that is not valid UTF-8, a BOM followed by nothing. You cannot type those into an editor — an editor writes what its own encoding says — so you name the bytes.
Two escapes that are arithmetic, and one that is a lookup¶
\x41 and \101 are the same instruction in two radixes: write the byte whose value is 65. Nothing about them depends on your locale, your terminal, your LANG, or which decade your bash is from. They are the reason a page can promise that a file has exactly the bytes it says.
\u20ac is a different kind of thing. It names the code point U+20AC and asks the shell to encode it — which needs a table, which comes from the locale, which is where the portability ends. That single distinction is most of this page.
The other half is echo, which is not one command. It is a shell builtin with different behaviour in every shell, plus a /bin/echo that differs again, and the flags that control it (-n, -e, -E) are themselves ambiguous with data. printf has one format argument and everything after it is data, which is why it is what this library uses.
In the terminal¶
Verified output of printf_writes_bytes_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
1. ONE CHARACTER, SIX WAYS TO WRITE ITS TWO BYTES
e-acute is c3 a9 in UTF-8. Every line below puts those two bytes on
the pipe and nothing else — no newline, no eighth bit turned on by
accident, no locale consulted.
printf '\xc3\xa9' c3 a9
printf '\303\251' c3 a9
printf '%b' '\xc3\xa9' c3 a9
printf '%b' '\0303\0251' c3 a9
printf '%s' $'\xc3\xa9' c3 a9
xxd -r -p <<< c3a9 c3 a9
\xHH is hex and \NNN is octal. Octal is the POSIX spelling and hex is
a bash extension, so a script that must run under /bin/sh writes 303
251 — which is why old code is full of octal nobody enjoys reading.
2. WHERE THE ESCAPES ARE READ, AND WHERE THEY ARE NOT
printf '\x41' 41
printf '%s' '\x41' 5c 78 34 31
printf '%b' '\x41' 41
printf '\101' 41
printf '%s' '\101' 5c 31 30 31
printf '%b' '\101' 41
The FORMAT string is always read for escapes. An ARGUMENT is read for
them only under %b — under %s it is five literal bytes, 5c 78 34 31,
a backslash and three ASCII characters. That is the difference, and
it is the reason %b exists at all.
3. THE STRING YOU CANNOT ECHO
echo '-n' []
echo '-e' [0a]
printf '%s\n' '-n' [2d 6e 0a]
echo 'a\tb' [61 5c 74 62 0a]
The first line printed NOTHING — not the two characters, not even a
newline. echo read '-n' as its own option, which is what it is for.
There is no quoting that gets it back: the argument is gone before
echo starts. printf has no options after the format, so '-n' is data.
4. THE ESCAPE THAT NAMES A CODE POINT, AND WHY IT IS NOT HERE
printf '\u20ac' (a CODE POINT) 6 bytes
printf '\xe2\x82\xac' (BYTES) 3 bytes
is the \u output pure ASCII? yes — so it is NOT a euro
Six bytes where three were wanted. This machine handed the escape
back as text instead of encoding it, and a different bash on a
different locale hands back six DIFFERENT bytes or three correct
ones. Three configurations, three answers, one of them a euro sign.
The table is on the page. \xHH and \NNN ask the locale nothing and
are identical everywhere, which is why this library writes bytes.
5. THE FORMAT IS REUSED UNTIL THE ARGUMENTS RUN OUT
printf '%s-' a b c 61 2d 62 2d 63 2d
printf '<%s>' a 3c 61 3e
printf '<%s>' 3c 3e
With three arguments the format ran three times; with none it ran
once and %s took the empty string. That is a loop you did not write,
and it is why printf DATA is a bug: a '%' inside the data becomes a
conversion. Put the data in an argument — printf '%s' "$var" —
never in the format.
6. THE ONE BYTE A SHELL VARIABLE CANNOT CARRY
printf 'a\000b' > f then wc -c < f 3
the file's bytes 61 00 62
v=$(printf 'a\000b'); printf %s "$v" 61 62
printf wrote the NUL and the file kept it. The command substitution
did not: $( ) drops NUL bytes, so the same three bytes came back as
two. Build bytes with printf by all means — but redirect them to a
file, because a shell variable is the one container on this page
that cannot hold every byte.
7. AND BACK AGAIN
the file 63 61 66 c3 a9 0a
xxd -p round.bin 636166c3a90a
then xxd -r -p 63 61 66 c3 a9 0a
cat -v round.bin cafM-CM-)
xxd -p and xxd -r -p are printf's round trip: bytes to a hex string
you can paste into a bug report, and back to the same bytes. cat -v
is the third view — M-C M-) is c3 a9 with the high bit named rather
than drawn, which is the form that survives an email.
Section 2 is the rule worth memorising. The format is always scanned for escapes; an argument is scanned only under %b. So printf '\x41' writes A, and printf '%s' '\x41' writes the four characters a human typed — 5c 78 34 31, a backslash and x41. Both are correct and they are answers to different questions. If the bytes are in a variable, they have already been decided and %s is what you want; if the escape is the data, you want %b.
Section 3 is the argument against echo in one line. echo '-n' printed nothing at all — not the two characters, not the newline — because echo read its own argument as an option, and there is no quoting that undoes it. Any script that echoes user data has this bug, and it is invisible until the day the data starts with a hyphen. printf '%s\n' has no such failure: after the format, everything is data.
Section 6 is the one container on the page that leaks. printf wrote the NUL and the file kept all three bytes; $( ) handed back two. A command substitution cannot carry a NUL, so the moment you capture generated bytes in a shell variable you have a lossy channel — for exactly one byte value, which is the byte that ends a C string and therefore the one most worth testing with. Redirect to a file.
The escape that names a code point is the one that moves¶
macOS still ships bash 3.2.57, whose own --version line says Copyright (C) 2007 — frozen there because bash 4 moved to the GPLv3, which Apple does not ship. Measured 2026-09-07: the escape is absent in 3.2.57 and present in 4.4.23 and 5.2.37. In macOS's /bin/bash, then, printf does what printf does with any escape it does not know — hands it back. Bash is not the only printf on a Mac, though, and the others answer differently.
configuration bytes written as text verdict
bash 5.2.21, LC_ALL=C.UTF-8 e2 82 ac € 3 bytes, a euro sign
bash 5.2.21, LC_ALL=C 5c 75 32 30 41 43 \u20AC 6 bytes, hex UPPERCASED
bash 3.2.57 (macOS), any 5c 75 32 30 61 63 \u20ac 6 bytes, untouched
Three configurations, three answers, and only one of them is a euro sign. Note the middle row, which is the one nobody expects: bash 5.2 understood the escape, found that the C locale cannot represent the character, and re-emitted the escape with the hex digits normalised to uppercase. So the two six-byte answers are not even the same six bytes, and a script that greps its own output for \u20ac finds it on one machine and not the other. There is no answer key that could hold this, which is why the example above records only the length.
What to do instead. If you want bytes, name bytes: \xHH or \NNN, identical everywhere. If you genuinely want a code point encoded for you, leave the shell — that is a job for a language with a table it carries:
The printf you get depends on who runs it¶
The table above is bash, and bash is not what most people type into on a Mac: Terminal has opened zsh by default since macOS Catalina, plenty of people run fish, and printf is a builtin in every one of them — so the shell decides which implementation you get. A printf reached through env, xargs or find -exec is not a builtin at all. It is /usr/bin/printf, and on macOS that is BSD's, which knows neither the hex escape nor the code-point ones and does not refuse them either: it drops the backslash and prints the rest.
printf '\xc0' printf '\u00e9' printf '\U0001F40D'
zsh 5.9, UTF-8 locale c0 c3 a9 f0 9f 90 8d
zsh 5.9, LC_ALL=C c0 nothing — stderr says "character not in range", both columns
fish 4.3.2, any locale c0 c3 a9 f0 9f 90 8d
bash 3.2.57 (/bin/bash, sh) c0 the 6 bytes typed the 10 bytes typed
dash the 4 bytes typed the 6 bytes typed the 10 bytes typed
/usr/bin/printf, macOS (BSD) x c 0 u 0 0 e 9 U 0 0 0 1 F 4 0 D
/usr/bin/printf, Ubuntu (GNU) c0 c3 a9; \u00E9 under C f0 9f 90 8d; typed, under C
Two rows deserve a second look. zsh under LC_ALL=C writes nothing at all for a code point it cannot encode — the complaint goes to stderr, the exit status is still 0, and a script that checks $? sees success and an empty file. And BSD's /usr/bin/printf is the quiet one: xc0 is three perfectly printable bytes, so nothing downstream looks broken — the file just holds the wrong three bytes. Octal is the spelling that survives every row: printf '\300' wrote c0 in all of them, builtin or binary. The split between the two /usr/bin/printfs is in CONTRIBUTING's list.
The NUL warning is a bash version, not a platform¶
The result in section 6 is the same everywhere; the diagnostic is not.
bash 3.2.57 (macOS /bin/bash, and the bash:3.2 image) nothing at all
bash 4.4.23 warning: command substitution: ignored null byte in input
bash 5.2.37 warning: command substitution: ignored null byte in input
Both drop the byte; only the newer ones say so. This is the shape the xxd -e note in CONTRIBUTING warns about — nothing about the platform predicts it, the version does — and it has the worse polarity, because the silent build is the one on the laptop. The example above parks file descriptor 2 for a single line rather than let a warning reach an answer key, and the redirection has to go on exec rather than on the assignment: the warning is emitted while the substitution is being expanded, before any redirection on that command takes effect.
If you are coming from Python or ABAP¶
Python. printf '\xc3\xa9' is sys.stdout.buffer.write(b"\xc3\xa9"), and the .buffer is the whole point: print and sys.stdout go through a text layer that encodes for you, while .buffer is the byte channel with no table in the way. bytes.fromhex("c3a9") is xxd -r -p, and data.hex(" ") is xxd -p. The distinction section 2 draws — is this string a format or is it data — is the same one behind print(f"{x}") against print("{}", x), and it has the same security shape: a % in data that reaches a format string is a byte that means something to somebody else. Python's advantage is that b"\xc3\xa9" and "€" are different types, so the question this page is about — did I just name a byte or a character? — is answered by the literal rather than by the shell you happen to be in.
ABAP. The two containers are xstring and string, and they behave exactly like printf's two halves: an xstring literal is hex you wrote down (DATA(x) = 'C3A9' — bytes, no code page consulted), while a string is characters the system will encode when it writes them out. cl_abap_conv_codepage=>create_out( ) is the encode step, and cl_abap_char_utilities holds the named constants — CR_LF, NEWLINE, HORIZONTAL_TAB — that exist precisely because you cannot type them. Two things transfer directly: build a test fixture as an xstring when the bytes are the point, exactly as this page builds files with printf; and remember that a string written to a file has an encoding decision in it whether or not OPEN DATASET names one. (Not machine-checked — CI cannot run ABAP.)
Try it¶
- Make the smallest broken file you can —
printf 'a\351b' > f— and run your own pipeline over it: the CSV parser, the log shipper, the importer. This is how you find out what your tools do with a byte that is not text, on a file small enough to reason about. - Run
echo "$x"andprintf '%s\n' "$x"withxset to-n, then to-e, then toa\tb. Two of the six answers will surprise you. - Find out what
printf '\u20ac'does on every machine you can reach — your Mac, a container, a CI runner — and check the answer against the table above. - Take a file you care about, run
xxd -pon it, edit one hex digit, and put it back withxxd -r -p. That is the whole edit-the-bytes workflow, and it is worth doing once on a file whose damage you can see.
Practice¶
Four writes that look identical. Before running anything, write down the exact bytes each of these puts on the pipe, and how many:
Then answer the harder half: none of those four is machine-dependent. Change one character in one of them so that it becomes machine-dependent, and say what the three possible answers are.
Answers
Verified output of printf_writes_bytes_kata_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
THE FOUR WRITES
printf '\x41\x42' 41 42 2 bytes
printf '%s' '\x41\x42' 5c 78 34 31 5c 78 34 32 8 bytes
printf '%b' '\x41\x42' 41 42 2 bytes
echo '\x41\x42' 5c 78 34 31 5c 78 34 32 0a 9 bytes
WHY THEY DIFFER
Rows 1 and 3 wrote AB. The escapes were read, because the FORMAT is
always scanned and %b asks for an ARGUMENT to be scanned too.
Row 2 wrote eight bytes: 5c is a backslash, and 78 34 31 is the ASCII
for x41. Under %s an argument is data, and a backslash is a character
like any other. Nothing was interpreted, which is usually what you
want when the string came from outside your script.
Row 4 wrote the same eight bytes plus 0a. bash's echo does not read
escapes without -e — but /bin/sh's echo on many systems does, and
there is your portability bug: one script, two byte counts, no error.
THE ONE-CHARACTER CHANGE
Replace the x in row 1 with a u, and 41 becomes a CODE POINT rather
than a byte: printf '\u0041'
printf '\x41' here, and everywhere 1 byte
printf '\u0041' here NOT RECORDED — see below
\x41 is one byte on every machine ever built, which is why its length
is printed above and \u0041's is not. \u0041 writes ONE byte on a bash
that implements \u — 4.2 and later, in any locale, since A exists in
all of them — and SIX on macOS's bash 3.2, which predates the escape
and hands it back as text. Two answers, so no answer key.
Outside ASCII the fork has three tines rather than two:
printf '\u20ac' bash 5.2 + a UTF-8 locale e2 82 ac a euro sign
bash 5.2 + LC_ALL=C \u20AC hex uppercased
bash 3.2 (macOS), any \u20ac untouched
Same command, three answers, one euro sign. Name bytes rather than
code points and the question never arises.
See also¶
- Reading a hex dump — the other direction, and the columns to trust
xxdis the dump you can put back —-pand-r -pin full- Writing a code point — where the
\umeasurement was first taken, and the escape forms in every language - The NUL byte — the byte section 6 loses, and why it is the interesting one
- Locale and
LC_CTYPE— the setting\uconsults and\xdoes not iconv— once the file exists, the tool that re-encodes it