Skip to content

A character and its bytes on one line

Level: 101 · for anyone with a terminal

One line: printf '%s = ' "$(cat f)"; xxd -p f prints ż = c5bc — the character and its spelling on a single row, which is the shortest useful thing a terminal can tell you about text, and it only works because of three separate decisions about newlines.

The command

printf '%s = ' "$(cat one.txt)"; xxd -p one.txt
Abridged for the lesson — the generated run is below
ż = c5bc

In fish, command substitution has no dollar sign, so the same line reads printf '%s = ' (cat one.txt); xxd -p one.txt. Everything else is identical.

That output is the whole subject of this library on one row: on the left a character, on the right the bytes that spell it, and no way to get from one to the other except by naming an encoding. Keep it in your fingers — it is the fastest way to answer "what is actually in this file?"

Why it fits on one line

Three parts, and each is making a decision about the newline that the previous page is about:

Part Its job The newline decision
printf '%s = ' writes the character and the separator writes no newline, so the line stays open
$(cat one.txt) reads the file's text strips every trailing newline, so the text cannot break the line
xxd -p one.txt writes the bytes as bare hex writes one newline, which closes the row

Swap any one of them and it falls apart. echo instead of printf puts the two halves on separate lines. Plain xxd instead of xxd -p brings an offset column and a text column and wraps. And without the substitution — printf '%s = ' < one.txtprintf reads no arguments from stdin at all and prints just the separator.

The substitution is the part worth understanding, because it is doing something people find surprising when they meet it elsewhere: $(…) removes all trailing newlines, not one. That is what makes this line work, and it is why the same construct is the wrong tool for measuring a file. Measure with wc -c.

xxd versus xxd -p

xxd is for reading — an offset, the bytes in pairs, and a text column to orient yourself in. xxd -p (for "postscript") is for using — nothing but the hex digits, which is the form you paste into a bug report, an email, or the next command. It is also the only one of the three dump tools with a reverse gear:

echo c5bc | xxd -r -p

That prints ż — bytes back from hex, which makes the pair a round trip you can actually test rather than a display you have to trust.

In the terminal

Verified output of character_and_its_bytes_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

1. THE ONE-LINER
$ printf "%s = " "$(cat one.txt)"; xxd -p one.txt
ż = c5bc
   Three parts, and each one is doing a job:
     printf '%s = '   writes the character and NO newline, so the line stays open
     $(cat one.txt)   substitution — hands over the text, trailing newlines stripped
     xxd -p           bare hex, no offset column, no ascii column — and it ends the line

2. WITHOUT A FILE: THE SAME THING ON A PIPE
$ printf "ż" | xxd -p
c5bc

3. WHY -p, AND NOT PLAIN xxd
$ xxd one.txt
00000000: c5bc                                     ..
$ xxd -p one.txt
c5bc
   Plain xxd is for READING: an offset, byte pairs, and a text column.
   -p ('postscript') is for USING: just the hex, which is what you paste into
   a bug report, an email, or the next command.

4. THE REVERSE GEAR
$ echo c5bc | xxd -r -p
ż
   xxd -r -p turns the hex back into bytes — the only one of xxd/od/hexdump
   that goes both ways, which makes the pair a round trip you can test.
   The letter above sits on its own line only because this script printed a
   newline after it — xxd -r -p wrote the two bytes and stopped, which is the
   previous lesson happening inside this one.

5. A TABLE, WHICH IS THE ONE-LINER IN A LOOP
   char     bytes   hex
   A        1       41
   ż       2       c5bc
   €      3       e282ac
   😀     4       f09f9880
   One character each, one to four bytes each. That column is UTF-8's whole
   design, and this loop is how you check it on any character you meet.
   (The char column looks ragged because printf pads by BYTES, not by how
    wide the glyph is — a two-byte letter eats two of its eight columns.)

6. THE TRAP IN THE SUBSTITUTION
$ printf "abc\n\n\n" > blanks.txt   # six bytes: a b c and three newlines
   file on disk : 6162630a0a0a
   $(cat file)  : 616263
   The substitution ate all three newlines, not just one. That is exactly what
   you want when you are printing a character beside its bytes — and exactly
   wrong if you were trying to measure the file. Measure files with wc -c.

In Python

The shell version runs three programs to put a character beside its bytes. Python asks the string, which makes visible the step the shell hides: a str has no bytes until you name an encoding. xxd -p only ever saw the result of that choice.

Verified output of character_and_its_bytes_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. THE ONE-LINER
   ż = c5bc
   .encode('utf-8') is the step the shell does not show you: a str has no
   bytes until you name an encoding. xxd -p only ever saw the result.

2. THE TABLE
   char   bytes  hex            code point name
   A      1      41             U+0041     LATIN CAPITAL LETTER A
   ż      2      c5 bc          U+017C     LATIN SMALL LETTER Z WITH DOT ABOVE
   €      3      e2 82 ac       U+20AC     EURO SIGN
   😀      4      f0 9f 98 80    U+1F600     GRINNING FACE
   The last two columns are the ones no terminal tool gives you for free:
   the code point is the character's NUMBER, the hex is its UTF-8 SPELLING,
   and confusing the two is the single most common mistake in this subject.

