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¶
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.txt — printf 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:
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:
$ uni identify 'żé€'
Dec UTF8 HTML Name
'ż' U+017C 380 c5 bc ż LATIN SMALL LETTER Z WITH DOT ABOVE
'é' U+00E9 233 c3 a9 é LATIN SMALL LETTER E WITH ACUTE
'€' U+20AC 8364 e2 82 ac € 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¶
- Run the one-liner on a file you did not create — a
.csvfrom a colleague, say. Is the first byteef? - Replace
xxd -pwith plainxxdand explain, out loud, why the output now takes two lines. - Run
printf 'é' | xxd -p, thenprintf 'é' | iconv -f UTF-8 -t ISO-8859-1 | xxd -p. One character, two spellings. - 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¶
- The trailing newline — the rule all three parts of this line depend on
printfwrites bytes — the rest of whatprintfcan put on a pipe- Inspecting a file — the same tools when you want the whole file rather than one row
- Unicode code points — the column this line does not give you
-cis not the prompt, in the Python library ↗ — the same bare( )is whypython3 -c chr(0x20AC)never reaches Python in fish