Output & Running
Hello, World
A C# file used to open with a namespace, a class, and
static void Main(string[] args) before it could print anything. Since C# 9 that ceremony is optional: a file may consist of top-level statements, which the compiler wraps in a generated entry point for you. Every C# example on this page is written that way, so the shape matches a Python script.print("Hello, World!")Console.WriteLine("Hello, World!");The two visible costs against Python are the semicolon and the capital letters.
Console.WriteLine appends a newline; Console.Write does not, which is the pair to Python's print(..., end=""). Only one file in a C# project may use top-level statements — the rest still declare types — so this is a script affordance, not a change to how the language is organized.Printing several values
C# has interpolated strings, and they are close enough to f-strings that you will reach for them without thinking. The prefix is
$ before the quote rather than f, and the braces hold an ordinary expression.name = "Alice"
age = 30
print(f"{name} is {age}")
print(name, age)var name = "Alice";
var age = 30;
Console.WriteLine($"{name} is {age}");
Console.WriteLine($"{name} {age}");What C# does not have is Python's multi-argument
print, which inserts a space between arguments for you. Console.WriteLine(name, age) does not mean that at all — it is read as a format string plus arguments and throws at run time unless name contains {0}. Write the spaces yourself, inside the interpolation.There is a compile step
This row is about when you find out. The Python column is a perfectly valid program right up to the moment it executes the second line; the C# column, with that line uncommented, produces no program at all.
print("the first line already ran")
count = "seven"
try:
print(count + 1)
except TypeError as error:
print("TypeError, at run time:", error)Console.WriteLine("the first line already ran");
var count = "seven";
// Console.WriteLine(count + 1);
// Uncomment that line and the first one never runs either: there is no program.Python raises
TypeError at run time, after the first line has already printed and whatever side effects it had are done. Uncomment the C# line and the compiler refuses to produce an executable at all, so the WriteLine above it never runs either — the failure arrives before the program exists. That is the single biggest change of habit — a whole class of bugs moves from your users find it to the build fails, and in exchange you must satisfy the compiler about things Python let you leave vague.Multi-line strings
Both languages use triple quotes, but C# raw string literals strip a common indentation, measured from the closing delimiter.
report = """Sales report
Q1: 100
Q2: 150"""
print(report)var report = """
Sales report
Q1: 100
Q2: 150
""";
Console.WriteLine(report);The indentation of the closing
""" sets the left margin, and every line is de-indented by that much — so a raw string nested inside a method body stays readable without the leading spaces leaking into the value. Python has no such rule, which is why textwrap.dedent exists. C# raw strings also need no escaping of quotes or backslashes, and can be interpolated by prefixing $.LINQ & Comprehensions
A comprehension is a LINQ chain
This is the friendliest door into C#, so the page opens with it. A list comprehension has three parts — a source, a filter, and a projection — and LINQ has exactly the same three, written as methods in the order they apply.
numbers = [1, 2, 3, 4, 5, 6]
squares_of_evens = [number * number for number in numbers if number % 2 == 0]
print(squares_of_evens)var numbers = new[] { 1, 2, 3, 4, 5, 6 };
var squaresOfEvens = numbers.Where(number => number % 2 == 0)
.Select(number => number * number)
.ToList();
Console.WriteLine(string.Join(", ", squaresOfEvens));Read the C# bottom-up and it is the comprehension:
numbers is the for clause, Where is the if, Select is the expression on the left. The filter comes first in C# and last in Python, which is the only reordering you have to absorb. ToList is the part with no Python counterpart, and the next row is about why it is there.LINQ is lazy, like a generator expression
A LINQ chain describes work; it does not do it. Nothing is computed until something enumerates the result — a
foreach, a ToList, a Count. That is exactly the deferral of a generator expression, and it has exactly the same consequence.numbers = [1, 2, 3]
doubled = (number * 2 for number in numbers)
numbers.append(4)
print(list(doubled))var numbers = new List<int> { 1, 2, 3 };
var doubled = numbers.Select(number => number * 2);
numbers.Add(4);
Console.WriteLine(string.Join(", ", doubled));Both columns print
2, 4, 6, 8, including the element added after the query was written, because both re-read the source when they are finally consumed. The difference is that a generator is one-shot — consume it twice and the second pass is empty — while a LINQ query is a standing recipe you may enumerate as many times as you like, paying the cost each time. Calling ToList is how you say "compute it now and keep the answer".Sorting by a key
The
key= argument and OrderBy are the same idea: hand the sort a function that extracts what to compare.people = [("Alice", 30), ("Bob", 25), ("Carol", 35)]
by_age = sorted(people, key=lambda person: person[1])
for name, age in by_age:
print(name, age)var people = new[] { ("Alice", 30), ("Bob", 25), ("Carol", 35) };
var byAge = people.OrderBy(person => person.Item2);
foreach (var (name, age) in byAge)
Console.WriteLine($"{name} {age}");C# chains a second key with
ThenBy rather than returning a tuple from the key function, and reverses with OrderByDescending rather than reverse=True. Both sorts are stable. Note that OrderBy returns a new sequence and leaves the source alone — the equivalent of sorted(), never of list.sort(), which has no LINQ counterpart because LINQ never mutates its input.Grouping
Grouping is where LINQ pulls ahead of anything in the Python standard library.
itertools.groupby only groups adjacent equal keys, so Python code usually reaches for a defaultdict and a loop, as here.from collections import defaultdict
words = ["apple", "avocado", "banana", "blueberry", "cherry"]
groups = defaultdict(list)
for word in words:
groups[word[0]].append(word)
for letter in sorted(groups):
print(letter, groups[letter])var words = new[] { "apple", "avocado", "banana", "blueberry", "cherry" };
var groups = words.GroupBy(word => word[0]).OrderBy(group => group.Key);
foreach (var group in groups)
Console.WriteLine($"{group.Key} [{string.Join(", ", group)}]");LINQ's
GroupBy groups the whole sequence regardless of order, and each group is itself an enumerable carrying a Key. It composes: .GroupBy(...).Select(group => new { group.Key, Count = group.Count() }) is a one-line histogram. This is the closest thing C# has to a language-level dictionary comprehension, and it is more powerful than one.sum, min, max and reduce
The reductions you know as builtins are extension methods on any sequence in C#, and
functools.reduce is spelled Aggregate.from functools import reduce
numbers = [4, 8, 15, 16, 23, 42]
print(sum(numbers), min(numbers), max(numbers))
print(reduce(lambda running, number: running * number, numbers, 1))var numbers = new[] { 4, 8, 15, 16, 23, 42 };
Console.WriteLine($"{numbers.Sum()} {numbers.Min()} {numbers.Max()}");
Console.WriteLine(numbers.Aggregate(1, (running, number) => running * number));The argument order differs:
Aggregate takes the seed first and the function second, the reverse of reduce. Sum, Min, Max and Average also take an optional selector — people.Sum(person => person.Age) saves the intermediate Select. Unlike Python's sum, Sum on an int sequence returns an int and will overflow rather than promote.any, all and first
These three short-circuit in both languages, stopping at the first element that settles the question.
numbers = [3, 7, 12, 19]
print(any(number % 2 == 0 for number in numbers))
print(all(number > 0 for number in numbers))
print(next((number for number in numbers if number > 10), -1))var numbers = new[] { 3, 7, 12, 19 };
Console.WriteLine(numbers.Any(number => number % 2 == 0));
Console.WriteLine(numbers.All(number => number > 0));
Console.WriteLine(numbers.FirstOrDefault(number => number > 10, -1));The names line up almost exactly, and
next(generator, default) becomes FirstOrDefault(predicate, default). Watch the naming rule: a LINQ method ending in OrDefault returns a default instead of throwing when nothing matches, while the bare First throws InvalidOperationException — the analogue of next() without a default raising StopIteration. There is also Single, which insists the match is unique; Python has no builtin for that.Flattening and zipping
A nested comprehension flattens; so does
SelectMany, which is the one LINQ name that does not announce what it does.nested = [[1, 2], [3, 4], [5]]
flat = [number for row in nested for number in row]
print(flat)
names = ["Alice", "Bob"]
scores = [90, 85]
print([f"{name}:{score}" for name, score in zip(names, scores)])var nested = new[] { new[] { 1, 2 }, new[] { 3, 4 }, new[] { 5 } };
var flat = nested.SelectMany(row => row);
Console.WriteLine(string.Join(", ", flat));
var names = new[] { "Alice", "Bob" };
var scores = new[] { 90, 85 };
Console.WriteLine(string.Join(", ", names.Zip(scores, (name, score) => $"{name}:{score}")));SelectMany projects each element to a sequence and concatenates the results — the second for clause of a nested comprehension, and the same operation functional languages call flatMap. Zip stops at the shorter sequence exactly as Python's does, and takes the combining function inline instead of yielding tuples, though the two-argument overload yields tuples if you prefer.Query syntax — the SQL-shaped alternative
C# has a second spelling of the same thing, built into the language grammar. It reads like SQL and compiles to precisely the method chain from the first row of this section.
people = [("Alice", 30), ("Bob", 25), ("Carol", 35)]
names = [name for name, age in people if age >= 30]
print(names)var people = new[] { ("Alice", 30), ("Bob", 25), ("Carol", 35) };
var names = from person in people
where person.Item2 >= 30
select person.Item1;
Console.WriteLine(string.Join(", ", names));Query syntax is the closest any mainstream language comes to a comprehension as syntax, and note the clause order: the source comes first, as in Python's
for, and the projection last. Most C# code uses the method syntax anyway, because only a subset of the operators have query keywords, but a query with several joins or a let is far more legible this way.Iterators & yield
yield return is yield
This is the closest correspondence on the page. Both compilers see a function containing
yield and rewrite it into a state machine that resumes where it left off; neither runs a line of the body until the caller asks for the first element.def countdown(start):
while start > 0:
yield start
start -= 1
print(list(countdown(3)))IEnumerable<int> Countdown(int start)
{
while (start > 0)
{
yield return start;
start--;
}
}
Console.WriteLine(string.Join(", ", Countdown(3)));The declaration is where the difference lives. Python infers "this is a generator" from the presence of
yield; C# requires you to declare the return type as IEnumerable<T> and infers the rest. Local functions like this one may be declared before or after the statement that calls them, which is why the example reads top-down like Python.An infinite sequence, taken from
Because both sides are lazy, an endless producer is safe as long as the consumer stops.
from itertools import count, islice
def fibonacci():
current, following = 0, 1
while True:
yield current
current, following = following, current + following
print(list(islice(fibonacci(), 10)))IEnumerable<int> Fibonacci()
{
var (current, following) = (0, 1);
while (true)
{
yield return current;
(current, following) = (following, current + following);
}
}
Console.WriteLine(string.Join(", ", Fibonacci().Take(10)));Take is itertools.islice with a friendlier name, and it lives on the sequence rather than in a separate module. C# also has tuple assignment and tuple swap, so the Fibonacci step reads the same in both columns — one of the places where C# is closer to Python than to its own C-family relatives.A generator is spent; a query is not
Both sides are lazy, and this row is where that shared word stops meaning the same thing. Consume each one twice and the answers differ.
numbers = (number for number in [1, 2, 3])
print(sum(numbers))
print(sum(numbers)) # the generator is exhaustedvar numbers = new[] { 1, 2, 3 }.Select(number => number);
Console.WriteLine(numbers.Sum());
Console.WriteLine(numbers.Sum()); // runs the query againA Python generator holds a position; once it reaches the end it stays there, so the second
sum is 0. A LINQ query holds no position — it is a description, and every enumeration starts a new walk over the source, so the second Sum is 6 again. That is friendlier until the source is a database or a file, at which point the query silently runs twice. ToList is how you pin the answer down.The iteration protocol
To make your own type iterable, Python wants
__iter__ and C# wants GetEnumerator. Both are allowed to be generator methods, so neither needs a hand-written iterator object.class Countdown:
def __init__(self, start):
self.start = start
def __iter__(self):
current = self.start
while current > 0:
yield current
current -= 1
print([number for number in Countdown(3)])foreach (var number in new Countdown(3))
Console.Write(number + " ");
Console.WriteLine();
class Countdown
{
private readonly int start;
public Countdown(int start) => this.start = start;
public IEnumerator<int> GetEnumerator()
{
for (var current = start; current > 0; current--)
yield return current;
}
}C# resolves
foreach by shape, not by interface: any type with a suitable public GetEnumerator is iterable, whether or not it implements IEnumerable. That is structural typing hiding inside a nominal language, and it is the same bargain as Python's protocols — with the check done at compile time. Implementing IEnumerable<T> as well is what makes the LINQ methods available on your type.Variables & Types
var infers, it does not mean "any"
C# borrowed
var from the dynamic languages and then kept only half of it: the part that saves you typing the type, not the part that lets the type change.count = 3
print(type(count).__name__)
count = "three"
print(type(count).__name__)var count = 3;
Console.WriteLine(count.GetType().Name);
// count = "three"; // uncomment: cannot assign string to int
Console.WriteLine("still an int");A Python name is a label that can be re-tied to anything. A C#
var is a full static type, fixed at the point of declaration by whatever is on the right — var count = 3 declares an int as firmly as int count = 3 does, and reassigning a string is a compile error. The type still exists at run time, which is why GetType() can report it; C# generics are not erased the way Java's are.Type hints against types that bite
If you already annotate your Python, the syntax below will look familiar and the semantics will not.
def double(value: int) -> int:
return value * 2
print(double("ab")) # the hint is not enforcedint Double(int value) => value * 2;
Console.WriteLine(Double(21));
// Console.WriteLine(Double("ab")); // uncomment: compile errorA Python annotation is metadata. It is stored in
__annotations__, read by mypy or your editor if you run them, and ignored entirely by the interpreter — which is why the Python column happily prints abab. A C# parameter type is a promise the compiler enforces for every call site before the program exists. The pay-off is not that fewer bugs happen; it is that the ones that do are found without running anything.Constants and readonly
Python signals "do not reassign this" by shouting in the name. C# has two real mechanisms and the compiler backs both.
MAX_RETRIES = 3 # a convention, not a rule
MAX_RETRIES = 4 # nothing stops this
print(MAX_RETRIES)const int MaxRetries = 3;
// MaxRetries = 4; // uncomment: compile error
Console.WriteLine(MaxRetries);const is compile-time: the value is baked into every use site, so it must be a literal expression and only primitives and strings qualify. readonly is the run-time version, assignable once in a constructor and useful for a field whose value is not known until then. Neither makes the pointed-at object immutable — a readonly List<int> can still gain items, exactly as a Python "constant" list can.Conversions are explicit
Both languages refuse to guess between numeric addition and concatenation, and both make you say which you meant.
text = "42"
value = int(text)
print(value + 8)
print(str(value) + "8")var text = "42";
var value = int.Parse(text);
Console.WriteLine(value + 8);
Console.WriteLine(value.ToString() + "8");int.Parse throws on bad input; int.TryParse(text, out var parsed) returns a bool instead and is the idiomatic form when the input is not trusted — the closest thing C# has to Python's try/except-around-int(). In the other direction, C# is less strict than Python: "total: " + value compiles, because + with a string operand calls ToString() for you.dynamic — the escape hatch back to Python
C# has a keyword that turns the type checker off for one variable. Method calls on a
dynamic are resolved the way Python resolves them: by looking at the object when the call happens.class Greeter:
def hello(self):
return "hi from Python"
thing = Greeter()
print(thing.hello())dynamic thing = new Greeter();
Console.WriteLine(thing.Hello());
class Greeter
{
public string Hello() => "hi from C#";
}This is duck typing bolted onto a static language, and it costs what you would expect — no compile-time checking, no editor completion, and a slower call through the runtime binder. It exists mainly for COM interop and for consuming loosely-shaped JSON, and idiomatic C# uses it rarely. Reach for it when you genuinely have no type to name; reach for
object plus a pattern match when you do.Value Types & Reference Types
A struct copies; a class does not
Python has one kind of object and one kind of assignment: a name is bound to an object, and two names can be bound to the same one. C# has two kinds, and which one you get is decided by the author of the type, not by you at the point of use.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
first = Point(1, 2)
second = first # the same object
second.x = 99
print(first.x)var first = new Point(1, 2);
var second = first; // a COPY of the value
second.X = 99;
Console.WriteLine(first.X);
struct Point
{
public int X, Y;
public Point(int x, int y) { X = x; Y = y; }
}Change
struct to class in the C# column and it prints 99 instead of 1 — the Python answer. That is the whole distinction. A struct is a value type: assigning it, passing it to a method, or storing it in a list copies the bytes. A class is a reference type and behaves like every Python object you have ever used. There is nothing in Python that prepares you for this, and it is the correctness surprise that costs newcomers the most time.The copy that bites: a struct inside a collection
This is the same rule as the previous row, met in the place it actually catches people: pulling an element out of a collection and changing it. Read both columns as if structs did not exist and you would predict
1, 0 from each.class Counter:
def __init__(self):
self.total = 0
counters = [Counter(), Counter()]
first = counters[0] # the same object the list holds
first.total += 1
print([counter.total for counter in counters])var counters = new List<Counter> { new Counter(), new Counter() };
var first = counters[0]; // a COPY of what the list holds
first.Total += 1;
Console.WriteLine(string.Join(", ", counters.Select(counter => counter.Total)));
struct Counter
{
public int Total;
}The C# column prints
0, 0. Reading counters[0] hands back a copy of the struct, so the increment lands on something that is discarded at the end of the statement. The compiler blocks the two most obvious ways to do it in place — assigning to a foreach variable is error CS1654, and counters[0].Total += 1 on a List is CS1612, because an indexer returns a value rather than a storage location — so the copy escapes through a local like this one. On a plain array counters[0].Total += 1 does compile and does mutate the element, which makes the behaviour depend on the container. The rule of thumb: reach for struct only for small immutable values like a point, a colour, or a money amount.Equality: == against __eq__
The operator you reach for by reflex means different things in the two languages, and the C# meaning is the surprising one.
first = [1, 2, 3]
second = [1, 2, 3]
print(first == second) # contents
print(first is second) # identityvar first = new List<int> { 1, 2, 3 };
var second = new List<int> { 1, 2, 3 };
Console.WriteLine(first == second); // identity!
Console.WriteLine(ReferenceEquals(first, second)); // identity, explicitly
Console.WriteLine(first.SequenceEqual(second)); // contentsPython's
== asks the object, via __eq__, and every builtin container answers by comparing contents. C#'s == on a reference type defaults to reference identity — Python's is — unless the type overloads it, which string and the numeric types do and List<T> does not. For contents use SequenceEqual, and for your own types either override Equals or declare a record, which writes value equality for you.Boxing — when a value type becomes an object
In Python,
42 is already a heap object with a type and a reference count — that is why a list of mixed things costs nothing conceptually. In C#, 42 is four bytes on the stack, so storing it in an object slot requires wrapping it first.value = 42
things = [value, "text", 3.5]
print([type(thing).__name__ for thing in things])var value = 42;
var things = new object[] { value, "text", 3.5 };
Console.WriteLine(string.Join(", ", things.Select(thing => thing.GetType().Name)));That wrapping is called boxing, and it happens silently: an allocation, a copy, and a later cast to get the value back. It is invisible in the source and visible in a profiler, which is why generic collections exist — a
List<int> stores raw ints with no boxing at all, whereas the pre-generics ArrayList boxed every element. This is the performance story behind C#'s reified generics, and it is the reason a numeric loop in C# runs at a speed Python cannot approach.The mutable-default trap has no C# counterpart
Python evaluates a default argument once, when the
def executes, and reuses that object for every call — the best-known gotcha in the language.def append_to(item, target=[]):
target.append(item)
return target
print(append_to(1))
print(append_to(2)) # the same list, still thereList<string> AppendTo(string item, List<string>? target = null)
{
target ??= new List<string>();
target.Add(item);
return target;
}
Console.WriteLine(string.Join(", ", AppendTo("first")));
Console.WriteLine(string.Join(", ", AppendTo("second")));C# sidesteps it by construction: a default argument value must be a compile-time constant, so
= new List<string>() will not compile at all. The idiom is the one shown — default to null and fill it in with ??=, which assigns only when the left side is null. The C# column prints each item alone, which is what the Python author almost certainly meant.Numbers
Integers have a ceiling
A Python
int grows until memory runs out. A C# int is 32 bits and a long is 64, and passing the top wraps silently to the bottom.biggest = 2 ** 63 - 1
print(biggest + 1)
print(2 ** 200)var biggest = long.MaxValue;
Console.WriteLine(biggest + 1);
Console.WriteLine(System.Numerics.BigInteger.Pow(2, 200));The C# column prints
-9223372036854775808: arithmetic wraps by default, with no exception and no warning. Wrap the expression in checked { } to get an OverflowException instead, or choose BigInteger, which is Python's int in a library — arbitrary precision, and correspondingly slower. The everyday advice is to use long when a count might grow and to remember that C# arithmetic is machine arithmetic.Division of two integers
Python 3 made
/ always produce a float and gave floor division its own operator. C# kept the C rule: the operator does whatever the operand types say.print(7 / 2) # true division, always a float
print(7 // 2) # floor division
print(-7 // 2) # floors toward negative infinity
print(7 % 3, -7 % 3)Console.WriteLine(7 / 2); // integer division
Console.WriteLine(7 / 2.0); // one operand is a double
Console.WriteLine(-7 / 2); // truncates toward zero
Console.WriteLine($"{7 % 3} {-7 % 3}");Two traps for a Python reader in three lines.
7 / 2 is 3, not 3.5, because both operands are int — write 7 / 2.0 or cast. And C# truncates toward zero, so -7 / 2 is -3 where Python's // gives -4. The remainders follow: C# % takes the sign of the dividend (-1), Python's takes the sign of the divisor (2).Money: decimal is a built-in type
Both languages have binary floating point and both have a decimal type for money. The difference is how much ceremony the decimal costs.
from decimal import Decimal
print(0.1 + 0.2)
print(Decimal("0.1") + Decimal("0.2"))Console.WriteLine(0.1 + 0.2);
Console.WriteLine(0.1m + 0.2m);C#'s
decimal is a first-class 128-bit type with its own literal suffix m, its own arithmetic, and no import — so the exact answer is one character away. Python's Decimal needs a module and a string constructor (Decimal(0.1) would import the binary error you were trying to avoid). C#'s decimal is not a bignum: it holds roughly 28 significant digits and is slower than double, which is the usual bargain for money types.Formatting numbers
Interpolation holes take a format specifier after a colon in both languages. The specifiers themselves are entirely different vocabularies.
amount = 1234567.891
print(f"{amount:,.2f}")
print(f"{42:05d}")
print(f"{0.256:.1%}")var amount = 1234567.891;
Console.WriteLine($"{amount:N2}");
Console.WriteLine($"{42:D5}");
Console.WriteLine($"{0.256:P1}");C# uses single-letter standard formats —
N for a grouped number, D for a zero-padded integer, P for a percentage, C for currency, F for fixed decimals — with an optional digit count after the letter, and a custom form (#,##0.00) when those are not enough. They are culture-aware, so N2 prints a comma or a full stop as the thousands separator depending on the current culture, which Python's , never does.Strings
The everyday string methods
Nearly every string method you know exists in C# under a different name, and the vocabulary is worth learning in one sitting.
greeting = " Hello, World "
print(greeting.strip())
print(greeting.strip().upper())
print(greeting.strip().replace("World", "C#"))
print("World" in greeting)var greeting = " Hello, World ";
Console.WriteLine(greeting.Trim());
Console.WriteLine(greeting.Trim().ToUpper());
Console.WriteLine(greeting.Trim().Replace("World", "C#"));
Console.WriteLine(greeting.Contains("World"));strip is Trim, upper is ToUpper, in is Contains, startswith is StartsWith, find is IndexOf, join and split keep their names. The naming convention is C#'s throughout: methods are PascalCase, and one that returns something new rather than mutating usually starts with a verb like To or Trim.Slicing and ranges
C# 8 added range and index operators that cover most of what a Python slice does.
.. is the slice and ^ counts from the end.word = "cheatsheet"
print(word[0:5])
print(word[-5:])
print(word[::-1])var word = "cheatsheet";
Console.WriteLine(word[0..5]);
Console.WriteLine(word[^5..]);
Console.WriteLine(new string(word.Reverse().ToArray()));word[^1] is word[-1], and word[1..^1] drops the first and last characters. What C# ranges do not have is a step, so [::-1] has no direct spelling — reversing goes through LINQ and back into a string, as shown. Ranges work on arrays and spans too, and on an array they copy, so they are closer to a Python slice than to a memoryview.Splitting and joining
Both split into a sequence and join from one; the argument order of the join is what trips people up.
line = "alice,bob,carol"
people = line.split(",")
print(people)
print(" | ".join(people))
print("a-b-c".split("-", 1))var line = "alice,bob,carol";
var people = line.Split(",");
Console.WriteLine(string.Join(", ", people));
Console.WriteLine(string.Join(" | ", people));
Console.WriteLine(string.Join(", ", "a-b-c".Split("-", 2)));Python calls
join on the separator; C# calls the static string.Join with the separator first and the sequence second, which reads more naturally to most people and is the one place C# wins this comparison outright. The count argument differs too: Python's maxsplit=1 means "split once, giving two pieces", while C#'s second argument is the maximum number of pieces, so the same result needs 2.Building a string in a loop
Strings are immutable in both languages, so repeated concatenation allocates a fresh string every time round the loop. Each language has its recommended way out.
pieces = []
for index in range(5):
pieces.append(f"line {index}")
print("\n".join(pieces))var builder = new StringBuilder();
for (var index = 0; index < 5; index++)
builder.AppendLine($"line {index}");
Console.Write(builder.ToString());Python's is a list plus
join; C#'s is StringBuilder, a growable buffer with Append, AppendLine and AppendJoin. Both turn quadratic work into linear. For a handful of pieces neither is necessary — an interpolated string is clearer, and the C# compiler is smart enough to fuse adjacent concatenations into a single string.Concat call.A character is its own type
Indexing a Python string gives you a one-character string, because Python has no character type. Indexing a C# string gives you a
char.word = "hello"
first = word[0]
print(first, type(first).__name__, len(word))var word = "hello";
var first = word[0];
Console.WriteLine($"{first} {first.GetType().Name} {word.Length}");A
char is a 16-bit UTF-16 code unit and is written with single quotes — 'a' is a char and "a" is a string, and the two are not interchangeable. That distinction also means word.Length counts UTF-16 units, not code points, so an emoji outside the Basic Multilingual Plane counts as two where Python's len says one. When that matters, enumerate with StringInfo or EnumerateRunes.Collections
list against List<T>
The workhorse container in each language. The shapes match; the type parameter and the property name are the two things to absorb.
numbers = [1, 2, 3]
numbers.append(4)
numbers.insert(0, 0)
print(numbers, len(numbers))
print(numbers[2])var numbers = new List<int> { 1, 2, 3 };
numbers.Add(4);
numbers.Insert(0, 0);
Console.WriteLine($"[{string.Join(", ", numbers)}] {numbers.Count}");
Console.WriteLine(numbers[2]);List<int> holds int and nothing else, so the heterogeneous list Python allows needs List<object> and a cast on the way out. append is Add and len is the Count property — Length belongs to arrays and strings, and mixing the two up is the most common early typo. A C# array (new[] { 1, 2, 3 }) is fixed-size, so List<T> is what you want almost always.dict against Dictionary<K,V>
The mapping type, with the three lookups a Python programmer uses daily: indexing,
get with a default, and membership.ages = {"Alice": 30, "Bob": 25}
ages["Carol"] = 35
print(ages.get("Dave", 0))
if "Alice" in ages:
print(ages["Alice"])
for name, age in sorted(ages.items()):
print(name, age)var ages = new Dictionary<string, int> { ["Alice"] = 30, ["Bob"] = 25 };
ages["Carol"] = 35;
Console.WriteLine(ages.GetValueOrDefault("Dave", 0));
if (ages.TryGetValue("Alice", out var aliceAge))
Console.WriteLine(aliceAge);
foreach (var (name, age) in ages.OrderBy(entry => entry.Key))
Console.WriteLine($"{name} {age}");GetValueOrDefault is dict.get. TryGetValue has no Python counterpart at all: it returns a bool and hands the value back through an out parameter, doing the "is it there" and the "give it to me" in one lookup instead of two. Indexing a missing key throws KeyNotFoundException, the analogue of KeyError. Iteration yields KeyValuePair entries which deconstruct into a tuple, so the foreach reads like Python's .items().set against HashSet<T>
Sets exist in both, with the same semantics and no operator overloads on the C# side.
first = {1, 2, 3}
second = {3, 4}
print(sorted(first | second))
print(sorted(first & second))
print(sorted(first - second))var first = new HashSet<int> { 1, 2, 3 };
var second = new HashSet<int> { 3, 4 };
Console.WriteLine(string.Join(", ", first.Union(second).Order()));
Console.WriteLine(string.Join(", ", first.Intersect(second).Order()));
Console.WriteLine(string.Join(", ", first.Except(second).Order()));Python spells the set algebra with operators; C# uses LINQ methods, which have the advantage of working on any sequence rather than only on sets.
HashSet<T> also has mutating versions — UnionWith, IntersectWith, ExceptWith — which change the set in place and match Python's |= family. Neither language's set preserves order.Tuples, named and unpacked
Returning several values and unpacking them at the call site works the same way in both languages, and C# tuple elements can carry names.
def divide(numerator, denominator):
return numerator // denominator, numerator % denominator
quotient, remainder = divide(17, 5)
print(quotient, remainder)(int Quotient, int Remainder) Divide(int numerator, int denominator)
=> (numerator / denominator, numerator % denominator);
var (quotient, remainder) = Divide(17, 5);
Console.WriteLine($"{quotient} {remainder}");
var result = Divide(17, 5);
Console.WriteLine(result.Quotient);The names in
(int Quotient, int Remainder) are part of the signature, so the caller may either deconstruct positionally or read result.Quotient — Python needs a NamedTuple class to get that. C# tuples are value types, so assigning one copies it, and they compare by contents with ==. They are meant for short-lived returns; anything that outlives a method call should be a record.Converting between collections
Python converts between containers by calling the target type —
set(...), list(...), dict(...). C# converts by calling a To... method at the end of a query.words = ["pear", "apple", "pear", "fig"]
unique = sorted(set(words))
print(unique)
lengths = {word: len(word) for word in unique}
print(lengths)var words = new[] { "pear", "apple", "pear", "fig" };
var unique = words.Distinct().Order().ToList();
Console.WriteLine(string.Join(", ", unique));
var lengths = unique.ToDictionary(word => word, word => word.Length);
Console.WriteLine(string.Join(", ", lengths.Select(entry => $"{entry.Key}={entry.Value}")));ToList, ToArray, ToHashSet and ToDictionary are the four you will use, and ToDictionary takes a key selector and a value selector, which is a dict comprehension split into two lambdas. Each of them forces the query to run, so their position in a chain is also a decision about when the work happens.Read-only views
Both languages let you hand out a collection that the recipient cannot modify. They differ on whether the recipient can still watch it change.
numbers = [1, 2, 3]
snapshot = tuple(numbers) # a COPY, taken now
numbers.append(4)
print(f"{len(snapshot)} items: {snapshot}")var numbers = new List<int> { 1, 2, 3 };
IReadOnlyList<int> view = numbers; // a VIEW, not a copy
numbers.Add(4);
Console.WriteLine($"{view.Count} items: {string.Join(", ", view)}");A Python tuple is a snapshot — converting copied the elements, so the later append is invisible and the tuple still holds three. The C# view reports four, because it is a window onto the same list.
IReadOnlyList<T> is an interface over the same object — the holder cannot add, but anyone with the underlying List still can, and the view sees it. For a real snapshot use numbers.ToImmutableList() from System.Collections.Immutable, or [.. numbers] to copy into a new array. Returning IReadOnlyList from a property is nonetheless the everyday idiom.Control Flow
for over a range, and over a sequence
C# has two loop keywords where Python has one.
foreach is Python's for; the C-style for is what range saves you from writing.for index in range(3):
print(index)
for colour in ["red", "green"]:
print(colour)for (var index = 0; index < 3; index++)
Console.WriteLine(index);
foreach (var colour in new[] { "red", "green" })
Console.WriteLine(colour);The three-part
for is worth knowing because it is the only form that gives you the index cheaply, but reach for foreach whenever you are walking a sequence — it is clearer and it works on anything enumerable. C# has break and continue with the same meanings, and no for...else; the usual replacement is a bool flag or a LINQ Any.enumerate and its C# equivalents
C# has no
enumerate. The overload of Select that also hands you the index is what stands in for it.colours = ["red", "green", "blue"]
for index, colour in enumerate(colours, start=1):
print(index, colour)var colours = new[] { "red", "green", "blue" };
foreach (var (colour, index) in colours.Select((colour, index) => (colour, index + 1)))
Console.WriteLine($"{index} {colour}");The lambda receives the element first and the index second — the reverse of the tuple
enumerate yields — which is a small trap when converting code. Many C# programmers simply use a counted for loop here, and .NET 9 added colours.Index(), which yields (int Index, T Item) pairs and is the direct translation when your target framework has it.There is no truthiness
Python gives every object a truth value: empty containers, zero, empty strings and
None are all falsy. C# accepts only a bool in a condition.items = []
if not items:
print("empty list is falsy")
name = ""
print(name or "anonymous")var items = new List<int>();
if (items.Count == 0)
Console.WriteLine("say what you mean");
var name = "";
Console.WriteLine(string.IsNullOrEmpty(name) ? "anonymous" : name);if (items) does not compile — there is no implicit conversion from a list to a bool — so the emptiness test has to be spelled out. That verbosity buys the removal of a whole family of bugs where 0 and None and "" were treated alike by accident. The same applies to or: C#'s || demands bools, and the value-returning idiom is ??, which tests only for null, never for emptiness.Conditional expressions and block scope
Two differences at once, both about where things live. The conditional expression is reordered, and C# scopes names to the block they were declared in.
score = 75
grade = "pass" if score >= 60 else "fail"
print(grade)
for index in range(3):
last = index
print(last) # the loop variable outlives the loopvar score = 75;
var grade = score >= 60 ? "pass" : "fail";
Console.WriteLine(grade);
var last = -1;
for (var index = 0; index < 3; index++)
last = index;
Console.WriteLine(last); // index itself is gone hereC# puts the condition first —
condition ? then : else — where Python puts the value first. More consequentially, a variable declared inside a loop or an if ceases to exist at the closing brace, so the pattern of leaning on a loop variable after the loop, which Python allows, needs a declaration outside. The same rule is why you cannot reuse a name in a nested block, and why the compiler catches a use-before-assignment that Python would only find at run time.Methods & Lambdas
Default and named arguments
C# has both defaults and named arguments, so the call style you are used to carries over almost unchanged.
def connect(host, port=5432, timeout=30):
print(f"{host}:{port} timeout={timeout}")
connect("db.example.com")
connect("db.example.com", timeout=5)void Connect(string host, int port = 5432, int timeout = 30)
=> Console.WriteLine($"{host}:{port} timeout={timeout}");
Connect("db.example.com");
Connect("db.example.com", timeout: 5);The only syntax change is the colon instead of the equals sign at the call site. Two rules differ underneath: a default value must be a compile-time constant, and the default is baked into the caller at compile time — so changing a default in a library does not change already-compiled callers until they are rebuilt. C# has no
*args/**kwargs pair either; params int[] numbers covers the first and nothing covers the second.Overloading — one name, several signatures
Python has exactly one function per name, so handling several shapes of input means one body with a type test at the top — or
functools.singledispatch. C# lets the same name carry several signatures and picks between them at compile time.def describe(value):
if isinstance(value, int):
return f"the number {value}"
if isinstance(value, str):
return f"the text {value}"
return "something else"
print(describe(42))
print(describe("hi"))Console.WriteLine(Description.Of(42));
Console.WriteLine(Description.Of("hi"));
static class Description
{
public static string Of(int value) => $"the number {value}";
public static string Of(string value) => $"the text {value}";
}The two methods live in a class because local functions cannot be overloaded — one name, one local function, which is the one place C# is as restrictive as Python. Overloads are a member of a type. This is overload resolution, and it happens before the program runs: the compiler looks at the static types of the arguments and writes the chosen method into the call. That means it cannot dispatch on a runtime type — a variable declared as
object holding an int selects an object overload, not the int one, which is the opposite of what singledispatch would do. When you want runtime dispatch, use a virtual method or a pattern match.out and ref — returning through a parameter
Python returns a tuple when a function has two answers. C# has a second channel: a parameter the method writes back through.
def parse(text):
try:
return True, int(text)
except ValueError:
return False, 0
ok, value = parse("42")
print(ok, value)if (int.TryParse("42", out var value))
Console.WriteLine($"True {value}");
var total = 10;
AddTen(ref total);
Console.WriteLine(total);
void AddTen(ref int number) => number += 10;An
out parameter must be assigned before the method returns, and the caller declares the variable inline with out var. A ref parameter passes the variable itself rather than its value, so the method can change the caller's variable — the one way to get Python-like reference behaviour out of a value type. Both are common in the standard library (TryParse, TryGetValue) and rare in new code, where a tuple or a nullable return is usually cleaner.Lambdas are not limited to one expression
Python's
lambda holds one expression, which is why a filter with two lines needs a named def. A C# lambda may have a full statement body in braces.numbers = [1, 2, 3, 4]
def is_even(number):
return number % 2 == 0
print([number for number in numbers if is_even(number)])
print(list(filter(lambda number: number > 2, numbers)))var numbers = new[] { 1, 2, 3, 4 };
Func<int, bool> isEven = number =>
{
var remainder = number % 2;
return remainder == 0;
};
Console.WriteLine(string.Join(", ", numbers.Where(isEven)));
Console.WriteLine(string.Join(", ", numbers.Where(number => number > 2)));That removes the reason most Python code names a small helper, and it is why LINQ chains stay inline where the Python equivalent often does not. A lambda's type is
Func<...> when it returns something and Action<...> when it does not — the last type parameter of a Func is the return type. Both are ordinary values: store them in a list, pass them around, return them.Closures capture variables, not values
Both languages close over the variable itself, so the counter keeps counting after the enclosing call has returned.
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print(counter(), counter(), counter())Func<int> MakeCounter()
{
var count = 0;
return () => ++count;
}
var counter = MakeCounter();
Console.WriteLine($"{counter()} {counter()} {counter()}");C# needs no
nonlocal: a lambda may read and assign an enclosing local without any declaration, and the compiler quietly moves that local onto the heap so it can outlive the method. That is one keyword fewer to remember and one more reason to be careful — capturing a loop variable in a for loop captures the single shared variable, so all the lambdas see the final value. C# fixed this for foreach in C# 5 but the C-style for still behaves the old way.Extension methods instead of monkey-patching
Python lets you add methods to your own classes at run time but not to the built-in types, so helpers on
str end up as free functions. C# has a supported way to write one that reads like a method on the type.def shout(text):
return text.upper() + "!"
print(shout("hello"))
# str.shout = shout would fail: built-in types are closedConsole.WriteLine("hello".Shout());
static class StringExtensions
{
public static string Shout(this string text) => text.ToUpper() + "!";
}The
this on the first parameter is the whole trick: it is still a static method, and the compiler rewrites "hello".Shout() into StringExtensions.Shout("hello"). Nothing is modified, so it is safe in a way monkey-patching is not, and it is available only where the containing namespace is in scope. LINQ is nothing but extension methods on IEnumerable<T> — that is how a sealed interface gained fifty operators without changing.Classes & Properties
A class, a constructor, a method
The same class in both languages, so the vocabulary lines up:
__init__ is a constructor named after the class, self is an implicit this, and __str__ is ToString.class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
def __str__(self):
return f"{self.owner}: {self.balance}"
account = Account("Alice")
account.deposit(100)
print(account)var account = new Account("Alice");
account.Deposit(100);
Console.WriteLine(account);
class Account
{
public string Owner { get; }
public int Balance { get; private set; }
public Account(string owner, int balance = 0)
{
Owner = owner;
Balance = balance;
}
public void Deposit(int amount) => Balance += amount;
public override string ToString() => $"{Owner}: {Balance}";
}Three things have no Python counterpart. Fields are declared, so an attribute invented in some other method does not exist.
public and private are enforced rather than hinted with a leading underscore — Balance { get; private set; } is readable everywhere and writable only inside the class. And override must be written explicitly when replacing an inherited method, so a typo produces an error rather than a silently unused method.Properties: @property with less ceremony
Both languages let a plain attribute access run code, so you can start with a field and add validation later without changing a single caller. This is the same design goal reached by different routes.
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("radius must be positive")
self._radius = value
circle = Circle(2)
circle.radius = 5
print(circle.radius)var circle = new Circle(2);
circle.Radius = 5;
Console.WriteLine(circle.Radius);
class Circle
{
private int radius;
public Circle(int radius) => this.radius = radius;
public int Radius
{
get => radius;
set => radius = value >= 0
? value
: throw new ArgumentException("radius must be positive");
}
}C# has a keyword-level feature rather than a decorator pair, and the setter's incoming value is always called
value. When there is no logic to add, public int Radius { get; set; } is the whole declaration — an auto-property, with the backing field generated for you, which is why C# code declares properties where Python would use bare attributes. An init accessor in place of set allows assignment only in an object initializer, giving immutability after construction.Inheritance and virtual methods
A subclass replacing a method looks the same in both columns, except that C# needs permission on both sides of the relationship.
class Animal:
def speak(self):
return "..."
class Dog(Animal):
def speak(self):
return "Woof"
for animal in [Animal(), Dog()]:
print(animal.speak())foreach (var animal in new Animal[] { new Animal(), new Dog() })
Console.WriteLine(animal.Speak());
class Animal
{
public virtual string Speak() => "...";
}
class Dog : Animal
{
public override string Speak() => "Woof";
}Every Python method is virtual: the object decides. In C# a method is non-virtual by default, and a subclass may only
override one marked virtual or abstract. Redefining a non-virtual method silently hides it instead, so the base version runs when the variable is typed as the base — the exact bug override exists to prevent, and the compiler warns unless you write new to say you meant it. C# also has no multiple inheritance and no method resolution order; mixins are done with interfaces and default implementations.Interfaces against duck typing
The Python column works because nobody asked what the objects were, only whether they had the method. C# wants the shared capability written down and declared by each type that has it.
class Duck:
def quack(self):
return "Quack"
class Person:
def quack(self):
return "I say quack"
for thing in [Duck(), Person()]:
print(thing.quack()) # anything with the method worksforeach (IQuacker thing in new IQuacker[] { new Duck(), new Person() })
Console.WriteLine(thing.Quack());
interface IQuacker
{
string Quack();
}
class Duck : IQuacker
{
public string Quack() => "Quack";
}
class Person : IQuacker
{
public string Quack() => "I say quack";
}The compiler then guarantees the method exists everywhere it is called, and the interface becomes a name you can use as a parameter type. The cost is that a type you do not own cannot be retrofitted into your interface — no equivalent of registering a class with a
Protocol after the fact. The C# convention of prefixing interface names with I is universal in the standard library, so IQuacker rather than Quacker.Static members and class variables
A value that belongs to the type rather than to any instance.
class Counter:
created = 0
def __init__(self):
Counter.created += 1
Counter()
Counter()
print(Counter.created)new Counter();
new Counter();
Console.WriteLine(Counter.Created);
class Counter
{
public static int Created { get; private set; }
public Counter() => Created++;
}The declarations look alike, but a C#
static member is reachable only through the type — instance.Created does not compile, where Python happily reads a class attribute through an instance and, worse, lets an assignment through an instance create a shadowing instance attribute. C# also has static class for a type that can never be instantiated, which is where free-function-style helpers live, and a static constructor that runs once before first use.Operator overloading
Both languages let a type define what
+ means for it. Python uses a dunder method on the instance; C# uses a static method with the operator keyword.class Money:
def __init__(self, amount):
self.amount = amount
def __add__(self, other):
return Money(self.amount + other.amount)
def __str__(self):
return f"${self.amount}"
print(Money(3) + Money(4))Console.WriteLine(new Money(3) + new Money(4));
class Money
{
public decimal Amount { get; }
public Money(decimal amount) => Amount = amount;
public static Money operator +(Money left, Money right)
=> new Money(left.Amount + right.Amount);
public override string ToString() => "$" + Amount;
}Because the C# operator is static and takes both operands, there is no
__radd__ problem — one method covers both orders for a given pair of types. The set of overloadable operators is fixed and smaller than Python's dunder catalogue; notably == can be overloaded but should be done together with Equals and GetHashCode, and the compiler warns when it is not. Declaring a record gives you all three for free.Records
record against @dataclass
This is the row where C# is shorter than Python. A
record declares a type whose whole definition is its data, and the compiler writes the constructor, the equality, the hash code and a readable ToString.from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
first = Point(1, 2)
print(first)
print(first == Point(1, 2))var first = new Point(1, 2);
Console.WriteLine(first);
Console.WriteLine(first == new Point(1, 2));
record Point(int X, int Y);The printed form is
Point { X = 1, Y = 2 }, close enough to a dataclass repr to feel familiar, and == compares by contents — the one place a C# reference type does that without being asked. The positional parameters become init-only properties, so a record is immutable unless you ask for otherwise, which is the opposite default from @dataclass.Copying with a change
Immutable data needs a cheap way to say "the same thing but with one field different". Python has
dataclasses.replace; C# has an operator for it.from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Point:
x: int
y: int
first = Point(1, 2)
moved = replace(first, y=99)
print(first, moved)var first = new Point(1, 2);
var moved = first with { Y = 99 };
Console.WriteLine($"{first} {moved}");
record Point(int X, int Y);The
with expression copies the record and applies the listed assignments to the copy, leaving the original untouched. It is checked at compile time, so a misspelled property name is an error rather than the TypeError replace raises at run time. This is what makes immutable-by-default records practical to work with rather than merely virtuous.Enums
Both languages have a named-constant type; the C# one is much thinner than it looks.
from enum import Enum
class Status(Enum):
ACTIVE = 1
RETIRED = 2
state = Status.ACTIVE
print(state, state.name, state.value)var state = Status.Active;
Console.WriteLine($"{state} {(int)state}");
enum Status
{
Active = 1,
Retired = 2,
}A C#
enum is an integer wearing a name. It has no methods, and — the part that surprises everyone — no validation: (Status)99 is a perfectly legal Status value, because the cast is just a reinterpretation of the number. A Python Enum member is a real object with identity, so Status(99) raises. Use Enum.IsDefined when the value came from outside, and consider a record hierarchy when you want behaviour attached.Pattern Matching
match against a switch expression
Python 3.10 borrowed structural pattern matching from languages like C#, so the two read very much alike: a subject, a list of patterns, a guard clause, a catch-all.
def describe(value):
match value:
case 0:
return "zero"
case int() if value < 0:
return "negative"
case int():
return "positive"
case _:
return "not a number"
print(describe(0), describe(-5), describe(7), describe("x"))string Describe(object value) => value switch
{
0 => "zero",
int number when number < 0 => "negative",
int => "positive",
_ => "not a number",
};
Console.WriteLine($"{Describe(0)} {Describe(-5)} {Describe(7)} {Describe("x")}");The C# form is an expression — it produces a value, which is why the method body is a single
=>. when is Python's if guard, and _ is the discard in both. The one real difference is that the compiler tracks exhaustiveness: drop the _ arm and you get a warning, and at run time an unmatched value throws SwitchExpressionException rather than falling through silently as Python's match does.Matching on the shape of an object
Both languages can match on an object's members rather than only on its type, and both let a relational test appear inside the pattern.
from dataclasses import dataclass
@dataclass
class Order:
total: int
express: bool
def fee(order):
match order:
case Order(total=total, express=True) if total > 100:
return 0
case Order(express=True):
return 15
case _:
return 5
print(fee(Order(150, True)), fee(Order(20, True)), fee(Order(20, False)))int Fee(Order order) => order switch
{
{ Express: true, Total: > 100 } => 0,
{ Express: true } => 15,
_ => 5,
};
Console.WriteLine($"{Fee(new Order(150, true))} {Fee(new Order(20, true))} {Fee(new Order(20, false))}");
record Order(int Total, bool Express);C# writes the member tests in braces and allows a bare comparison —
Total: > 100 — where Python needs a capture plus a when guard. Patterns nest ({ Customer.Address.Country: "NL" }), combine with and/or/not, and can deconstruct positionally: Order(var total, true) works because a record generates a Deconstruct method.isinstance as a pattern
The
is operator tests the type and, in the same breath, gives you a variable of that type.def length(value):
if isinstance(value, str):
return len(value)
if isinstance(value, list):
return len(value)
return 0
print(length("hello"), length([1, 2, 3]), length(42))int Length(object value)
{
if (value is string text)
return text.Length;
if (value is List<int> numbers)
return numbers.Count;
return 0;
}
Console.WriteLine($"{Length("hello")} {Length(new List<int> { 1, 2, 3 })} {Length(42)}");That combination is the everyday form of pattern matching in C# and it removes the cast that used to follow every type test. Note the collision of names: C#'s
is is Python's isinstance, while Python's is — identity — is C#'s ReferenceEquals. The declared variable is in scope for the rest of the enclosing block, which is what makes the early-return style read cleanly.A closed set of types
A base type plus a fixed set of variants — the shape you would reach for a class hierarchy or an
Enum to express in Python.class Shape: pass
class Circle(Shape):
def __init__(self, radius): self.radius = radius
class Square(Shape):
def __init__(self, side): self.side = side
def area(shape):
match shape:
case Circle(): return 3.14 * shape.radius ** 2
case Square(): return shape.side ** 2
raise ValueError("unknown shape")
print(area(Circle(1)), area(Square(2)))double Area(Shape shape) => shape switch
{
Circle circle => 3.14 * circle.Radius * circle.Radius,
Square square => square.Side * square.Side,
};
Console.WriteLine($"{Area(new Circle(1))} {Area(new Square(2))}");
abstract record Shape;
record Circle(double Radius) : Shape;
record Square(double Side) : Shape;The C# switch has no catch-all arm and still compiles without a warning here, because the compiler can see every type deriving from
Shape in this file. Marking the base sealed — abstract sealed record hierarchies, formally sealed types — makes that guarantee hold across files, so adding a variant later turns every unhandled switch into a warning. That is the check Python's match cannot perform, and the reason this pattern is worth reaching for over a virtual method when the operations change more often than the types.Nullability
null is opt-in
Any Python name may be
None, and nothing in the language distinguishes the ones that might be from the ones that never are. Modern C# does distinguish them, in the type.def find(names, target):
for name in names:
if name == target:
return name
return None
found = find(["alice"], "bob")
print(found)
print(len(found) if found is not None else "not found")string? Find(string[] names, string target)
{
foreach (var name in names)
if (name == target)
return name;
return null;
}
var found = Find(new[] { "alice" }, "bob");
Console.WriteLine(found ?? "(null)");
Console.WriteLine(found is not null ? found.Length.ToString() : "not found");With nullable reference types enabled — the default for new projects —
string means "never null" and string? means "might be". Dereferencing the second without checking is a compiler warning, and the checks you write teach the compiler what it may assume afterwards. It is a warning rather than an error because the guarantee is not airtight: older libraries predate the annotations. Still, it moves the majority of null bugs to compile time, which is the closest thing C# has to a solved problem that Python has not.The null operators
C# has three short operators for null and they cover most of what Python expresses with
or, and and an if.config = {"host": None}
host = config.get("host") or "localhost"
print(host)
value = config.get("missing")
print(value.upper() if value is not None else "(none)")var config = new Dictionary<string, string?> { ["host"] = null };
var host = config.GetValueOrDefault("host") ?? "localhost";
Console.WriteLine(host);
var value = config.GetValueOrDefault("missing");
Console.WriteLine(value?.ToUpper() ?? "(none)");?? supplies a value when the left side is null; ?. calls a member only when the receiver is not null and yields null otherwise; ??= assigns only when the target is null. The difference from Python's or matters: 0 or 5 is 5 and "" or "x" is "x", because Python tests truthiness, whereas ?? tests null alone and leaves 0 and "" intact. Chaining ?. short-circuits the whole chain, which is the idiom for a deep optional path.A number that might be missing
An
int in C# is a value type and cannot hold null the way a Python integer variable can hold None. int? is a different type — Nullable<int> — that wraps it.readings = [1, None, 3]
total = sum(reading for reading in readings if reading is not None)
print(total)var readings = new int?[] { 1, null, 3 };
var total = readings.Where(reading => reading.HasValue).Sum(reading => reading!.Value);
Console.WriteLine(total);It carries
HasValue and Value, and unwrapping it when it is empty throws InvalidOperationException. This is the one place where C# nullability is enforced at run time rather than merely checked at compile time, because a nullable value type genuinely has a different representation. The ! is the null-forgiving operator: it tells the compiler "I have checked, stop warning", and it is the tool you will most regret overusing.The exception you will actually see
When a null does slip through, the two runtimes report it differently, and the C# message is the less helpful of the two.
name = None
try:
print(name.upper())
except AttributeError as error:
print("AttributeError:", error)string? name = null;
try
{
Console.WriteLine(name!.ToUpper());
}
catch (NullReferenceException error)
{
Console.WriteLine("NullReferenceException: " + error.Message);
}Python names the attribute and the type:
'NoneType' object has no attribute 'upper'. A NullReferenceException historically named nothing at all, though .NET 6 and later add the expression when it can work it out. Guarding an argument at the top of a method with ArgumentNullException.ThrowIfNull(name) gives a far better message and is the standard idiom for public methods.Generics
A function over any type
Python code is generic by default — a function accepts whatever it is given and fails only if an operation is missing. C# says so in the signature, with a type parameter in angle brackets.
def first_or_default(items, fallback):
return items[0] if items else fallback
print(first_or_default([1, 2], 0))
print(first_or_default([], "none"))T FirstOrFallback<T>(IReadOnlyList<T> items, T fallback)
=> items.Count > 0 ? items[0] : fallback;
Console.WriteLine(FirstOrFallback(new[] { 1, 2 }, 0));
Console.WriteLine(FirstOrFallback(Array.Empty<string>(), "none"));The
<T> makes the relationship explicit: the fallback must be the same type as the elements, and the return type is that type too, so the caller keeps full type information. The call sites need no annotation because C# infers the type argument from the arguments. This is the same information a TypeVar conveys to mypy, except that here it is the compiler enforcing it and the runtime preserving it.Constraining a type parameter
To use an operation inside a generic function you must first promise the type supports it. That promise is the
where clause.def largest(items):
biggest = items[0]
for item in items[1:]:
if item > biggest: # hope every item supports >
biggest = item
return biggest
print(largest([3, 9, 4]))
print(largest(["pear", "apple"]))T Largest<T>(IEnumerable<T> items) where T : IComparable<T>
{
var biggest = items.First();
foreach (var item in items)
if (item.CompareTo(biggest) > 0)
biggest = item;
return biggest;
}
Console.WriteLine(Largest(new[] { 3, 9, 4 }));
Console.WriteLine(Largest(new[] { "pear", "apple" }));Without the constraint the body would not compile, because
T could be anything. With it, the compiler checks each call site instead — passing a type that does not implement IComparable<T> is an error at the call, not a surprise inside the loop. That is the trade for Python's optimism, where largest of a list of dictionaries type-checks fine and raises TypeError in production. Other constraints cover reference types, value types, having a parameterless constructor, and deriving from a base class.Type arguments survive to run time
A Python list has no element type to lose. C#'s generics keep theirs all the way into the running program — unlike Java, where they are erased.
numbers = [1, 2, 3]
print(type(numbers).__name__) # just "list"
numbers.append("not a number") # nothing objects
print(numbers)var numbers = new List<int> { 1, 2, 3 };
Console.WriteLine(numbers.GetType().Name);
Console.WriteLine(numbers.GetType().GetGenericArguments()[0].Name);
// numbers.Add("not a number"); // uncomment: compile errorThe runtime knows this object is a
List of Int32, which is why List<int> can store unboxed integers, why typeof(T) works inside a generic method, and why serialization libraries can reconstruct a strongly-typed list from JSON. If you have met Java's erasure, this is the thing C# does differently, and it is the reason C# generics carry no List<?>-style wildcards.Error Handling
try / except / finally
The structure is identical; only the keyword and the exception names change.
try:
value = int("not a number")
except ValueError as error:
print("ValueError:", error)
finally:
print("always runs")try
{
var value = int.Parse("not a number");
}
catch (FormatException error)
{
Console.WriteLine("FormatException: " + error.Message);
}
finally
{
Console.WriteLine("always runs");
}except is catch, and the type comes first with the variable after it. finally means the same thing in both languages. What C# lacks is Python's else clause on a try, and — importantly for anyone translating code — it has no checked exceptions, so unlike Java nothing forces you to declare or handle what a method may throw. That makes C# error handling feel much more like Python's than like Java's.Raising your own exception
A custom exception is a class deriving from the base exception type in both languages, carrying whatever extra data the handler will want.
class InsufficientFunds(Exception):
def __init__(self, shortfall):
super().__init__(f"short by {shortfall}")
self.shortfall = shortfall
try:
raise InsufficientFunds(25)
except InsufficientFunds as error:
print(error, error.shortfall)try
{
throw new InsufficientFundsException(25);
}
catch (InsufficientFundsException error)
{
Console.WriteLine($"{error.Message} {error.Shortfall}");
}
class InsufficientFundsException : Exception
{
public int Shortfall { get; }
public InsufficientFundsException(int shortfall)
: base($"short by {shortfall}") => Shortfall = shortfall;
}raise is throw, and super().__init__(...) is the : base(...) clause after the constructor signature. The convention is to end the name in Exception. One habit to unlearn: re-throwing inside a handler should be a bare throw;, not throw error; — the second resets the stack trace to this line, discarding where the failure actually happened.with against using
Both languages have a block that guarantees cleanup when control leaves it, however it leaves — normal exit, early return, or exception.
import io
with io.StringIO() as buffer:
buffer.write("written inside the block")
print(buffer.getvalue())
print("buffer is closed")using (var writer = new StringWriter())
{
writer.Write("written inside the block");
Console.WriteLine(writer.ToString());
}
Console.WriteLine("writer is disposed");with calls __enter__ and __exit__; using calls Dispose on anything implementing IDisposable. The differences are that __exit__ can suppress an exception by returning true and Dispose cannot, and that C# offers a declaration form — using var writer = new StringWriter(); with no block — which disposes at the end of the enclosing scope and is what most modern code uses.Exception filters
Sometimes only some instances of an exception type are yours to handle. Python catches everything of that type and re-raises what it does not want; C# has a clause that decides before catching.
try:
raise ValueError("code 404")
except ValueError as error:
if "404" not in str(error):
raise
print("handled a 404")try
{
throw new InvalidOperationException("code 404");
}
catch (InvalidOperationException error) when (error.Message.Contains("404"))
{
Console.WriteLine("handled a 404");
}The difference is not cosmetic. A
when filter runs while the stack is still intact, so an unmatched exception continues on its way with its original stack and any debugger break happens at the throw site — where the Python version has already unwound to the handler before deciding to re-raise. Filters are also the clean way to log without handling: catch (Exception error) when (Log(error)), with a Log that returns false.Async & Threads
Same keywords, different guarantees
The keywords are spelled the same and mean the same thing at the level of syntax: mark a function
async, suspend it at an await, resume when the awaited thing completes.import asyncio
async def fetch(name):
await asyncio.sleep(0) # stands in for I/O
return f"data from {name}"
async def main():
print(await fetch("service"))
asyncio.run(main())async Task<string> Fetch(string name)
{
await Task.Delay(1); // stands in for I/O
return $"data from {name}";
}
Console.WriteLine(await Fetch("service"));What they promise underneath is different. Python's coroutines run on one thread inside one event loop, so an
async program is concurrent but never parallel, and a blocking call stalls everything. A C# Task resumes on the thread pool, so two awaited operations can genuinely run at the same time on different cores. Also note that a C# top-level program may await directly — the compiler generates the async entry point — so there is no asyncio.run to call.Awaiting several things at once
Starting several operations and waiting for all of them is the reason to use async in the first place, and both languages have one call for it.
import asyncio
async def work(number):
await asyncio.sleep(0)
return number * 2
async def main():
results = await asyncio.gather(*(work(number) for number in range(5)))
print(results)
asyncio.run(main())async Task<int> Work(int number)
{
await Task.Delay(1);
return number * 2;
}
var results = await Task.WhenAll(Enumerable.Range(0, 5).Select(Work));
Console.WriteLine(string.Join(", ", results));Task.WhenAll is asyncio.gather, and it preserves order in the same way. The important semantic difference is when the work starts: a Python coroutine does nothing until it is awaited or scheduled, whereas calling a C# async method starts it immediately and hands back a Task already in flight. That is why Select(Work) above launches all five before WhenAll is even called — a hot task, not a cold coroutine.Real parallelism, no GIL
Two threads incrementing a shared counter. The code is nearly the same; what the machine does with it is not.
import threading
total = 0
lock = threading.Lock()
def increment():
global total
for _ in range(100_000):
with lock:
total += 1
workers = [threading.Thread(target=increment) for _ in range(2)]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
print(total)var total = 0;
var padlock = new object();
void Increment()
{
for (var step = 0; step < 100_000; step++)
lock (padlock)
total++;
}
var workers = new[] { Task.Run(Increment), Task.Run(Increment) };
Task.WaitAll(workers);
Console.WriteLine(total);CPython's global interpreter lock means those two threads take turns, so threading buys nothing for CPU-bound work and Python programmers reach for
multiprocessing instead. The .NET threads run at the same instant on different cores, which is why the lock is not optional: remove it and the answer comes out short, because total++ is a read, an add and a write. C# spells the mutual exclusion lock (someObject), the direct analogue of with lock:.Parallelising a query
Spreading a pure computation across cores takes an executor and a pool in Python. In C# it takes one method call in the middle of a query you have already written.
from concurrent.futures import ThreadPoolExecutor
def slow_square(number):
return number * number
with ThreadPoolExecutor(max_workers=4) as pool:
results = sorted(pool.map(slow_square, range(10)))
print(results)var results = Enumerable.Range(0, 10)
.AsParallel()
.Select(number => number * number)
.OrderBy(square => square)
.ToList();
Console.WriteLine(string.Join(", ", results));AsParallel turns a LINQ query into a PLINQ query: the runtime partitions the source across the thread pool and merges the results. Because the GIL does not exist, this genuinely uses every core, which is the payoff for all the type declarations elsewhere on this page. Ordering is not preserved unless you ask — hence the explicit OrderBy, or AsOrdered() — and for a query this small the coordination costs more than the work, so measure before sprinkling it about.Packages, Build & Tooling
Namespaces and using
A
using directive looks like an import and is not one. It imports no code — it only makes names in a namespace available without their full prefix.import json
from collections import Counter
print(json.dumps({"ok": True}))
letter, count = Counter("banana").most_common(1)[0]
print(letter, count)using System.Text.Json;
Console.WriteLine(JsonSerializer.Serialize(new { ok = true }));
var counts = "banana".GroupBy(letter => letter)
.OrderByDescending(group => group.Count())
.First();
Console.WriteLine($"{counts.Key} {counts.Count()}");Everything in every referenced assembly is already present;
using is purely about how much of the name you have to type, so there is no import-time side effect, no circular import problem, and no cost to an unused one. There is no from x import y for a single type, though using static System.Math; imports a type's static members and using Alias = Some.Long.Type; renames. The runner supplies the common namespaces implicitly, which is why only System.Text.Json is written out here.pip against NuGet, and the project file
The workflow around the code is where the two ecosystems diverge most, and knowing the mapping saves an afternoon.
# pip install requests
# pip freeze > requirements.txt
# python -m venv .venv && source .venv/bin/activate
# python main.py
print("Python: an interpreter, a virtualenv, and a list of pins")// dotnet new console -o MyApp
// dotnet add package Newtonsoft.Json
// dotnet run
// MyApp.csproj records the packages; no virtualenv exists
Console.WriteLine("C#: a project file, a restore, and a build");dotnet add package is pip install, NuGet is PyPI, and the .csproj file is requirements.txt and pyproject.toml at once — it lists dependencies with versions and is checked in. There is no virtual environment because packages are restored per project by default, which removes an entire category of Python problem. What you give up is the REPL-first workflow: dotnet run compiles first, and while dotnet-script and the C# Interactive window exist, no C# team lives in them the way a Python team lives in the REPL.