Arithmetic, comparison, logical, null-handling, pattern and index/range operators in C# 14, with precedence rules and the bugs each one commonly causes.
Day to day I use arithmetic, comparison, logical and the null operators. && and || short-circuit, which is what makes x != null && x.IsValid safe. ?. returns null instead of throwing, ?? gives a fallback, ??= assigns only when null, and in C# 14 ?. also works on the left of an assignment. The bugs come from integer division (5 / 2 is 2), precedence (?? binds looser than +), using & instead of &&, and == meaning reference equality on classes that do not overload it. I prefer is null because it cannot be hijacked by an overloaded ==.
Most operators are uncontroversial, so I focus on the ones that cause production bugs: the null operators, short-circuiting, integer division and remainder, precedence surprises, and equality.
| Operator | Meaning | Result type |
|---|---|---|
a?.B |
Member access only if a is not null; otherwise the whole chain is null |
Lifted to nullable (int becomes int?) |
a?[i] |
Null-conditional indexer | Nullable |
a ?? b |
a if not null, otherwise evaluate b |
Common type of both |
a ??= b |
Assign b to a only if a is null |
Type of a |
a! |
Null-forgiving: silences the compiler, no runtime effect | Same type |
x is null / x is not null |
Pattern test that cannot be hijacked by an overloaded == |
bool |
Key behaviours:
?. short-circuits the rest of the chain: in order?.Customer.Address.City, if order is null nothing to the right is evaluated. But if order is not null and Customer is null, you still get a NullReferenceException — you need ?. at each nullable step.?? evaluates its right-hand side only when needed, so value ?? throw new ArgumentNullException(nameof(value)) and cache ?? LoadExpensive() are cheap.??= is ideal for lazy initialisation: _items ??= []; — but it is not thread-safe.customer?.Name = "Ada"; assigns only when customer is not null.&& and || short-circuit: user != null && user.IsActive never dereferences a null user.& and | on bool always evaluate both sides. Using & by accident causes NREs or doubles side effects (Validate() & Save() saves even when validation fails).^ is logical XOR on bool.& and | use three-valued logic (null & false == false), while &&/|| are not allowed.7 / 2 == 3, -7 / 2 == -3. double ratio = done / total; is 0 for ints.% is remainder, not modulo: -7 % 3 == -1. For a true modulo (e.g. circular buffer indexes) use ((a % n) + n) % n.++x vs x++: same effect on x, different expression value. Avoid using them inside larger expressions.byte b = 10; b += 300; compiles (and wraps), while b = b + 300; does not.1 << 32 on an int is 1, not 0. >> is arithmetic for signed types; C# 11 added >>> for unsigned (logical) shift.Precedence from high to low (simplified): primary (x.y, x?.y, f(), a[i], x++) → unary (!, -, ~, ++x, casts) → * / % → + - → shifts → relational and is/as → == != → & → ^ → | → && → || → ?? → ?: → assignment and lambdas.
Bugs I have seen:
total = price ?? 0 + shipping; parses as price ?? (0 + shipping) because + binds tighter than ??. Write (price ?? 0) + shipping."Total: " + a + b concatenates a then b as strings ("Total: 12", not "Total: 3").flags & Mask == Mask parses as flags & (Mask == Mask) — a compile error for ints, but a silent bug for bools. Parenthesise bitwise tests.a ? b : c ? d : e is right-associative; nested ternaries need parentheses for readability.== is reference equality unless the type overloads it (string, records do).object a = "x"; object b = new string('x', 1); a == b is false — the overload is chosen at compile time based on the static type object.is null over == null for null checks; it cannot be redirected by a custom operator ==.A pricing service computed var discount = customer?.Tier.DiscountRate ?? 0m;. When customers without a tier arrived from a new import, customer was not null but Tier was, and the endpoint threw NREs for a subset of users. The fix was customer?.Tier?.DiscountRate ?? 0m, but the real fix was enabling nullable reference types on the project, which flagged Tier as Tier? and forced every caller to handle it. Similar classic: a percentage column showed 0% for weeks because it was completed / total * 100 with int operands — caught only when a product owner asked why the chart was always flat.
The interviewer checks everyday fluency and whether the candidate knows the traps, not just the symbols.
Green flags
?? precedence bugs quickly.is null cannot be overridden and that ! does nothing at run time.Red flags
& and && are interchangeable for bools.?. makes code safe everywhere without thinking about what null means.7 / 2 or -7 % 3.?. and ?? compile to?. evaluates the receiver once into a temporary, tests it, then continues:
// var city = order?.Customer?.City;
var t1 = order;
string? city;
if (t1 == null) city = null;
else
{
var t2 = t1.Customer;
city = t2 == null ? null : t2.City;
}
Because the receiver is evaluated once, GetOrder()?.Total calls GetOrder() only once. For value-type results the expression is lifted: order?.Total has type decimal?. That is why int count = list?.Count; does not compile and you need list?.Count ?? 0.
The null-conditional invocation handler?.Invoke(this, args) is the standard thread-safe event raise: the delegate is copied into a temporary before the null check, so another thread unsubscribing between the check and the call cannot cause an NRE.
a ?? b compiles to "evaluate a once; if not null use it (unwrapping Nullable<T> with GetValueOrDefault()), else evaluate b". The right side being lazily evaluated is what makes ?? throw legal — throw is an expression since C# 7.
??= is not thread-safe_cache ??= Load();
// is roughly
if (_cache is null) _cache = Load();
Two threads can both see null and both call Load(). For thread-safe lazy initialisation use Lazy<T>, LazyInitializer.EnsureInitialized, or Interlocked.CompareExchange(ref _cache, Load(), null) when duplicate creation is acceptable but you need one published winner.
Operators are static methods resolved at compile time from the operands' static types:
string s1 = "hello";
string s2 = new string("hello".ToCharArray());
Console.WriteLine(s1 == s2); // True - string.op_Equality
Console.WriteLine((object)s1 == (object)s2); // False - reference equality
The compiler warns (CS0252/CS0253) when you compare object with string this way. This is also why generic code where T : class comparing a == b uses reference equality even if T is string — use EqualityComparer<T>.Default.Equals(a, b) in generics.
is null compiles to a direct reference comparison, bypassing any user-defined ==. Some ORMs and Unity's UnityEngine.Object override == so that destroyed objects compare equal to null — that is the classic case where is null and == null disagree.
a && b becomes a branch: evaluate a; if false jump to "false" without evaluating b. a & b evaluates both and performs a bitwise AND. Short-circuiting is therefore also a performance tool: put the cheapest, most selective test first (if (span.Length > 0 && ExpensiveCheck(span))).
With bool?, the & (AND) and OR operators implement SQL-style three-valued logic:
a |
b |
AND result | OR result |
|---|---|---|---|
true |
null |
null |
true |
false |
null |
false |
null |
null |
null |
null |
null |
idiv truncates toward zero and produces both quotient and remainder; C# defines % to match, so the remainder takes the sign of the dividend.x % 2^n for non-negative values can be x & (2^n - 1). Math.DivRem returns both results.int.MinValue / -1 overflows and throws OverflowException even in unchecked code, because the CPU faults.x << 33 on an int equals x << 1.??, ??= and ?: are right-associative: a ?? b ?? c is a ?? (b ?? c), which is what you want.a - b - c is (a - b) - c.as and is sit with relational operators, so x as Foo ?? fallback works as expected, but !x is Foo parses as (!x) is Foo — write x is not Foo.Func<int,int> f = x => x + 1; needs no parentheses.Modern C# turns many operator combinations into patterns, which are easier to read and harder to get wrong:
if (order is { Status: OrderStatus.Paid, Total: > 0m }) { ... }
if (code is >= 200 and < 300) { ... }
if (input is not (null or "")) { ... }
Patterns are evaluated left to right with short-circuiting, and they do not call user-defined == for constant patterns on most types.
var x = a?.B?.C ?? 0; and read the lowered C#.-7 / 2, -7 % 3, 1 << 32 and int.MinValue / -1 (in a try/catch).decimal? price = null; decimal shipping = 5; var t = price ?? 0 + shipping; and print t, then add parentheses.(object)"ab" == (object)string.Concat("a", "b") with string.Equals.namespace Basics;
public sealed class Address { public string? City { get; set; } }
public sealed class Customer { public Address? Address { get; set; } }
public sealed record Point(int X, int Y);
public static class OperatorDemo
{
private static List<string>? _cache;
public static void Main()
{
Customer? customer = null;
// Null-conditional + null-coalescing
string city = customer?.Address?.City ?? "Unknown";
Console.WriteLine(city); // Unknown
// C# 14 null-conditional assignment: no-op when customer is null
customer?.Address = new Address { City = "Perth" };
// ??= lazily initialises once
_cache ??= ["a", "b"];
// Integer division trap
int processed = 1, total = 3;
Console.WriteLine(processed / total * 100); // 0
Console.WriteLine((int)Math.Round(100.0 * processed / total)); // 33
// Modulo sign follows the dividend
Console.WriteLine(-7 % 3); // -1
Console.WriteLine(IsOdd(-7)); // True
// Precedence
int? n = null;
Console.WriteLine(n ?? 0 + 5); // 5 (n ?? (0 + 5))
Console.WriteLine((n ?? 0) + 5); // 5, but intent is now clear
// Value equality on records, reference equality on classes
Console.WriteLine(new Point(1, 2) == new Point(1, 2)); // True
// Index and range
int[] values = [10, 20, 30, 40];
Console.WriteLine(values[^1]); // 40
Console.WriteLine(string.Join(",", values[1..3])); // 20,30
}
private static bool IsOdd(int x) => x % 2 != 0;
}
?. chain too earlyWhat happens: order?.Customer.Address.City is assumed to be null-safe.
Why it is wrong: ?. only protects the receiver it is attached to. If Customer or Address can be null you still get a NullReferenceException.
Do instead: apply ?. at every nullable step, and enable nullable reference types so the compiler tells you which steps can be null.
??What happens: var total = price ?? 0 + shipping;
Why it is wrong: + binds tighter than ??, so this is price ?? (0 + shipping) — shipping is ignored whenever price has a value.
Do instead: always parenthesise ?? inside arithmetic: (price ?? 0) + shipping.
! to make warnings go awayWhat happens: user!.Name sprinkled wherever the compiler warns.
Why it is wrong: the null-forgiving operator does nothing at run time; it just hides the warning, so the NRE still happens in production.
Do instead: fix the flow (guard clause, ?? throw, [NotNullWhen]), and reserve ! for cases you can prove, such as values set by a framework after construction.
& or | instead of && or ||What happens: if (user != null & user.IsActive) or if (Validate() | Save()).
Why it is wrong: non-short-circuit operators evaluate both sides — dereferencing null or running side effects that should have been skipped.
Do instead: use &&/|| for boolean logic; keep &/| for bit flags and deliberate eager evaluation.
What happens: double percent = done / total * 100; with int operands.
Why it is wrong: done / total is computed in integer arithmetic and truncates to 0 before conversion.
Do instead: convert first: 100.0 * done / total or (decimal)done / total.
% as mathematical moduloWhat happens: circular-buffer or day-of-week code uses index % n with possibly negative index.
Why it is wrong: % keeps the sign of the dividend, so -1 % 7 == -1 and the array access throws.
Do instead: use ((index % n) + n) % n, or keep indexes non-negative.
objectWhat happens: values stored as object (e.g. in a Dictionary<string, object>) are compared with ==.
Why it is wrong: overloads are chosen by static type, so == becomes reference equality and equal strings or boxed numbers compare as different.
Do instead: use Equals, object.Equals(a, b), or EqualityComparer<T>.Default in generic code.
??= or ?. for thread safetyWhat happens: _client ??= CreateClient(); in a singleton accessed by many requests.
Why it is wrong: check-then-assign is not atomic; multiple threads can create multiple instances, and some may observe a half-initialised object.
Do instead: use Lazy<T>, LazyInitializer, or register the object as a singleton in DI.
++ and -- inside bigger expressionsWhat happens: arr[i++] = arr[i] + 1; or if (count++ > max).
Why it is wrong: readers misjudge whether the old or new value is used, and refactors change behaviour.
Do instead: increment on its own line; clarity beats saving a line.
What happens: if (flags & Permission.Write == Permission.Write).
Why it is wrong: == binds tighter than &, so it either fails to compile or silently tests the wrong thing for bool.
Do instead: write (flags & Permission.Write) == Permission.Write, or flags.HasFlag(Permission.Write) (which the JIT now optimises well).
Seniors use operators to make intent explicit and avoid cleverness.
| Situation | Prefer | Avoid |
|---|---|---|
| Null check | is null / is not null |
== null on types that overload == |
| Defaulting | value ?? fallback |
nested ternaries |
| Guard clause | ArgumentNullException.ThrowIfNull(x) |
x ?? throw ... repeated everywhere |
| Lazy init (single thread) | ??= |
manual if/assign |
| Lazy init (multi-thread) | Lazy<T> or LazyInitializer |
??= (it is not atomic) |
| Percentages | double maths then round |
integer division |
??= is not thread-safe: two threads can both see null and both assign.?. hides nulls; overusing it can turn a bug (a missing customer) into silent wrong data. Decide whether null is valid before reaching for it.is null, ?? and ?., so idiomatic operators also improve warnings.An expression produces a value, like a + b or order.Total > 100; a statement does something, like if, foreach, return or a declaration. C# has become more expression-oriented: a switch expression returns a value, cannot fall through and warns if it is not exhaustive, and patterns (type, relational, property, list, and/or/not) let you test shape and values in one place. Expression-bodied members use => for one-line members, and throw expressions let ?? and ?: throw. I use expressions to compute values without mutation and statements when there are steps or side effects.
C# has integral types (byte to long, plus nint, Int128), binary floating point (float, double, Half) and decimal. I use int by default, long for IDs and counters that can pass 2.1 billion, double for measurements and science, and decimal for money because it is base-10 and exact for values like 0.1. Integer overflow is unchecked by default, so int.MaxValue + 1 silently wraps; I use checked where wrapping would be a bug. Doubles never throw: dividing by zero gives Infinity or NaN, and Math.Round defaults to banker's rounding.
Nullable reference types make nullability part of the declared type for static analysis: string means "should never be null" and string? means "may be null". The compiler tracks a null-state for every expression and warns when a maybe-null value is dereferenced or assigned to a non-nullable. It is compile-time only: there are no run-time checks, and ! merely silences a warning. Attributes like [NotNullWhen(true)] on TryGet methods or [MemberNotNull] on initialisers tell the analyser about contracts it cannot infer. Nulls can still arrive from deserialisation, arrays, default and unannotated code, so I still validate at the boundaries.
Last updated 30 Oct 2026.