Skip to content

Arithmetic has its own width

Level: 101 → 201 · for anyone starting from zero

One line: A byte holds eight bits, but almost nothing computes in eight — so 255 << 2 is 1020 in Python, 1020 in the shell, 252 in Rust, and in C it is 1020 right up until the moment you store it back in the byte, where it becomes 252.

The store is eight bits. The operator is not.

A byte is eight bits settles what a byte holds: 256 patterns, 0..255, and no ninth bit anywhere. That is a fact about storage. This page is about the other half, which nothing in the first fact implies: how wide the arithmetic is when you use one.

Take the widest byte there is and shift it two places left.

  1111 1111       255, every bit on
<<          2
1 1111 1100       1020, and the answer needs ten bits

Ten bits will not go into eight. Every language on this page agrees about the picture and disagrees about what to do next, and the four answers are the four available positions:

  • Python keeps all ten bits, because its integers have no width to overflow. You get 1020, and if you wanted the byte you say so with a mask.
  • The shell keeps all ten too, in its one and only integer type — 64 bits, signed, and there is no way to ask for another.
  • Rust keeps eight, because 255u8 << 2 is a u8 by the same rule that made 255u8 one. You get 252, and widening is a cast you write down.
  • C does something none of the others do: it widens silently, computes at the wider size, and narrows again only when you store. 255 << 2 is genuinely 1020 as an expression, and genuinely 252 in the variable.

That last one is the reason this page exists. C's rule is called integer promotion: before an arithmetic operator runs, every operand narrower than int is converted to int. It is not optional, it costs nothing at runtime, and — this is the part that catches people — there is no syntax for it. Nothing in c << 2 says a widening happened. A comment claiming the answer is 1111 1100 is describing the destination, not the expression, and the two are different numbers.

The practical shape of the bug: a model in which a char is an 8-bit calculator keeps giving right answers, because the widened result usually gets stored straight back into a char and narrows to what you expected. It stops the first time the result is used rather than stored — in a comparison, as an array index, or passed to a function.

unsigned char x = 200;
if (x * 2 > 255) { /* this branch RUNS: x * 2 is 400, an int */ }

In Python

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

1. THERE IS NO WIDTH TO OVERFLOW
   c                     = 255    1111 1111, the largest value a byte holds
   c << 2                = 1020   and Python simply keeps the ninth and tenth bits
   (c << 2) has          = 10     bits, so it no longer fits in a byte
   (1 << 1000) has       = 1001   bits, and that is not an error either
   An int here is as wide as its value needs. Nothing wraps, because there
   is no edge to wrap at -- the memory is the only limit.

2. SO THE BYTE ANSWER HAS TO BE ASKED FOR BY NAME
   (c << 2) & 0xFF       = 252    mask to eight bits by hand
   ((c << 2) & 0xFF)     = 0b11111100
   int.to_bytes(1, ...)  = b'\xfc'   the width named in the call
   Both spellings say `eight bits` out loud. In a language where the type
   carries the width, that sentence is in the declaration instead -- and in C
   it is in neither, which is why the C aside on this page exists.

3. SIGNEDNESS IS AN ARGUMENT, NOT A PROPERTY OF THE BYTES
   the byte              = b'\x9c'  ->  10011100
   from_bytes(signed=False) = 156
   from_bytes(signed=True)  = -100
   One byte, two numbers, and the byte does not know which you meant. The
   keyword is where the reading lives; nothing about the storage decides it.

4. >> AND // AGREE HERE, AND IN C THEY DO NOT
   x = -5  x >> 3 = -1  x // 8 = -1  x % 8 = 3   int(x / 8) = 0
   x = -8  x >> 3 = -1  x // 8 = -1  x % 8 = 0   int(x / 8) = -1
   x = 5   x >> 3 = 0   x // 8 = 0   x % 8 = 5   int(x / 8) = 0
   Counted over -8..-1: `>>` and `//` differ on 0 of 8 values, because both
   round toward minus infinity. `>>` and C's truncating division differ on 7.
   Python's `%` follows its `//`, so it is never negative for a positive
   divisor -- which is the half of this that most often surprises a C reader.

5. THE ONE PLACE PYTHON DOES INSIST ON A WIDTH
   (255).to_bytes(1, 'big') -> b'\xff'
   (256).to_bytes(1, 'big') -> raises OverflowError
   (-1).to_bytes(1, 'big') -> raises OverflowError
   The width is not in the int; it is in the request to become bytes. That
   is the boundary this whole chapter is about, and it is the only place a
   Python program is made to say how many bits it meant.