3. THE REVERSE GEAR
   bytes.fromhex('c5bc')          -> b'\xc5\xbc'
   bytes.fromhex('c5bc').decode() -> 'ż'
   Python's pair for xxd -p and xxd -r -p, and it fails loudly on bad input
   where the shell would hand you a broken file and say nothing.

4. WHY THE HEX IS NOT THE CODE POINT
   A  U+0041  ->  41
   ż  U+017C  ->  c5 bc
   €  U+20AC  ->  e2 82 ac
   For 'A' they look identical (41 and U+0041) and that coincidence is why
   ASCII hides the distinction for a whole career. For 'ż' the number is
   U+017C and the bytes are C5 BC — nothing about one is readable in the other.

The column this line does not give you

c5bc is the character's spelling in UTF-8. It is not the character's number, which is U+017C. For A the two coincide — 41 and U+0041 — and that coincidence is why the distinction can stay hidden for an entire career of working in ASCII. For anything above U+007F they diverge completely, and no amount of staring at c5bc will reveal 017C.

So this one-liner answers "how is it stored?" and never "what is it?". For the second question you need a table lookup, which is what unicodedata in Python does — and in the terminal, what uni does. It is the one tool worth installing purely for this chapter, because it prints every column at once:

Measured 2026-09-06 — uni 2.9.0, brew install uni. Not machine-checked: CI has only the tools macOS and Ubuntu both ship, so no example on this page uses it.
$ uni identify 'żé€'
             Dec    UTF8        HTML       Name
'ż'  U+017C  380    c5 bc       &zdot;     LATIN SMALL LETTER Z WITH DOT ABOVE
'é'  U+00E9  233    c3 a9       &eacute;   LATIN SMALL LETTER E WITH ACUTE
'€'  U+20AC  8364   e2 82 ac    &euro;     EURO SIGN

The UTF8 column is what the one-liner above prints. The U+ column is the number, the Name column is the answer to "what is it?", and no amount of staring at c5bc produces either of them. uni search polish goes the other way, from a word to the characters. See RESOURCES.md for the rest of the terminal toolbox.

If you are coming from Python or ABAP

Python: ch.encode('utf-8').hex() is xxd -p, and bytes.fromhex(s).decode('utf-8') is xxd -r -p. The difference that matters is failure: Python raises on bytes that are not valid UTF-8, where the shell hands you a broken file and says nothing. Python also gives you hex(' ') for spaced output, which xxd -p cannot do.

ABAP (Not machine-checked — CI cannot run ABAP.) The pair is cl_abap_codepage=>convert_to( text ), which returns an xstring — the xstring type is the hex string this page prints, which is why an ABAP debugger shows you C5BC for a ż in a way no Python or Rust debugger will. convert_from is the reverse. The trap is that the default code page comes from the system, so the same statement on two systems can return two different xstrings; name the code page when the answer matters.

Try it

  1. Run the one-liner on a file you did not create — a .csv from a colleague, say. Is the first byte ef?
  2. Replace xxd -p with plain xxd and explain, out loud, why the output now takes two lines.
  3. Run printf 'é' | xxd -p, then printf 'é' | iconv -f UTF-8 -t ISO-8859-1 | xxd -p. One character, two spellings.
  4. Take the hex from step 3 and put it back through xxd -r -p. Which of the two comes back as é in your terminal, and why?

Practice

Why does the one-liner fit on one line? Build the file yourself — printf '\305\274' > f — then run printf '%s = ' "$(cat f)"; xxd -p f and predict the output before you press return.

Then account for the newlines. Exactly three separate decisions about the byte 0a make that row a row: one about the file, one about printf, one about xxd. Name all three, and say what the output would look like if each one went the other way. Finish by saying which half of the line decodes and which half does not.

Answers

Verified output of character_and_its_bytes_kata_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

THE LINE
   $ printf '%s = ' "$(cat f)"; xxd -p f
   ż = c5bc

THREE DECISIONS, AND THE LINE NEEDS ALL THREE

1. THE FILE HAS NO TRAILING NEWLINE.
   f is 2 bytes: c5bc -- letter only
   If it had one, $(cat f) would still be 'ż' -- command substitution
   strips trailing newlines -- but xxd -p would print c5bc0a and the row
   would claim the letter is three bytes. The file must be exactly the
   character for the two halves of the line to be about the same thing.

2. printf DOES NOT ADD ONE, SO THE ROW STAYS A ROW.
   printf %s = -> no newline, so xxd continues the SAME line
   With echo the '=' would end the line and the hex would land under it.

3. xxd -p DOES ADD ONE, SO THE ROW ENDS.
   xxd -p output ends with 0a
   That is the 0a finishing the line -- supplied by the tool, not by the
   file. Three separate decisions about one byte, and the readable
   one-liner is what you get when all three line up.

WHAT EACH HALF IS ACTUALLY DOING
   $(cat f)   DECODES: the terminal draws these bytes using your locale.
              Get the locale wrong and the left side is mojibake while
              the right side stays correct.
   xxd -p     does not decode at all. It is the file.
   So the row is a decoded reading beside the bytes it came from -- which
   is the whole reason it is worth typing: the two halves can disagree,
   and when they do, the right one is right.

See also