Skip to content

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.

Measured 2026-09-07 — printf '\u20ac' | od -An -tx1, three configurations
  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:

python3 -c 'import sys; sys.stdout.buffer.write("€".encode("utf-8"))'

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.

Measured 2026-09-10 — macOS 26.6.2 (every row but the last) and ubuntu:24.04 (the last). The bytes written; the exit status was 0 in every cell.
                                 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.

Measured 2026-09-07 — v=$(printf 'a\000b'), stderr
  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

  1. 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.
  2. Run echo "$x" and printf '%s\n' "$x" with x set to -n, then to -e, then to a\tb. Two of the six answers will surprise you.
  3. 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.
  4. Take a file you care about, run xxd -p on it, edit one hex digit, and put it back with xxd -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:

printf '\x41\x42'
printf '%s' '\x41\x42'
printf '%b' '\x41\x42'
echo    '\x41\x42'

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