Skip to content

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:

Measured 2026-09-06 — macOS 26.6 (BSD cut) and ubuntu:24.04 (GNU coreutils 9.4), file = 'café bar', LC_ALL=en_US.UTF-8. Not machine-checked: no key can match both.
                              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

Measured 2026-09-06 — macOS 26.6, LC_ALL=en_US.UTF-8. Three lines; the middle one holds the invalid bytes ff fe.
$ 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

  1. printf 'café bar\n' | cut -c1-4 | xxd -p on a Mac and on a Linux box. Two answers.
  2. The same with -b1-4. One answer, everywhere, and it is the honest one.
  3. 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.
  4. cut -c1-3 a 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