In the terminal

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

1. THE SHIFT THAT LEAVES THE BYTE, AND NOTHING NOTICES

$ echo $(( 255 << 2 ))          # 1111 1111 shifted twice -- ten bits now
1020

$ echo $(( (255 << 2) & 0xFF )) # the byte answer, masked by hand
252
   There is no byte in this script. $(( )) has one integer type, and the only
   way to say 'eight bits' is to write the mask yourself.

2. WHICH DIVISION THE SHELL CHOSE

$ echo $(( -5 / 8 ))            # rounds toward zero
0

$ echo $(( -5 % 8 ))            # so the remainder is negative
-5

$ echo $(( -5 >> 3 ))           # rounds down: a shift is not a division here either
-1
   The shell follows C, not Python. That is not a coincidence: $(( )) is C
   arithmetic, evaluated with the host's own integer operators.

3. THE BASE IS NOT A TYPE

$ x=0xFF; echo $(( x + 1 ))     # hex in, decimal out, no declaration anywhere
256

$ echo $(( 2#11111111 + 1 ))    # base#digits is the shell's own spelling for binary
256

$ z=010; echo $(( z + 1 ))      # and a LEADING ZERO means octal, so 010 is 8
9
   Three literals, three bases, one type. The last one is the trap: a zero-padded
   number out of a log file or a date becomes octal without being asked, and 08
   is then an error rather than eight.

4. THE WIDTH APPEARS ONLY WHERE THE NUMBER BECOMES BYTES

$ echo $(( 255 << 2 ))                      # the shell's answer, as a number
1020

$ printf '%d' $(( 255 << 2 )) | xxd         # written out as text: four bytes
00000000: 3130 3230                                1020

$ printf '\374' | xxd                       # written out as the byte 252: one byte
00000000: fc                                       .
   Same value, four bytes and one, and only the last two commands ever had
   to know how wide anything was. That boundary is where Python makes you
   call to_bytes() and where a C declaration made the choice long before.

In Rust

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

1. THE WIDTH IS IN THE TYPE, AND THE OPERATOR STAYS INSIDE IT
   c                       = 255    1111 1111
   c << 2                  = 252    still a u8, so the two high bits are gone
   size_of_val(&(c << 2))  = 1      the expression did not become anything wider
   There is no promotion here. `c << 2` is a u8 because `c` is a u8, and the
   answer 252 is the whole answer -- nothing was computed and then thrown away.

2. WIDENING IS A CAST YOU HAVE TO WRITE DOWN
   (c as u32) << 2         = 1020   the number C would have computed for you
   Same bits, same operator, different answer -- and the difference is one
   visible `as u32`. That is the whole trade: Rust makes you say which
   arithmetic you meant, and in exchange the expression cannot surprise you.

3. THE TYPE PICKS THE SHIFT. THE BITS DO NOT.
   s: i8  = -5    bits 11111011
   u: u8  = 251   bits 11111011   <- the same eight bits
   s >> 3 = -1    arithmetic: the sign bit is copied in on the left
   u >> 3 = 31    logical:    zeros are shifted in on the left
   One byte, one operator, two answers. Nothing in `>>` chose between them;
   the declaration did, several lines earlier.

4. DIVISION FOLLOWS C, AND PYTHON'S ANSWER HAS ITS OWN NAME
   -5 / 8                  = 0      rounds toward zero, exactly as C does
   -5 % 8                  = -5     so the remainder is negative
   (-5).div_euclid(8)      = -1     rounds down, exactly as Python's // does
   (-5).rem_euclid(8)      = 3      so this remainder never is
   Counted over -8..-1: `>>` differs from `/` on 7 values and from
   `div_euclid` on 0. Four spellings of division, and you pick one by name.

5. WHAT RUST CHECKS, AND THE ONE THING IT DOES NOT
   250u8.checked_add(10)   = None   overflow of + is caught
   255u8.checked_shl(2)    = Some(252) but checked_shl only checks the AMOUNT,
   255u8.checked_shl(8)    = None   and 8 is not a legal shift for a u8
   So a left shift drops bits off the top in silence while an addition that
   overflows by one bit is refused. The rule is narrower than `Rust checks
   arithmetic`: a shift is checked for asking too much, not for losing data.

The C view: the widening nobody wrote

C is the only language here where the width of the arithmetic is stated nowhere — not in the expression, not in the declaration, not in the answer. This aside is on the page because that invisibility is the lesson, not because the page is about C.

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

1. THE STORE IS EIGHT BITS. THE OPERATOR IS NOT.
   sizeof(c)               = 1   the variable really is one byte
   sizeof(c << 2)          = 4   the EXPRESSION is an int, 4 bytes wide here
   Before any arithmetic operator runs, C converts every operand narrower
   than int to int. It is called integer promotion, it is not optional, and
   there is no syntax for it -- nothing in `c << 2` says a widening happened.

2. WHICH IS WHY A SHIFT CAN OUTGROW THE VARIABLE IT CAME FROM
   c                       = 255    1111 1111
   c << 2                  = 1020   <- what the operator produced, as an int
   (unsigned char)(c << 2) = 252    <- what survives the store back into a byte
   Both numbers are right. The narrowing happens at the STORE, not in the
   shift, so ((c << 2) > 255) is 1 while d is only 252.

3. TWO OPERATORS THAT LOOK LIKE DIVISION AND DISAGREE ON NEGATIVES
   s                       = -5     1111 1011 read as a signed byte
   s >> 3                  = -1     <- shifts round DOWN, toward minus infinity
   s / 8                   = 0      <- division rounds toward ZERO (C99 onward)
   s % 8                   = -5     <- and the remainder carries the sign of s
   Counted, not assumed: over the 8 values -8..-1 the two answers differ on
   7 of them, and over the 256 values 0..255 they agree on 256. So `>> 3` is a
   fast `/ 8` exactly while the value cannot be negative.

4. ONE BYTE, TWO READINGS -- AND ONLY ONE DIRECTION IS PROMISED
   signed char neg         = -100   the value stored
   (unsigned char)neg      = 156    the same eight bits, read unsigned
   Converting TO an unsigned type is defined by the standard: reduce modulo
   2^8 and you are done. Going back the other way -- putting 156 into a signed
   char -- is implementation-defined in C11, so this program does not do it.

5. THE ONE THING THIS PROGRAM DELIBERATELY WILL NOT PRINT
   Whether plain `char` is signed. C leaves that to the implementation, so it
   is a property of the machine and the compiler rather than of the language,
   and an answer key here could only be right on the machine that recorded it.
   Ask your own compiler with one line -- printf("%d\n", CHAR_MIN); -- and
   until you have, write `signed char` or `unsigned char` whenever the byte is
   a NUMBER. Plain `char` is for text, where the question does not arise.

One expression, four languages

C Python Rust bash
255 << 2 1020 as an expression, 252 once stored in an unsigned char 1020 252 1020
Where the width is written nowhere — promotion is implicit in the call that makes bytes (to_bytes) in the type, once, at the declaration nowhere, because there is only one
Widening automatic, to int not a concept a visible as u32 not a concept
Narrowing at the store, silently & 0xFF, by hand a visible as u8 & 0xFF, by hand
-5 >> 3 -1 -1 -1 -1
-5 / 8 0 — toward zero -1// rounds down 0, and div_euclid gives -1 0 — toward zero
-5 % 8 -5 3 -5, and rem_euclid gives 3 -5
Does >> match the division? no yes no for /, yes for div_euclid no
Arithmetic vs logical >> chosen by the operand's signedness one operator, no width chosen by the type — same bits, two answers always signed
Overflow of + wraps (unsigned) or is UB (signed) impossible panics in debug, wraps in release wraps at 64 bits

Two rows are worth stopping on.

>> is not a fast /. The C example counts it rather than claiming it: over the eight values -8..-1 the two answers differ on seven, and over the 256 values 0..255 they agree on all 256. So the substitution is safe exactly while the value cannot be negative — which is a statement about the type, and therefore the one language where you can check it by reading the declaration is Rust.

Same bits, two answers. The Rust example declares let s: i8 = -5 and let u: u8 = 0b1111_1011, which are the same eight bits, and shifts both right by three: -1 and 31. Nothing in >> chose between arithmetic and logical. The declaration did, several lines earlier. In Python the question cannot arise, because there are no bits to run out of; in C it is decided by the operand's signedness, which for a plain char is the next section's problem.

What an answer key here cannot hold

Three facts belong on this page and in none of its programs, because they are properties of a compiler, a standard revision, or a machine — the sort of claim CONTRIBUTING.md keeps out of recorded output and puts in a dated fence instead.

Whether plain char is signed is not a language fact. C leaves it to the implementation. Same source, one flag apart, on this machine:

Measured 2026-09-07, x86_64 Darwin 26.6, Apple clang 21.0.0

$ cc -o s signedness.c && ./s
CHAR_MIN=-128  CHAR_MAX=127  -> plain char is SIGNED
char ch = -100;  prints as %d -> -100
ch == -100 ? yes
ch >> 3 -> -13

$ cc -funsigned-char -o s signedness.c && ./s
signedness.c:8:36: warning: result of comparison of constant -100 with expression of type 'char' is always false [-Wtautological-constant-out-of-range-compare]
    8 |     printf("ch == -100 ? %s\n", ch == -100 ? "yes" : "NO!");
      |                                 ~~ ^  ~~~~
1 warning generated.
CHAR_MIN=0     CHAR_MAX=255  -> plain char is UNSIGNED
char ch = -100;  prints as %d -> 156
ch == -100 ? NO!
ch >> 3 -> 19

ch == -100 is false in the second column, and the compiler says so at build time ("comparison of constant -100 with expression of type 'char' is always false") — but only because the constant is right there in the source. The flag is not exotic: it exists because the choice belongs to the platform's ABI, and some ABIs choose unsigned. Ask your own compiler with printf("%d\n", CHAR_MIN); before you trust any 8-bit arithmetic you read, and write signed char or unsigned char whenever the byte is a number. Plain char is for text.

The binary literal is younger than most of the code that uses it. 0b1111_1011 reads as the obvious spelling, and in C it was a GCC extension for decades — standard only in C23:

Measured 2026-09-07.  x.c is one line:  unsigned char x = 0b1010;

$ gcc-15 -std=c17 -pedantic -c x.c
x.c:1:19: warning: binary constants are a C23 feature or GCC extension [-Wpedantic]
    1 | unsigned char x = 0b1010;
      |                   ^~~~~~

$ gcc-15 -std=c23 -pedantic -c x.c      # accepted in silence, exit 0
$ cc -std=c11 -Wall -Wextra -c x.c      # accepted in silence too, so the extension never announces itself

$ bash -c 'echo $(( 0b1010 ))'
/bin/bash: 0b1010: value too great for base (error token is "0b1010")
                                        # identical on bash 3.2.57 and on 5.2.21

Python has had 0b since 2.6 (2008) and Rust since 1.0, so a reader arriving from either will reach for it and, under -std=c11 with no -pedantic, get no warning at all. The shell has never had it; its own spelling is 2#1010.

Shifting by the width or more is undefined behaviour in C, and it fails quietly. The one place on this page where a wrong answer comes back with no diagnostic:

Measured 2026-09-07, x86_64, Apple clang 21.0.0.  The shift amount is argc + 31, so
the compiler cannot fold it, and -Wall -Wextra is clean in both builds.

$ cc -std=c17 -Wall -Wextra -fsanitize=undefined -o ub3 ub3.c && ./ub3
ub3.c:6:54: runtime error: shift exponent 32 is too large for 32-bit type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ub3.c:6:54
s << 32  (shift == int width) = -5

$ cc -std=c17 -Wall -Wextra -O2 -o ub3o ub3.c && ./ub3o
s << 32  (shift == int width) = -5

-5 is what the hardware does when the shift count is masked to five bits; it is not what C promises, and -Wall -Wextra never mentions it. Rust makes the same expression a compile error when the amount is a constant and a panic in a debug build when it is not; in release it masks, but masking is then the documented behaviour rather than a licence for the optimiser. Python shifts as far as you ask (-5 >> 100 is -1, forever).

If you are coming from Python or ABAP

From Python, the thing to carry across is that you have never had to think about this, and the moment you leave pure int you do. to_bytes is the boundary and it raises — but struct.pack, array, ctypes, a numpy dtype, a socket, or a database column with a declared width all reintroduce a fixed width, and not all of them are as loud about it. The habit worth forming is the one Rust enforces: when a number is going to become bytes, say how many bits you mean at the point where you say it, not at the point where it breaks.

From ABAP (not machine-checked — CI cannot run ABAP), four differences are worth knowing, and the first is the biggest on this page:

  • ABAP raises where C wraps. Integer overflow is a catchable exception, CX_SY_ARITHMETIC_OVERFLOW, not a silent wrap to a wrong number. Of the four languages here, only Python's "no width at all" is safer, and it is safer by not having the problem rather than by catching it.
  • There is no shift operator, and SHIFT is a false friend. ABAP's SHIFT moves characters in a string. Bit work happens on the byte types — x and xstring — through BIT-AND, BIT-OR, BIT-XOR, BIT-NOT, GET BIT and SET BIT. So the expression this whole page is about cannot be written at all; you reach for the byte type first, which is arguably the right instinct.
  • Integer division rounds, and every language on this page truncates or floors. With an integer target, / rounds to nearest — 5 / 2 is 3, where C, Rust and bash give 2 and Python's // gives 2. DIV and MOD are the integer pair. This is the single most transferable trap in the list: a ported formula that looked right in ABAP is off by one in three other languages, and nothing warns.
  • ABAP widens before it computes, as C does, and checks the store where C narrows. Every arithmetic expression gets a calculation type ↗ before anything is calculated, chosen from the types of all its operands, including the field the result is assigned to. b and s, ABAP's one- and two-byte integers, are never chosen, so an expression that uses them is calculated in i or wider: a b field holding 200, doubled, is 400, just as in C. The difference is at the store. C's unsigned char keeps the low eight bits without a word; ABAP converts the result to the field's type and, if it does not fit, raises CX_SY_CONVERSION_OVERFLOW — a different exception from the first bullet's, since the arithmetic itself never overflowed.

ABAP does have an unsigned integer type, and it is easy to miss. The last bullet's b runs from 0 to 255, and SAP lists it as an internal type ↗: no ABAP statement can name it, in the source or at runtime, and a type or field of your own is b when it is defined by reference to an ABAP Dictionary data element whose type is INT1. The arithmetic-vs-logical shift question in the table above still has no ABAP column, but the second bullet is the reason, not a missing type: with no shift operator there is nothing to ask it of.

Try it

cd 01_Bits_and_Bytes/arithmetic_has_its_own_width/examples
python3 arithmetic_has_its_own_width_py.py
bash arithmetic_has_its_own_width_sh.sh
rustc --edition 2024 arithmetic_has_its_own_width_rs.rs && ./arithmetic_has_its_own_width_rs
cc -std=c11 -Wall -Wextra promotion_is_invisible_c.c -o /tmp/pi && /tmp/pi

Then ask your own compiler the one question this page refused to answer for you:

printf '#include <limits.h>\n#include <stdio.h>\nint main(void){printf("CHAR_MIN=%%d\\n",CHAR_MIN);}\n' > /tmp/cm.c
cc -o /tmp/cm /tmp/cm.c && /tmp/cm
cc -funsigned-char -o /tmp/cm /tmp/cm.c && /tmp/cm

If the two lines differ, you have just watched the same source produce two different languages.

Practice

Predict two columns. For each of 200 + 100, 255 << 2, 1 << 9 and 60 + 5, write down the arithmetic answer, and then what is left when you store that answer back into one byte. Three of the four lose something; say how much, and say where the loss happens in Python, in Rust and in C — the three answers are different, and only one of them is at the operator.

Answers

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

expression   arithmetic  stored in a byte   fits?
200 + 100           300                44   NO -- lost 256
255 << 2           1020               252   NO -- lost 768
1 << 9              512                 0   NO -- lost 512
60 + 5               65                65   yes

Only 60 + 5 comes back. The other three are not wrong arithmetic: 300,
1020 and 512 are the right answers, and Python, the shell's $(( )) and
C's int all produce them. What differs is the BOX you put the answer in.

The masking is what a fixed-width type does for you, and where it
happens is the whole lesson:
  * Python  -- never. int grows; you only lose bits if you write & 0xFF.
  * Rust    -- at the operator. 255u8 << 2 is 252 in release and a panic
               in debug, because the type is eight bits all the way
               through. There is no wide intermediate to look at.
  * C       -- at the STORE. The operands are promoted to int, so the
               expression really is 1020, and the truncation happens
               silently when it lands back in a uint8_t.

That is why the same source line gives three answers, and why 'it
overflowed' is not a useful sentence until you say where.

See also