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 == 1 is not an odd check for negative numbers: -7 % 2 is -1. Use x % 2 != 0, or int.IsOddInteger(x) (.NET 7+). The same bug appears in the 2021 switch (a % 2) example in the if/switch article: with a = -13 neither case matched.
  • For a wrap-around index such as a circular buffer, use ((i % n) + n) % n. For -7 and 3 that printed 2.

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.
Decision diagram. Start: an int operation such as a + b. Question: does the result fit in int? If yes, you get the normal value. If no, it depends on the context. A constant expression such as int.MaxValue + 1 is error CS0220 at compile time. In a checked context an OverflowException is thrown. In the default unchecked context the value wraps, so int.MaxValue + 1 becomes -2147483648.
What happens when an int result does not fit. Measured on .NET 10: constant expressions fail to compile, checked code throws, and the default unchecked context wraps around silently.

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:

  1. Shift counts are masked. For int and uint only the low 5 bits of the count are used; for long and ulong, the low 6 bits. So 1 << 32 is 1 << 0, and 1L << 64 printed 1. We also compiled the constant int c = 1 << 32;: no error, no warning, and the value is 1. If you build bit masks from a variable width, check the width yourself.
  2. >> on a signed type is arithmetic: it copies the sign bit, so -16 >> 2 is -4. C# 11 added >>>, the unsigned right shift, which fills with zeros: -16 >>> 2 is 1073741820. Before C# 11 you had to cast to uint.
  3. ~ flips every bit. ~5 is -6, and x & ~1 clears 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 ?? b returns a unless 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?.Member and arr?[0] return null instead of throwing when the left side is null. The result type becomes nullable: name?.Length is an int?.
  • x ??= y (C# 8) assigns only when x is 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.

A chain of twelve boxes in precedence order: primary (x.y, x?.y, f(x), a[i], x++); unary (!, ~, -x, ++x, cast); multiplicative (*, /, %); additive (+, -); shift (<<, >>, >>>); relational and type tests (<, >, <=, >=, is, as); equality (==, !=); bitwise AND, then XOR, then OR; conditional AND, then conditional OR; null-coalescing (??); conditional (c ? a : b); assignment and lambda (=, +=, ??=, =>).
C# operator precedence, from tightest (top left) to loosest (bottom right). An operator earlier in the chain grabs its operands first.
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 + 1 is count ?? 1, not (count ?? 0) + 1.
  • + with a string on the left turns every following + into concatenation, left to right. Use interpolation.
  • x & 1 == 0 does not mean (x & 1) == 0; == is tighter, so it is x & (1 == 0) and fails with error 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.

  • #C#
  • #operators
  • #overflow
  • #checked
  • #null-coalescing
  • #.NET

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