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.
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 << 2is au8by the same rule that made255u8one. 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 << 2is 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.
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
SHIFTis a false friend. ABAP'sSHIFTmoves characters in a string. Bit work happens on the byte types —xandxstring— throughBIT-AND,BIT-OR,BIT-XOR,BIT-NOT,GET BITandSET 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 / 2is 3, where C, Rust and bash give 2 and Python's//gives 2.DIVandMODare 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.
bands, ABAP's one- and two-byte integers, are never chosen, so an expression that uses them is calculated inior wider: abfield holding 200, doubled, is 400, just as in C. The difference is at the store. C'sunsigned charkeeps the low eight bits without a word; ABAP converts the result to the field's type and, if it does not fit, raisesCX_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¶
- A byte is eight bits — what a byte holds, which is the half this page assumes
- Counting in hexadecimal — the shell's one width, met from the other end: 63 bits and a sign bit
- Hex: a number, or a picture of bytes — the other place a value narrows at the store rather than in the operator
- Bytes, hex and int — the rest of Python's toolkit for the boundary
to_bytesdraws - Validation is a boundary — the same
unsigned chardiscipline, applied where it matters most