cut counts what it is told to count¶
Level: 201 · for anyone slicing columns out of an export
One line: -b selects bytes and -c selects characters, which are different words for a good reason — and of the two cuts in common use, only BSD's keeps the second promise, only in a UTF-8 locale, so cut -c1-4 on one file gives four bytes on Ubuntu and five on a Mac.
The two flags¶
cut offers three ways to say where to cut, and they are not three spellings of one idea:
| Flag | Selects | Honest? |
|---|---|---|
-b |
bytes | always — it promises bytes and delivers bytes on every platform, in every locale |
-c |
characters | only on BSD cut, only in a UTF-8 locale. GNU cut has no multibyte support at all and -c is an alias for -b |
-d / -f |
fields, split on a delimiter | always safe — it never looks inside a field |
The third row is where nearly all real cut usage lives, and it is why the first two rows stay hidden for years.
In the terminal¶
Everything below runs in the C locale, where a character is a byte, so -c and -b are the same flag and both platforms agree. That is what makes the difference between them so easy to never notice.
Verified output of cut_by_byte_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
1. -c AND -b, ON THE SAME FILE, IN THIS LOCALE
$ cat cafe.txt
café bar
$ xxd -p cafe.txt
636166c3a9206261720a
$ cut -c1-4 cafe.txt | xxd -p
636166c30a
$ cut -b1-4 cafe.txt | xxd -p
636166c30a
Identical, and both wrong in the same way: 63 61 66 c3 is 'caf' plus
the first byte of é. The output is not valid UTF-8 and cut said
nothing. In the C locale a character IS a byte, so -c and -b are the
same flag — which is exactly why the difference between them is so
easy to never notice.
2. THE PROMISE IN THE MANUAL
-b selects BYTES. -c selects CHARACTERS. Those are different words for
a reason, and only one of the two cuts on your PATH keeps the second
promise, and only in a UTF-8 locale. The page has the measurement.
The rule that survives it: if you mean bytes, write -b; if you mean
characters, do not use cut.
3. FIELDS ARE SAFE — THE DELIMITER IS WHAT MATTERS, NOT THE CONTENT
$ cat rows.csv
Ada,café,3
Ben,naïve,7
$ cut -d, -f2 rows.csv
café
naïve
Whole fields, comma-delimited, and the accented text inside them comes
through untouched. cut never looks inside a field, so -d/-f is safe at
any width. Nearly all real cut usage is this.
4. BUT THE DELIMITER ITSELF MUST BE ONE BYTE
$ cat bullet.txt # a bullet, three bytes: e2 80 a2
a•b
$ cut -d"•" -f2 bullet.txt
(refused — exit 1)
Both cuts refuse here, wording it differently, so only the status is
shown. A multi-byte delimiter is not a thing cut can take in this
locale — and when a field separator is a real character rather than a
comma, awk -F is the tool that will take it.
5. THE FIXED-WIDTH RECORD, WHICH IS WHERE THIS BITES FOR REAL
$ cat fixed.txt
ADA café 003
$ xxd -p fixed.txt
4144412020636166c3a92020202020203030330a
The layout was designed in CHARACTERS, the way a person counts:
name 1-5, description 6-15, code 16-18 (18 characters of record)
$ cut -b1-5 fixed.txt | xxd -p # name: correct
41444120200a
$ cut -b16-18 fixed.txt # code: should be 003
00
It printed ' 00'. The code field is one byte late, and every field
after the é is, because é costs two bytes and the layout budgeted one
character. The record is 18 characters and 19 bytes, so the byte
columns for the code are 17-19, not 16-18:
$ cut -b17-19 fixed.txt
003
Both cuts do this identically, and neither reports anything: you get a
field, it is the wrong field, and it is only obviously wrong because
this record ends in digits. Had the code been letters you would have
shipped it. The offset is correct until the first non-ASCII character
in the record and wrong for everything after it, which is why these
bugs surface months later, on one customer's data.
6. COUNTING CHARACTERS WITHOUT cut
wc -c (bytes) : 10
wc -m (this locale): 10
Both say the same number here, because in the C locale wc -m is also
counting bytes. Two flags, one answer, and no warning that the
question you asked was not the question that got answered.
The part that is not the same on both machines¶
Change to a UTF-8 locale and the two cuts stop agreeing — about the same command, on the same file:
macOS (BSD) Ubuntu (GNU 9.4)
cut -c1-4 → hex 63 61 66 c3 a9 63 61 66 c3
'café' — 4 chars 'caf' + half an é
cut -b1-4 → hex 63 61 66 c3 63 61 66 c3
cut -d'•' -f2 b refused: "the delimiter
must be a single character"
Three things follow.
1. cut -c is portable in spelling and not in meaning. The command runs everywhere, exits 0 everywhere, and returns a different number of bytes depending on the machine. On a Mac you get four characters; on Linux you get four bytes, the last of which is half a letter. Nothing warns you, and the output of the Linux run is not valid UTF-8.
2. GNU cut has never had multibyte support. This is not a bug report waiting to happen — coreutils documents -c as identical to -b, and the multibyte version has been an open wish for two decades. So on Linux, -c is a comment: it tells a reader you meant characters and does bytes.
3. A multi-byte delimiter works on exactly one of them. BSD cut in a UTF-8 locale will split on •; GNU refuses in every locale, and BSD refuses in the C locale. When your separator is a real character rather than a comma, awk -F is the tool that takes it.
The rule that survives all three: if you mean bytes, write -b. If you mean characters, do not use cut.
What it does with bytes that are not text¶
$ cut -c1-3 invalid.txt $ cut -b1-3 invalid.txt
goo goo
bad bad
cut: invalid.txt: Illegal las
byte sequence
$ echo $? $ echo $?
74 0
-c stops at the bad line, loses the good line after it, and exits 74. -b never decodes anything, so it is immune. That is the shape of the whole page: the byte flag cannot fail this way because it never made a claim about characters. On Ubuntu both run clean, because GNU -c is -b.
The fixed-width record, which is where this actually costs money¶
Section 5 of the example above is the real-world version, and it is worth reading twice. A record laid out as name 1–5, description 6–15, code 16–18 — eighteen characters — is nineteen bytes the moment one description contains an é. cut -b16-18 then returns 00 instead of 003: the code field is one byte late, and so is every field after the first non-ASCII character in the record.
Nothing reports it. You get a field; it is the wrong field; and it is only visibly wrong here because the record ends in digits. That is the fixed-width byte field problem in its natural habitat, and the reason interface specifications have to say bytes or characters in so many words. "Position 16 to 18" is not a specification.
The same choice turns up wherever a tool reports a position. rg --column counts bytes and its manual says so outright — X is the sixth character of café X and rg calls it column 7. That is the honest version of what cut -b does: pick bytes, and say in the documentation that you picked bytes.
If you are coming from Python or ABAP¶
Python: text[15:18] on a str is character slicing and always means characters; data[15:18] on bytes is always bytes; and there is no flag, locale or implementation that blurs them. That is the whole -c/-b question answered by picking a type. For fixed-width records the useful habit is to open the file in binary and slice bytes, because the layout was written in bytes by whoever wrote the file — then decode each field. Decoding first and slicing after is what produces the off-by-an-accent bug.
ABAP (Not machine-checked — CI cannot run ABAP.) text+15(3) on a string is character offsetting; the same syntax on an xstring is byte offsetting; and a fixed-width file read into a string structure will silently misalign for exactly the reason above. This is the single most common shape of encoding bug on a legacy interface: the DDIC structure counts characters, the file counts bytes, and they agree perfectly until the first customer with an umlaut in their name. Read fixed-width files as xstring, cut the fields by byte, convert each field with a named code page.
Try it¶
printf 'café bar\n' | cut -c1-4 | xxd -pon a Mac and on a Linux box. Two answers.- The same with
-b1-4. One answer, everywhere, and it is the honest one. - Take a fixed-width export you actually have.
grep -n '[^ -~]'it — every hit is a row where the byte columns and the character columns disagree. cut -c1-3a file with a broken byte in the middle under a UTF-8 locale, and check$?and how many lines came out.
Practice¶
-b or -c? On café naïve, predict cut -b1-4 and cut -c1-4 in the C locale, as bytes. Then predict both again in a UTF-8 locale — and this time the answer depends on which cut you have.
Say what BSD cut gives, what GNU cut gives, and which of the two keeps the promise the flag makes. Then say what you would use instead of either.
Answers
Verified output of cut_kata_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
THE FILE: 12 bytes of text, 10 characters
café naïve
636166c3a9206e61c3af76650a
IN THE C LOCALE, WHERE A CHARACTER IS A BYTE
cut -b1-4 636166c30a caf\xc3
cut -c1-4 636166c30a caf\xc3
Identical, and both end in c3 -- the first half of é. The output is
four bytes and is not valid UTF-8. In the C locale there is no
difference between the two flags because there is no difference
between a byte and a character.
IN A UTF-8 LOCALE THE TWO cuts SEPARATE. Measured 2026-09-07:
BSD cut (macOS) cut -c1-4 -> 63 61 66 c3 a9 FIVE bytes, four
characters: it keeps the promise -c makes.
GNU cut (Ubuntu) cut -c1-4 -> 63 61 66 c3 FOUR bytes, the
same as -b: GNU treats -c as a synonym for -b.
So the same command on the same file gives four bytes on one machine
and five on the other, and neither prints a warning.
WHY THE FLAG EXISTS AT ALL
POSIX defines -b as bytes and -c as characters precisely because they
are different questions. GNU's manual is honest about not
distinguishing them; the trap is that the FLAG still reads as a
promise, so a script written on a Mac and deployed on Linux changes
behaviour without changing a character of source.
WHAT TO USE INSTEAD
cut -d" " -f1 café -- fields, not offsets
A field boundary is in the data; a byte offset is a guess about the
data's width. Where the data really is fixed-width, it is fixed-width
in BYTES, and -b is then the correct and honest flag.
See also¶
awkis three programs —substr()is the same trap with no flag to escape ittrandsortwork a byte at a time — where this page's question was first raised- Fixed-width byte fields — the interface version, at length
sedmatches patterns, not bytes — the tool to reach for when a position will not do