C# Operators on .NET 10: Overflow, ??, % and Shifts
C# operators re-tested on .NET 10 with real outputs: overflow and checked, ?? and ??=, negative %, integer division and the shift-count masking trap.
Revised November 2026. This tutorial first appeared in Korean in 2021. We recompiled every example on .NET 10.0.14 (C# 14), and all of them still print the same results. What the 2021 version left out is where operators actually bite in production: integer overflow that wraps silently,
%with negative numbers,1 << 32, and precedence surprises around??and string concatenation. Those are added below with the outputs we got. The original tables also showed—and–where the code needs--and-; that was a typography conversion, and the code below uses the real characters.
This article is for C# beginners, and for developers who already know the basic operators but want to know which ones can return a wrong number without an error. The short answer: integer +, - and * wrap around on overflow unless you ask for checked; / on two integers drops the fraction; % keeps the sign of the left operand; and shift counts are masked to 5 bits for int. Each one is shown below with a real output, followed by a measurement of what checked costs.
Arithmetic, increment and assignment: the 2021 examples on .NET 10
C# groups its operators the same way as in 2021: arithmetic (+ - * / %), increment and decrement (++ --), assignment (= += -= *= /= %= and more), logical (&& || !), relational (< > == != >= <=) and bitwise (& | ^, plus ~ << >> >>>, which the 2021 table did not list).
The original arithmetic example, rewritten as top-level statements:
int a = 55, b = 6;
Console.WriteLine(a + b);
Console.WriteLine(a - b);
Console.WriteLine(a * b);
Console.WriteLine(a / b);
Console.WriteLine(a % b);
61
49
330
9
1
The increment example also printed the same four lines as in 2021:
int a = 1;
Console.WriteLine(++a); // 2
Console.WriteLine(a++); // 2, then a becomes 3
Console.WriteLine(--a); // 2
Console.WriteLine(a--); // 2, then a becomes 1
The 2021 text said that the prefix form increments "before the statement runs" and the postfix form "after the statement runs". More precisely, both forms increment immediately; the difference is the value of the expression. ++a evaluates to the new value, a++ to the old one. That matters as soon as you write i = i++ + ++i;: starting from 5 it gives 12 (5 + 7), and code like that should be split into separate lines.
The assignment example printed 110 50 3000 50 50, the same as before. Compound assignment has one detail worth knowing: b += 1 on a byte compiles, while b = b + 1 does not, because b + 1 is an int and the compound form inserts the cast for you. That cast also hides overflow: a byte holding 255 becomes 0 after b++.
Integer division, % and negative numbers
Dividing two integers produces an integer. The fraction is dropped, toward zero:
int seven = 7;
Console.WriteLine($"{seven / 2} {-seven / 2} {seven / 2.0} {(double)seven / 2} {(double)(seven / 2)}");
3 -3 3.5 3.5 3
The last value is a common bug: (double)(seven / 2) divides first, as integers, and casts 3 afterwards. Cast one operand, not the result.
Because division truncates toward zero, % returns a remainder with the sign of the left operand:
-7 % 3 = -1
7 % -3 = 1
-7 % -3 = -1
5.5 % 2 = 1.5
-5.5 % 2 = -1.5
Python programmers expect -7 % 3 to be 2; C# (like C, Java and JavaScript) gives -1. Two practical consequences:
x % 2 == 1is not an odd check for negative numbers:-7 % 2is-1. Usex % 2 != 0, orint.IsOddInteger(x)(.NET 7+). The same bug appears in the 2021switch (a % 2)example in the if/switch article: witha = -13neither case matched.- For a wrap-around index such as a circular buffer, use
((i % n) + n) % n. For-7and3that printed2.
Division by zero depends on the type. 7 / 0 with an int variable threw DivideByZeroException: Attempted to divide by zero.; with constants the compiler refuses it outright (error CS0020: Division by constant zero). With double, 1.0 / 0 printed ∞ and 0.0 / 0 printed NaN, without any exception. One more edge case: int.MinValue / -1 and int.MinValue % -1 both threw OverflowException, because the mathematically correct result, 2,147,483,648, does not fit in an int.
Overflow: checked, unchecked and the compiler
This is the operator behavior that most often turns into a production bug. By default, integer arithmetic in C# is unchecked: the result is cut to the type's width, and nothing is reported.
int max = int.MaxValue;
Console.WriteLine(max + 1);
Console.WriteLine(unchecked(max + 1));
Console.WriteLine(checked(max + 1));
-2147483648
-2147483648
Unhandled exception. System.OverflowException: Arithmetic operation resulted in an overflow.
The compiler catches overflow only in constant expressions. int b = int.MaxValue + 1; is error CS0220: The operation overflows at compile time in checked mode. Wrapping it in unchecked(...) makes it compile and gives the wrapped value.
A realistic version of the bug, where the overflow happens before the value reaches the long:
int count = 50_000, size = 50_000;
long wrong = count * size; // int * int, then widened
long right = (long)count * size;
Console.WriteLine($"{wrong} {right}");
-1794967296 2500000000
Other overflow cases we hit while testing:
| Expression | Result on .NET 10 |
|---|---|
(int)3_000_000_000L |
-1294967296 (high bits dropped) |
(int)3e10 (double to int) |
2147483647 (saturates) |
(int)double.NaN |
0 |
checked((int)3e10) |
OverflowException |
Math.Abs(int.MinValue) |
OverflowException: Negating the minimum value of a twos complement number is invalid. |
-int.MinValue |
-2147483648 |
The double-to-int row deserves a note: since .NET 9, out-of-range floating-point to integer conversions saturate on every platform, so older articles that show a garbage value such as -2147483648 were describing x86/x64 behavior that has changed. With a constant ((int)double.NaN written directly) the compiler instead reports error CS0221: Constant value 'NaN' cannot be converted to a 'int' (use 'unchecked' syntax to override).
Measured: what does checked cost?
A common reason to avoid checked is speed. We summed a 1,000,000-element int array 300 times (300 million additions) with unchecked(s + d[i]) and checked(s + d[i]) in separate [MethodImpl(NoInlining)] methods, Release build, Apple Silicon Mac (M1 Pro), one warm-up and five measured runs:
| Loop | Run 1 | Run 2 | Run 3 | Run 4 | Run 5 |
|---|---|---|---|---|---|
unchecked int sum |
129.2 ms | 128.3 ms | 109.3 ms | 106.2 ms | 107.5 ms |
checked int sum |
133.7 ms | 102.2 ms | 99.6 ms | 97.0 ms | 99.1 ms |
In this loop the overflow check was lost in the run-to-run noise: one extra conditional branch after each add that is never taken costs almost nothing next to the memory reads. We would not claim checked is free in every loop, but "too slow" is not a good default reason to skip it. For code that counts money, sizes or IDs, consider turning it on for the whole project with <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> and using unchecked only where wrapping is intended, such as hash functions.
Bitwise operators and the shift-count trap
The 2021 bitwise example still prints 0, 15, 15 for 5 & 10, 5 | 10 and 5 ^ 10. The operators it did not cover:
int one = 1, s32 = 32, s33 = 33, neg = -16;
Console.WriteLine($"{one << 31} {one << s32} {one << s33} {1L << s32}");
Console.WriteLine($"{neg >> 2} {neg >>> 2} {~5} {5 & ~1}");
-2147483648 1 2 4294967296
-4 1073741820 -6 4
Three things to notice:
- Shift counts are masked. For
intanduintonly the low 5 bits of the count are used; forlongandulong, the low 6 bits. So1 << 32is1 << 0, and1L << 64printed1. We also compiled the constantint c = 1 << 32;: no error, no warning, and the value is1. If you build bit masks from a variable width, check the width yourself. >>on a signed type is arithmetic: it copies the sign bit, so-16 >> 2is-4. C# 11 added>>>, the unsigned right shift, which fills with zeros:-16 >>> 2is1073741820. Before C# 11 you had to cast touint.~flips every bit.~5is-6, andx & ~1clears the lowest bit.
When used on bool, & and | are logical operators that always evaluate both sides, while && and || short-circuit. With a method Hit() that counts calls, false && Hit() left the counter at 0 and false & Hit() raised it to 1.
Null operators: ??, ??= and ?.
These arrived after the C# 2.0 era that most beginner tutorials are based on, and they are now in almost every codebase.
string? name = null;
Console.WriteLine(name ?? "guest"); // guest
Console.WriteLine(name?.Length); // (empty line: null)
int? len = name?.Length; // int?, HasValue = False
List<int>? cache = null;
cache ??= new List<int>(); // assigns only if null (C# 8)
cache.Add(1);
cache ??= new List<int> { 9, 9 }; // not null, so ignored
Console.WriteLine(cache.Count); // 1
a ?? breturnsaunless it is null. On a non-nullable value type it does not compile:error CS0019: Operator '??' cannot be applied to operands of type 'int' and 'int'.x?.Memberandarr?[0]return null instead of throwing when the left side is null. The result type becomes nullable:name?.Lengthis anint?.x ??= y(C# 8) assigns only whenxis null. It is the idiom for lazy initialization.
Precedence traps
Most precedence questions never come up, because * before + matches arithmetic. The ones that cause bugs are the low-precedence operators.
int? x = 1; int y = 10, z = 5;
Console.WriteLine(x ?? y + z); // 1 (means x ?? (y + z))
Console.WriteLine((x ?? y) + z); // 6
Console.WriteLine("Sum: " + 1 + 2); // Sum: 12
Console.WriteLine($"Sum: {1 + 2}"); // Sum: 3
Console.WriteLine(1 + 2 << 1); // 6 (+ binds tighter than <<)
bool t = true, f = false;
Console.WriteLine(t || f && f); // True (&& binds tighter than ||)
??is lower than arithmetic.count ?? 0 + 1iscount ?? 1, not(count ?? 0) + 1.+with a string on the left turns every following+into concatenation, left to right. Use interpolation.x & 1 == 0does not mean(x & 1) == 0;==is tighter, so it isx & (1 == 0)and fails witherror CS0019: Operator '&' cannot be applied to operands of type 'int' and 'bool'. Write(x & 1) == 0.&&is tighter than||. Mixing them without parentheses is legal but hard to review.
When in doubt, add parentheses. The compiler removes them; your reviewer will thank you.
Summary
| Situation | What to do |
|---|---|
| Results that may exceed the type | checked(...) or <CheckForOverflowUnderflow>; widen before multiplying ((long)a * b) |
| Average or ratio of integers | Cast one operand: (double)sum / count |
| Odd/even check | x % 2 != 0 or int.IsOddInteger(x), not == 1 |
| Wrap-around index | ((i % n) + n) % n |
| Bit masks with a variable width | Shift a long (1L << n) and validate n |
Unsigned right shift on int |
>>> (C# 11) |
| Default for null | ??, lazy init with ??= |
Mixing ??, +, &, == |
Parentheses |
The previous step, types and their ranges, is in C# variables and types. Comparison and logical operators are put to work in if and switch, and exceptions such as OverflowException are handled in C# exception handling.
Tested with .NET SDK 10.0.403 / runtime 10.0.14, C# 14 (the net10.0 default), macOS on an Apple Silicon Mac (M1 Pro).
FAQ
Why does int.MaxValue + 1 become a negative number in C#?
Integer arithmetic is unchecked by default, so a result that does not fit wraps around: int.MaxValue + 1 printed -2147483648 on .NET 10. Wrap the expression in checked(...) or enable <CheckForOverflowUnderflow> in the project to get an OverflowException instead. If the expression is a compile-time constant, the compiler rejects it with error CS0220.
Why is -7 % 3 equal to -1 in C#, not 2?
In C#, % returns a remainder with the sign of the left operand, because integer division truncates toward zero (-7 / 3 is -2, and -2 * 3 + -1 = -7). We measured -7 % 3 = -1 and 7 % -3 = 1. For a result that is always between 0 and n-1, use ((a % n) + n) % n.
Why does 1 << 32 equal 1 for an int?
For int and uint, C# uses only the low 5 bits of the shift count (the low 6 bits for long and ulong). 32 & 31 is 0, so 1 << 32 is 1 << 0 = 1, and 1 << 33 is 2. The compiler does not warn, even for the constant 1 << 32. If you need a 64-bit result, shift a long: 1L << 32 printed 4294967296.