C# is a statically typed language from Microsoft that compiles to run on .NET,
on Windows, macOS and Linux alike. It is behind ASP.NET Core web services,
Unity games and a lot of desktop and cloud software. The reference below is
grouped by what you are trying to do, and the filter box searches all of it at
once. Type linq and every LINQ row comes to you, or type C# 14 to see what
that release added.
Every snippet is checked against C# 14 on .NET 10, the current
long-term support release. .NET 8, the previous LTS, is the baseline, so
anything newer says so in the notes column and you can tell at a glance whether
it will run on the SDK you have. Names like names, people and Person are
placeholders for your own. If you do not have the .NET SDK installed, the
official Docker image is the quickest way to try something:
docker run --rm -it -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:10.0 bash,
and the Docker cheat sheet has the rest.
Coming from Java? The Java cheat sheet is laid
out the same way, so the two are easy to compare side by side.
Searches the task, the command and the third column. Press / from anywhere on the page.
306 commands
The dotnet CLI
| Task | Command | Notes |
|---|---|---|
| Check which version you have | dotnet --version dotnet --list-sdks | |
| Run one .cs file, no project | dotnet run app.cs | .NET 10. A file-based app: top-level code, no .csproj |
| Use a NuGet package in that file | #:package Humanizer@2.14.1 | .NET 10. A directive on the first lines of app.cs |
| Turn the file into a project | dotnet project convert app.cs | .NET 10. For when it outgrows one file |
| New console app | dotnet new console -o MyApp | Makes a MyApp folder with MyApp.csproj and Program.cs |
| Other starting points | dotnet new webapi dotnet new classlib dotnet new xunit | dotnet new list shows every template |
| Run the project in this folder | dotnet run | Builds first if anything changed |
| Pass arguments to your program | dotnet run -- --port 8080 | Everything after -- goes to args |
| Rerun on every save | dotnet watch | Hot reload where it can, a restart where it cannot |
| Build | dotnet build dotnet build -c Release | Debug is the default configuration |
| Treat every warning as an error | dotnet build -warnaserror | Or <TreatWarningsAsErrors>true</TreatWarningsAsErrors> in the .csproj |
| Run the tests | dotnet test | |
| Run some of the tests | dotnet test --filter "FullyQualifiedName~Orders" | |
| Add a NuGet package | dotnet add package Serilog | Latest stable. --version 4.3.0 pins one |
| Remove a package | dotnet remove package Serilog | |
| List packages, and outdated ones | dotnet list package dotnet list package --outdated | |
| Reference another project | dotnet add reference ../Core/Core.csproj | |
| Make a solution and add projects | dotnet new sln dotnet sln add MyApp/MyApp.csproj | .NET 10 writes the newer .slnx format |
| Restore packages | dotnet restore | build, run and test do it for you |
| Publish for deployment | dotnet publish -c Release -o out | Release is the default for publish since .NET 8 |
| One self-contained file | dotnet publish -r linux-x64 --self-contained -p:PublishSingleFile=true | Runs on a machine with no .NET installed. win-x64, osx-arm64 and so on |
| Tidy the code style | dotnet format | |
| Install a command-line tool | dotnet tool install -g dotnet-ef | Tools go in ~/.dotnet/tools |
| Clear build output | dotnet clean |
dotnet run app.cs is the quickest way to try something on .NET 10: the file is just statements, no class or Main needed. Once there is more than one file, dotnet new console gives you a project, and the .csproj is where the target framework, nullable setting and packages live.
Types and nullable references
Every type is either a value type (int, double, bool, struct, enum), which holds its data directly and is copied on assignment, or a reference type (string, arrays, classes, records), which is a reference to an object. With nullable references on, which every new project template does, a reference type can only hold null if you add ? to it, and the compiler warns you before a null gets where it should not.
| Task | Code | Notes |
|---|---|---|
| Integer | int n = 42; | 32 bits everywhere. int is an alias for System.Int32 |
| Wider integer | long big = 9_000_000_000L; | 64 bits. The _ is a digit separator |
| Floating point | double d = 3.14; float f = 3.14f; | double is the default for decimals |
| Exact decimals, for money | decimal price = 19.99m; | Base 10, so 0.1m + 0.2m is exactly 0.3m. The m is required |
| Boolean | bool done = false; | Not a number, so if (1) does not compile |
| Character | char c = 'A'; | A 16-bit UTF-16 unit |
| Text | string name = "Ada"; | string and String are the same type |
| Let the compiler infer the type | var names = new List<string>(); | Local variables only. Still statically typed |
| Infer the type from the left | List<string> names = new(); | Target-typed new. Handy for fields |
| Compile-time constant | const int MaxUsers = 100; | Numbers, strings, bools and enums only |
| Set once, at run time | readonly DateTime created = DateTime.UtcNow; | A field. Assignable only here or in a constructor |
| Value type that can be null | int? age = null; | age.HasValue, age.Value, age ?? 0 |
| Reference that can be null | string? middleName = null; | Without the ?, the compiler warns when null is assigned |
| Turn nullable checking on | <Nullable>enable</Nullable> | In the .csproj. Or #nullable enable at the top of one file |
| Value, or a fallback if null | string shown = middleName ?? "none"; | |
| Assign only if null | cache ??= new Dictionary<string, int>(); | |
| Member access that stops at null | int? length = middleName?.Length; | null if middleName is null, instead of throwing |
| Assign through ?. | customer?.Order = order; | C# 14. Does nothing when customer is null |
| Promise the compiler it is not null | middleName!.Length | Silences the warning. Checks nothing at run time |
| Check for null | if (x is null) { } if (x is not null) { } | Safe even if the type overloads == |
| Throw if an argument is null | ArgumentNullException.ThrowIfNull(name); | |
| Convert to another type | double avg = (double)total / count; | Cast one side, or the division is whole-number |
| Cut a decimal down to an int | int n = (int)3.99; | 3. Truncates toward zero |
| Text to a number | int n = int.Parse("42"); decimal d = decimal.Parse("2.5"); | Throws FormatException on bad input |
| Text to a number, safely | if (int.TryParse(input, out int n)) { } | false instead of an exception |
| Number to text | 42.ToString() 255.ToString("X") | The second gives FF, in hex |
| Largest and smallest values | int.MaxValue long.MinValue | |
| Hex and binary literals | 0xFF 0b1010_1010 | No octal literals, so 010 is just 10 |
| Fail on overflow instead of wrapping | int total = checked(a + b); | Throws OverflowException. a + b silently wraps |
| Default value of a type | default(int) default(string) | 0 and null |
| Group values without a class | var person = (Name: "Ada", Age: 36); | A tuple. person.Name, person.Age |
| Take a tuple apart | var (name, age) = person; | |
| Swap two variables | (a, b) = (b, a); | |
| Name of a variable, as text | nameof(age) | "age". Survives a rename, unlike a string |
| Integers of any size | BigInteger.Pow(2, 100) | System.Numerics |
Strings and interpolation
A string can never change. Every method that seems to modify one returns a new string, so keep the result: s.ToUpper() on its own line does nothing. Unlike Java, == compares the text.
| Task | Code | Notes |
|---|---|---|
| Put values in a string | string msg = $"{name} is {age}"; | An interpolated string. Any expression goes in the braces |
| Format numbers | $"{price:C} {ratio:P1} {n:N0} {x:F2}" | Currency, percent, thousands separators, 2 decimal places |
| Pad and align | $"{name,-8}|{n,5}" | Negative aligns left, positive aligns right |
| Format a date | $"{DateTime.Now:yyyy-MM-dd HH:mm}" | |
| A literal brace | $"{{ {value} }}" | Double it |
| Backslashes as they are | @"C:\temp\notes.txt" | A verbatim string. Write a double quote as two |
| Text with quotes in it | """{"name": "Ada"}""" | A raw string literal. Nothing inside needs escaping |
| Raw text with values in it | $$"""{"name": "{{name}}"}""" | $$ means {{ }} interpolates and single braces stay as they are |
| Length | name.Length | A property, no brackets |
| One character | name[0] name[^1] | ^1 is the last one |
| Part of a string | s.Substring(1, 3) s[1..4] | Substring takes a start and a length, the range a start and an end |
| Compare text | answer == "yes" | Compares the text, and is fine with null |
| Compare ignoring case | string.Equals(a, b, StringComparison.OrdinalIgnoreCase) | |
| Alphabetical order | string.Compare(a, b, StringComparison.Ordinal) | Negative if a comes first, 0 if equal |
| Contains, starts with, ends with | s.Contains("cat") s.StartsWith('/') s.EndsWith(".cs") | |
| Find text | s.IndexOf("cat") | -1 if it is not there |
| Replace every match | s.Replace("cat", "dog") | Plain text. Regex.Replace takes a pattern |
| Split | string[] parts = "a,b,,c".Split(',', StringSplitOptions.RemoveEmptyEntries); | Without the option you get an empty string for ,, |
| Join | string.Join(", ", names) | |
| Trim whitespace | s.Trim() s.TrimStart() s.TrimEnd('/') | |
| Null, empty or blank | string.IsNullOrEmpty(s) string.IsNullOrWhiteSpace(s) | Static, so they are safe to call on null |
| Change case | s.ToUpper() s.ToLowerInvariant() | The Invariant versions ignore the user's language settings |
| Repeat a character | new string('-', 20) | |
| Reverse | string.Concat(s.Reverse()) | |
| Build one up in a loop | var sb = new StringBuilder(); sb.Append(word).Append(','); string result = sb.ToString(); | += in a loop copies the whole string every time |
| Loop over the characters | foreach (char c in s) { } | |
| Test a character | char.IsDigit(c) char.IsLetter(c) char.IsWhiteSpace(c) | |
| Split into lines | s.Split('\n') | Call s.ReplaceLineEndings() first if it might have \r\n |
For text across several lines, use a raw string literal: three double quotes on their own line, the text, then three closing double quotes on their own line. The closing quotes set the indentation, and whitespace to their left is removed from every line.
Collections
Arrays have a fixed length. List<T> is the growable default, Dictionary<TKey, TValue> maps keys to values, and HashSet<T> holds unique values. The generic collections are in System.Collections.Generic, which every project imports for you.
| Task | Code | Notes |
|---|---|---|
| Array with values | int[] nums = [3, 1, 2]; | A collection expression. Works for arrays, lists and spans |
| Array with a size | var names = new string[5]; | Filled with null. Numbers start at 0, bools at false |
| Length of an array | nums.Length | Count on a List. Length on arrays and strings |
| Sort an array | Array.Sort(nums); | |
| Two dimensions | int[,] grid = new int[3, 4]; grid[1, 2] = 5; | 3 rows of 4. grid.GetLength(0) is 3 |
| Growable list | List<string> names = ["Ada", "Linus"]; | Or new List<string>() for an empty one |
| Add | names.Add("Grace"); names.Insert(0, "Alan"); | Insert puts it at index 0 |
| Add several | names.AddRange(["Barbara", "Edsger"]); | |
| Read and replace by index | names[0] names[^1] names[0] = "Barbara"; | ^1 is the last item |
| Count and contains | names.Count names.Contains("Ada") | |
| Remove | names.Remove("Ada"); names.RemoveAt(0); | Remove returns false if it was not there |
| Remove every match | names.RemoveAll(n => n.StartsWith('A')); | Safe, unlike removing inside a foreach |
| Find one | names.Find(n => n.Length > 4) | null or 0 if nothing matches |
| Slice | names[1..3] | A new list of items 1 and 2 |
| Sort | names.Sort(); people.Sort((a, b) => a.Age.CompareTo(b.Age)); | |
| Combine lists | int[] all = [.. first, .. second]; | The spread .. copies every item in |
| Key to value | var ages = new Dictionary<string, int> { ["Ada"] = 36 }; | |
| Add or replace | ages["Bob"] = 40; | |
| Add, failing if it exists | ages.Add("Ada", 36); | Throws ArgumentException for a duplicate key |
| Look up | ages["Ada"] | Throws KeyNotFoundException for a missing key |
| Look up, safely | if (ages.TryGetValue("Ada", out int age)) { } | |
| Look up with a default | ages.GetValueOrDefault("Zoe", 0) | |
| Is the key there, remove a key | ages.ContainsKey("Ada") ages.Remove("Ada"); | |
| Count occurrences | counts[word] = counts.GetValueOrDefault(word) + 1; | |
| Loop over keys and values | foreach (var (name, age) in ages) { } | Takes each KeyValuePair apart. Also ages.Keys and ages.Values |
| Dictionary sorted by key | var sorted = new SortedDictionary<string, int>(ages); | |
| Unique values | var seen = new HashSet<string>(); seen.Add(x) | Add returns false if it was already there |
| Union, intersection, difference | a.UnionWith(b); a.IntersectWith(b); a.ExceptWith(b); | Each one changes a |
| Stack | var stack = new Stack<int>(); stack.Push(1); stack.Peek(); stack.Pop(); | |
| Queue | var queue = new Queue<int>(); queue.Enqueue(1); queue.Dequeue(); | Throws when empty. TryDequeue does not |
| Always get the smallest next | var pq = new PriorityQueue<string, int>(); pq.Enqueue("task", 2); pq.Dequeue(); | Lowest priority number comes out first |
| Read-only view | IReadOnlyList<string> view = names.AsReadOnly(); | |
| Lookup table built once | FrozenDictionary<string, int> codes = ages.ToFrozenDictionary(); | System.Collections.Frozen. Slower to build, faster to read |
| Any number of arguments | static int Sum(params int[] nums) => nums.Sum(); | Call it as Sum(1, 2, 3) |
| params without an array | static int Sum(params ReadOnlySpan<int> nums) | C# 13. Also IEnumerable<T> and List<T> |
Adding or removing items while a foreach loops over the same list throws InvalidOperationException: Collection was modified. Use RemoveAll, or loop over a copy with foreach (var x in list.ToList()).
LINQ
LINQ is a set of methods on anything you can loop over (IEnumerable<T>): filter, transform, sort, group and add up, chained one after another. A query does not run when you write it, only when you loop over it or call something like ToList, Count or First. It lives in System.Linq, which every project imports for you.
| Task | Code | Notes |
|---|---|---|
| Keep matches | nums.Where(n => n > 10) | |
| Transform each one | names.Select(n => n.ToUpper()) | |
| With the index too | names.Select((n, i) => $"{i}: {n}") | |
| Run it into a list or array | .ToList() .ToArray() | Nothing runs until you do this or loop over it |
| Into a dictionary | people.ToDictionary(p => p.Id) | Throws ArgumentException on a duplicate key |
| Into a set | .ToHashSet() | |
| Sort | people.OrderBy(p => p.Age).ThenBy(p => p.Name) | Stable, so equal items keep their order |
| Largest first | people.OrderByDescending(p => p.Age) | |
| Sort plain values | nums.Order() nums.OrderDescending() | |
| First match | .First(n => n > 10) .FirstOrDefault(n => n > 10) | First throws InvalidOperationException if there is none. FirstOrDefault gives 0 or null |
| Exactly one match | .Single(p => p.Id == id) | Throws if there are none or several |
| Last match | .Last() .LastOrDefault() | |
| Any, all, how many | .Any() .Any(n => n < 0) .All(n => n > 0) .Count(n => n > 0) | |
| Add up | people.Sum(p => p.Age) people.Average(p => p.Age) | Average throws on an empty sequence |
| Smallest and largest value | people.Min(p => p.Age) people.Max(p => p.Age) | |
| The object with the largest value | people.MaxBy(p => p.Age) people.MinBy(p => p.Age) | The whole person, not just the age |
| Group | people.GroupBy(p => p.City) | Each group has a Key and is itself a sequence |
| Count each value | words.CountBy(w => w) | .NET 9. Pairs of key and count |
| Drop duplicates | .Distinct() .DistinctBy(p => p.Email) | |
| Page through | .Skip(20).Take(10) | |
| Take while a condition holds | .TakeWhile(n => n < 100) .SkipWhile(n => n < 100) | |
| Batches of three | .Chunk(3) | Each batch is an array |
| Flatten lists of lists | orders.SelectMany(o => o.Items) | |
| Inner join | customers.Join(orders, c => c.Id, o => o.CustomerId, (c, o) => new { c.Name, o.Total }) | Customers with no orders are left out |
| Left join | customers.LeftJoin(orders, c => c.Id, o => o.CustomerId, (c, o) => new { c.Name, Total = o?.Total }) | .NET 10. o is null for a customer with no orders |
| Pair up two sequences | names.Zip(ages) | Tuples of (First, Second). Stops at the shorter one |
| Loop with the index | foreach (var (i, name) in names.Index()) { } | .NET 9 |
| Combine into one value | .Aggregate((a, b) => a * b) | |
| A range of numbers | Enumerable.Range(1, 10) | Start and count, so 1 to 10 |
| Set operations | a.Union(b) a.Intersect(b) a.Except(b) | |
| Query syntax | from p in people where p.Age > 30 orderby p.Name select p.Name | Compiles to the same method calls |
Because a query runs every time you loop over it, a query over a database or a slow method does the work again on each loop. Call ToList() once if you need the results more than once.
Control flow
| Task | Code | Notes |
|---|---|---|
| if, else if, else | if (n > 0) { } else if (n < 0) { } else { } | The condition must be a bool |
| Pick one of two values | string label = n % 2 == 0 ? "even" : "odd"; | |
| switch that gives a value | string size = n switch { < 10 => "small", < 100 => "medium", _ => "large" }; | A switch expression. _ matches anything left |
| switch statement | switch (day) { case DayOfWeek.Saturday: case DayOfWeek.Sunday: Rest(); break; default: Work(); break; } | No fall-through: a case with code must end in break, return or throw |
| Counting loop | for (int i = 0; i < n; i++) { } | |
| Loop over anything | foreach (var name in names) { } | |
| Count down | for (int i = n - 1; i >= 0; i--) { } | |
| while and do-while | while (queue.Count > 0) { } do { } while (Retry()); | do-while always runs at least once |
| Skip to the next round, or stop | continue; break; | |
| Leave nested loops | return; | No labelled break. Move the loops into a method, or use goto |
| Produce values one at a time | IEnumerable<int> Evens() { for (int i = 0; ; i += 2) yield return i; } | An iterator. Runs only as far as the caller reads |
| Helper function inside a method | int Square(int x) => x * x; | A local function. Can be declared after it is used |
Pattern matching
A pattern tests a value's type, shape or contents, and can name the parts it matched. Patterns work after is, in switch expressions and in case labels.
| Task | Code | Notes |
|---|---|---|
| Check a type and use it | if (obj is string s && s.Length > 0) { } | s only exists where the check passed |
| One of several values | if (day is DayOfWeek.Saturday or DayOfWeek.Sunday) { } | |
| A range | if (age is >= 13 and <= 19) { } | |
| Not something | if (input is not (null or "")) { } | |
| Match on properties | if (order is { Status: "paid", Total: > 100 }) { } | |
| Nested properties | if (person is { Address.City: "London" }) { } | |
| Not null, and name it | if (user is { } u) { } | { } matches anything that is not null |
| switch on the type | string desc = shape switch { Circle c => $"circle {c.Radius}", Square { Side: 0 } => "empty", Square s => $"square {s.Side}", null => "none", _ => "other" }; | Arms are tried from the top |
| Add a condition to an arm | n switch { int x when x % 2 == 0 => "even", _ => "odd" } | |
| Take a record apart | point switch { (0, 0) => "origin", (var x, 0) => $"on the x axis at {x}", _ => "elsewhere" } | Positional pattern. Works on records and anything with Deconstruct |
| Match two values at once | (a, b) switch { (true, true) => "both", (true, false) or (false, true) => "one", _ => "neither" } | A tuple pattern |
| Match an array or list | if (args is [var first, .. var rest]) { } | A list pattern. rest gets everything after the first |
| Check the ends | if (parts is ["cd", var dir]) { } if (nums is [1, .., 9]) { } | .. matches any number of items in between |
| In a case label | case int n when n > 0: |
C# has no sealed type hierarchies, so a switch over a base type can never be proved complete. The compiler warns with CS8509 until you add a _ arm, and a value that matches nothing throws SwitchExpressionException.
Classes, records and structs
| Task | Code | Notes |
|---|---|---|
| Define a class | public class Account { private decimal _balance; } | One file can hold several types, named anything |
| Constructor | public Account(string owner) { Owner = owner; } | No return type |
| Constructor in the class line | public class Account(string owner) { public string Owner { get; } = owner; } | A primary constructor. owner is in scope for the whole class |
| One constructor calling another | public Account() : this("anonymous") { } | |
| Create an object | var a = new Account("Ada"); Account b = new("Bob"); | |
| Set properties as you create it | var p = new Person { Name = "Ada", Age = 36 }; | An object initialiser |
| Method | public void Deposit(decimal amount) { _balance += amount; } | |
| One-expression method | public decimal Doubled() => _balance * 2; | |
| Static class and method | public static class MathUtil { public static int Add(int a, int b) => a + b; } | Called as MathUtil.Add(1, 2) |
| Who can see it | public private protected internal | Members default to private, types to internal (this project only) |
| Inherit | public class Savings : Account | One base class, any number of interfaces, after the same colon |
| Call the base constructor | public Savings(string owner) : base(owner) { } | |
| Let subclasses replace a method | public virtual decimal Fee() => 1m; | Methods are not virtual unless you say so, unlike Java |
| Replace it | public override decimal Fee() => 0m; | |
| Call the base version | base.ToString() | |
| Class that must be inherited | public abstract class Shape { public abstract double Area(); } | Cannot be created with new |
| Stop inheritance | public sealed class Money { } | |
| Define an interface | public interface IShape { double Area(); } | Names start with I by convention |
| Implement it | public class Circle(double r) : IShape { public double Area() => Math.PI * r * r; } | |
| Data type in one line | public record Point(int X, int Y); | Constructor, init-only properties, equality by value, ToString and Deconstruct |
| Copy with one change | var moved = p with { Y = p.Y + 1 }; | Records only. The original is unchanged |
| Record that is a value type | public readonly record struct Money(decimal Amount, string Currency); | |
| Struct | public struct Vector { public double X; public double Y; } | A value type: copied on assignment, never null |
| Fixed set of values | public enum Size { Small, Medium, Large } | |
| Enum helpers | Enum.Parse<Size>("Small") Enum.GetValues<Size>() size.ToString() | |
| Flags that combine | [Flags] public enum Perm { Read = 1, Write = 2, Run = 4 } | Perm.Read | Perm.Write, then p.HasFlag(Perm.Write) |
| Add a method to a type you do not own | public static class StringExtensions { public static bool IsBlank(this string s) => string.IsNullOrWhiteSpace(s); } | An extension method. Called as name.IsBlank() |
| Add a property to a type you do not own | public static class StringExtensions { extension(string s) { public bool IsBlank => string.IsNullOrWhiteSpace(s); } } | C# 14. Extension members. Called as name.IsBlank |
| Equality by value on a class | public override bool Equals(object? obj) => obj is Point p && X == p.X && Y == p.Y; | |
| Hash to match | public override int GetHashCode() => HashCode.Combine(X, Y); | Override both or neither. A record does both for you |
| Custom text for printing | public override string ToString() => $"({X}, {Y})"; | |
| Program entry point | Console.WriteLine("Hello"); | The whole of Program.cs. Top-level statements, no class or Main |
| Entry point, long form | static void Main(string[] args) { } | Or static async Task Main to use await |
Class or record? A record is for data: it compares by value, so two records with the same fields are equal, and with makes a changed copy. A class compares by reference and suits objects whose state changes. A struct is for small values, a few fields at most, that you want copied rather than shared.
Properties
A property looks like a field from outside but runs code on get and set, so a class can add validation later without breaking its callers. Public data goes in properties, not public fields.
| Task | Code | Notes |
|---|---|---|
| Read and write | public string Name { get; set; } | An auto-property. The compiler writes the hidden field |
| With a starting value | public int Retries { get; set; } = 3; | |
| Read-only | public string Id { get; } | Set only in a constructor or initialiser |
| Set only while creating | public string Name { get; init; } | Works in an object initialiser, then fixed |
| Must be set by the caller | public required string Email { get; init; } | new User() without Email = ... does not compile |
| Public read, private write | public decimal Balance { get; private set; } | |
| Worked out on each read | public string FullName => $"{First} {Last}"; | No setter, no stored value |
| Run code on set | public string Name { get; set => field = value.Trim(); } | C# 14. field is the hidden backing field |
| Your own backing field | private int _age; public int Age { get => _age; set => _age = value >= 0 ? value : throw new ArgumentOutOfRangeException(nameof(value)); } | value is whatever the caller assigned |
| Shared by every object | public static int Count { get; private set; } | |
| In an interface | string Name { get; } | Implementations may add a setter |
| Index like an array | public string this[int i] => _items[i]; | An indexer. Called as thing[0] |
Exceptions
C# has no checked exceptions: nothing forces a caller to catch or declare what a method throws. Catch an exception where there is something useful to do about it, and let the rest travel up.
| Task | Code | Notes |
|---|---|---|
| Catch | try { Risky(); } catch (IOException ex) { Console.Error.WriteLine(ex.Message); } | |
| Catch specific types first | catch (FileNotFoundException) { } catch (IOException) { } | The compiler rejects them in the wrong order |
| Catch only when a condition holds | catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { } | An exception filter. Anything else keeps travelling |
| Always run | finally { Cleanup(); } | Runs whether the try succeeded, threw or returned |
| Dispose when the scope ends | using var reader = new StreamReader(path); | A using declaration. Closed however the method ends |
| Dispose at the end of a block | using (var reader = new StreamReader(path)) { } | |
| Throw | throw new ArgumentException("must be positive", nameof(age)); | |
| Throw inside an expression | _name = name ?? throw new ArgumentNullException(nameof(name)); | |
| Rethrow | catch (Exception ex) { Log(ex); throw; } | throw; keeps the stack trace. throw ex; resets it |
| Wrap one and keep the cause | throw new InvalidOperationException("could not load config", ex); | The original is in InnerException |
| Your own exception | public class OutOfStockException(string message) : Exception(message); | |
| Guard clauses | ArgumentOutOfRangeException.ThrowIfNegative(age); ArgumentException.ThrowIfNullOrWhiteSpace(name); | The message names the argument for you |
| Print the details | Console.Error.WriteLine(ex); | ToString() includes the type, message, stack trace and inner exceptions |
| Common ones to throw | ArgumentException ArgumentNullException InvalidOperationException NotSupportedException | Bad input, bad state, and not implemented here |
Lambdas and delegates
A delegate is a type that holds a method, and a lambda is a method written inline. Func and Action cover almost every case: Func returns a value (its last type argument), Action returns nothing.
| Task | Code | Notes |
|---|---|---|
| Takes a value, returns a value | Func<int, int> twice = x => x * 2; | |
| Several arguments | Func<int, int, int> add = (a, b) => a + b; | |
| Returns nothing | Action<string> greet = name => Console.WriteLine($"hi {name}"); | |
| Takes nothing | Func<DateTime> now = () => DateTime.UtcNow; | |
| Returns a bool | Predicate<int> isEven = n => n % 2 == 0; | Or Func<int, bool>, which LINQ uses |
| Several statements | Func<string, int> score = s => { int n = s.Length; return n * n; }; | |
| Let the compiler pick the type | var add = (int a, int b) => a + b; | Gives a Func<int, int, int> |
| Default argument | var greet = (string name = "you") => $"hi {name}"; | |
| Call it | twice(5) greet("Ada") | |
| Point at an existing method | Func<string, int> parse = int.Parse; | A method group. No brackets |
| Pass one to a method | static int Apply(Func<int, int> f, int x) => f(x); | |
| Use a local variable inside | int limit = 10; Func<int, bool> small = x => x < limit; | Captures the variable itself, so later changes are seen |
| Stop it capturing anything | Func<int, int> sq = static x => x * x; | A compile error if it touches a local |
| Declare an event | public event EventHandler? Changed; | |
| Raise it | Changed?.Invoke(this, EventArgs.Empty); | ?. because it is null with no subscribers |
| Subscribe and unsubscribe | button.Changed += OnChanged; button.Changed -= OnChanged; |
async and await
An async method returns a Task (or Task<T> for a result) straight away and finishes the work later. await waits for a Task without blocking the thread, so a web server or UI can do other work in the meantime. The convention is to end async method names in Async.
| Task | Code | Notes |
|---|---|---|
| Async method with a result | async Task<string> LoadAsync(string path) => await File.ReadAllTextAsync(path); | |
| Async method, no result | async Task SaveAsync(string path, string text) { await File.WriteAllTextAsync(path, text); } | Task, not void |
| Call it | string text = await LoadAsync("notes.txt"); | await works in top-level code too |
| Start several, wait for all | string[] pages = await Task.WhenAll(urls.Select(u => http.GetStringAsync(u))); | Runs them at the same time. Results come back in order |
| Wait for the first to finish | Task<string> first = await Task.WhenAny(a, b); | |
| Handle each as it finishes | await foreach (var task in Task.WhenEach(tasks)) { Use(await task); } | .NET 9 |
| Pause | await Task.Delay(TimeSpan.FromSeconds(1)); | |
| Give up after a time limit | await task.WaitAsync(TimeSpan.FromSeconds(5)); | Throws TimeoutException |
| Make something cancellable | async Task RunAsync(CancellationToken ct) { await Task.Delay(1000, ct); } | Pass the token to everything you await |
| Cancel after a time | using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await RunAsync(cts.Token); | Throws OperationCanceledException |
| Check for cancellation in a loop | ct.ThrowIfCancellationRequested(); | |
| Heavy CPU work off this thread | int result = await Task.Run(() => Crunch(data)); | |
| Loop in parallel, a few at a time | await Parallel.ForEachAsync(urls, new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (url, ct) => await FetchAsync(url, ct)); | |
| Return values one by one, async | async IAsyncEnumerable<int> TicksAsync() { for (int i = 0; i < 3; i++) { await Task.Delay(100); yield return i; } } | |
| Loop over them | await foreach (int tick in TicksAsync()) { } | |
| Already have the result | return Task.FromResult(42); return Task.CompletedTask; | From a method that is not marked async |
| Lock a block of code | private readonly Lock _gate = new(); lock (_gate) { } | .NET 9 and C# 13. An object instead of Lock before that. No await inside |
| Limit how many run at once | var gate = new SemaphoreSlim(3); await gate.WaitAsync(); try { } finally { gate.Release(); } |
Never block on a Task with .Result or .Wait(): it ties up a thread and can deadlock in apps with a UI or a synchronisation context. Await it instead. And avoid async void everywhere except event handlers, because nothing can await it or catch what it throws.
Files, dates, JSON and other everyday APIs
| Task | Code | Notes |
|---|---|---|
Console.WriteLine($"total: {total}"); Console.Write("no newline"); | ||
| Print an error | Console.Error.WriteLine("failed"); | |
| Read a line from the keyboard | string? line = Console.ReadLine(); | null when the input has ended |
| Read a whole file | string text = File.ReadAllText("notes.txt"); | System.IO. ReadAllTextAsync to await it |
| Read the lines | string[] lines = File.ReadAllLines(path); | |
| Stream a big file line by line | foreach (string line in File.ReadLines(path)) { } | Reads as you go, unlike ReadAllLines |
| Write a file | File.WriteAllText(path, text); | Replaces it. AppendAllText adds to the end |
| Check and create | File.Exists(path) Directory.CreateDirectory(dir) | CreateDirectory is fine if it already exists |
| List a folder | Directory.GetFiles(dir, "*.cs", SearchOption.AllDirectories) | |
| Build a path | Path.Combine("data", "users.json") | Right separator for the platform |
| Parts of a path | Path.GetFileName(path) Path.GetExtension(path) Path.GetDirectoryName(path) | |
| Now | DateTime.UtcNow DateTimeOffset.Now | UtcNow for timestamps. DateTimeOffset keeps the offset from UTC |
| A date with no time | var day = new DateOnly(2026, 9, 27); DateOnly.FromDateTime(DateTime.Today) | Months are 1 to 12. TimeOnly for a time with no date |
| Date arithmetic | day.AddDays(30) DateTime.UtcNow.AddHours(-1) | Returns a new value |
| Time between two dates | (end - start).TotalDays | Subtracting DateTimes gives a TimeSpan |
| A length of time | TimeSpan.FromMinutes(90).TotalHours | 1.5 |
| Parse and format a date | DateOnly.Parse("2026-09-27") day.ToString("dd/MM/yyyy") | |
| Object to JSON | string json = JsonSerializer.Serialize(person); | System.Text.Json |
| JSON to an object | Person? p = JsonSerializer.Deserialize<Person>(json); | |
| camelCase JSON, as web APIs use | JsonSerializer.Serialize(person, JsonSerializerOptions.Web) | .NET 9 |
| HTTP GET | string body = await http.GetStringAsync(url); | Make one HttpClient and reuse it, never one per request |
| HTTP GET, straight into a type | User? user = await http.GetFromJsonAsync<User>(url); | System.Net.Http.Json |
| Random number | int roll = Random.Shared.Next(1, 7); | 1 to 6. The upper bound is excluded |
| Shuffle | Random.Shared.Shuffle(array); | Shuffles an array in place. On .NET 10, LINQ's .Shuffle() returns a shuffled copy of any sequence |
| Maths | Math.Max(a, b) Math.Abs(x) Math.Pow(2, 10) Math.Sqrt(x) | |
| Round | Math.Round(2.5) Math.Round(2.5, MidpointRounding.AwayFromZero) | 2 and 3. The default rounds halves to the even number |
| Keep a number within bounds | Math.Clamp(value, 0, 100) | |
| Test with a regex | Regex.IsMatch(email, @"^\S+@\S+$") | System.Text.RegularExpressions. A verbatim string saves escaping |
| Capture groups | Match m = Regex.Match(email, @"(\w+)@(\w+)"); if (m.Success) { string user = m.Groups[1].Value; } | |
| Replace with a regex | Regex.Replace(s, @"\s+", " ") | |
| Unique ID | Guid.NewGuid() Guid.CreateVersion7() | Version 7 sorts by creation time. .NET 9 |
| Environment variable | Environment.GetEnvironmentVariable("HOME") | null if it is not set |
| Time something | var sw = Stopwatch.StartNew(); sw.Elapsed.TotalMilliseconds | System.Diagnostics |
| Thread-safe dictionary and counter | new ConcurrentDictionary<string, int>() Interlocked.Increment(ref count) | System.Collections.Concurrent and System.Threading |
Counting words, start to finish
Splitting a string, counting with LINQ, and sorting by two keys in one
program. It prints the three most common words, ties broken alphabetically.
Save it as words.cs and run it with dotnet run words.cs: on .NET 10 a
single file needs no project.
string text = "the cat sat on the mat and the cat slept";
var top = text
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.CountBy(word => word) // (Key, Value) pairs
.OrderByDescending(pair => pair.Value) // most frequent first
.ThenBy(pair => pair.Key) // then A to Z
.Take(3);
foreach (var (word, count) in top)
{
Console.WriteLine($"{word,-5} {count}");
}
// the 3
// cat 2
// and 1CountBy arrived in .NET 9. On .NET 8, use
.GroupBy(word => word).Select(g => KeyValuePair.Create(g.Key, g.Count())) in
its place.
OrderBy is a stable sort, so items that compare equal keep their original
order. List<T>.Sort and Array.Sort are not stable: they use introsort, which
starts as a quick sort, switches
to heap sort if the recursion goes
too deep, and finishes small ranges with
insertion sort. All three
are on the site as step-through visualisations.
Records and pattern matching
Records supply the constructors, equality and ToString, and a switch
expression takes each shape apart. C# cannot prove a switch over a base type is
complete, so the _ arm is needed: leave it out and the compiler warns with
CS8509.
Shape[] shapes = [new Circle(1), new Rectangle(3, 4), new Square(2)];
foreach (var shape in shapes)
{
Console.WriteLine($"{shape,-35} {Area(shape),6:F2}");
}
Console.WriteLine($"{"total",-35} {shapes.Sum(Area),6:F2}");
// Circle { Radius = 1 } 3.14
// Rectangle { Width = 3, Height = 4 } 12.00
// Square { Side = 2 } 4.00
// total 19.14
Console.WriteLine(new Square(2) == new Square(2)); // True: equal by value
Console.WriteLine(new Rectangle(3, 4) with { Height = 5 });
// Rectangle { Width = 3, Height = 5 }
static double Area(Shape shape) => shape switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle(var w, var h) => w * h,
Square { Side: var side } => side * side,
_ => throw new ArgumentOutOfRangeException(nameof(shape)),
};
abstract record Shape;
record Circle(double Radius) : Shape;
record Square(double Side) : Shape;
record Rectangle(double Width, double Height) : Shape;The three arms use three kinds of pattern: Circle c matches the type and names
it, Rectangle(var w, var h) takes the record apart by position, and
Square { Side: var side } reads a property by name. Top-level statements come
first in the file, and the types they use go after them.
Reading a file, with async, exceptions and nullable references
await reads the file without blocking, a when filter catches only the
failure there is a sensible fallback for, and nullable references make the
"not a setting" case a null that the compiler forces you to check.
const string path = "app.settings";
await File.WriteAllTextAsync(path, """
# sample settings
port = 8080
host=localhost
not a setting
""");
List<Setting> settings;
try
{
settings = await LoadAsync(path);
}
catch (IOException ex) when (ex is FileNotFoundException or DirectoryNotFoundException)
{
Console.Error.WriteLine($"No config at {path}, using defaults");
settings = [];
}
int port = settings
.Where(s => s.Key == "port")
.Select(s => int.Parse(s.Value))
.FirstOrDefault(80);
Console.WriteLine($"{settings.Count} settings, port {port}");
// 2 settings, port 8080
static async Task<List<Setting>> LoadAsync(string path, CancellationToken ct = default)
{
string[] lines = await File.ReadAllLinesAsync(path, ct);
List<Setting> settings = [];
foreach (string line in lines)
{
// Parse returns Setting?, so the compiler will not let you
// use the result until you have checked it for null.
if (Setting.Parse(line) is { } setting)
{
settings.Add(setting);
}
}
return settings;
}
record Setting(string Key, string Value)
{
// "port = 8080" becomes a Setting. Comments, blanks and junk become null.
public static Setting? Parse(string line)
{
int eq = line.IndexOf('=');
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#') || eq < 0)
{
return null;
}
return new Setting(line[..eq].Trim(), line[(eq + 1)..].Trim());
}
}FileNotFoundException and DirectoryNotFoundException both inherit from
IOException, so the filter handles the one failure that has a fallback and lets
every other I/O error carry on up. The raw string (the """ block) keeps its
line breaks, and the indentation to the left of the closing quotes is removed.
Properties, required members and init
A class with every kind of property in one place: a required one the caller
must set, an init one fixed after creation, one that trims its input with the
C# 14 field keyword, one with a private setter, and one worked out on each
read.
var ada = new User { Email = " ada@example.com ", Name = "Ada" };
ada.Deposit(25m);
ada.Deposit(17.5m);
Console.WriteLine($"{ada.Name} <{ada.Email}> has {ada.Balance:F2}, {ada.Status}");
// Ada <ada@example.com> has 42.50, active
// ada.Email = "x"; error CS8852: init-only, fixed after creation
// ada.Balance = 100m; error CS0272: the setter is private
// new User(); error CS9035: Email is required
class User
{
public required string Email { get; init => field = value.Trim(); }
public string Name { get; init; } = "anonymous";
public decimal Balance { get; private set; }
public string Status => Balance > 0 ? "active" : "empty";
public void Deposit(decimal amount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
Balance += amount;
}
}The field keyword is new in C# 14. Before it, trimming on set meant declaring
a private backing field yourself and writing both the get and the init by
hand.
Gotchas
The mistakes that turn up in almost every C# codebase at some point.
| Looks right | What actually happens | Do this instead |
|---|---|---|
Math.Round(2.5) | 2. Halves round to the even number by default | Math.Round(2.5, MidpointRounding.AwayFromZero) |
0.1 + 0.2 == 0.3 | False: double is binary floating point | decimal for money: 0.1m + 0.2m == 0.3m |
int avg = total / count; then cast it | The division already happened in whole numbers | (double)total / count |
list.First(x => ...) when nothing matches | InvalidOperationException: Sequence contains no matching element | FirstOrDefault, then check for null or 0 |
dict[key] for a missing key | KeyNotFoundException | TryGetValue or GetValueOrDefault |
list.Remove(x) inside foreach (var x in list) | InvalidOperationException: Collection was modified | list.RemoveAll(...) |
| Looping over the same LINQ query twice | The whole query runs twice, including any database call | .ToList() once, then reuse the list |
for (int i = 0; i < 3; i++) actions.Add(() => Console.Write(i)); | Prints 333: every lambda shares the one i | Copy it first: int copy = i;, or use foreach |
task.Result or task.Wait() | Blocks the thread, and can deadlock in a UI app | await task |
async void DoWorkAsync() | Callers cannot await it, and its exceptions crash the process | async Task DoWorkAsync() |
catch (Exception ex) { throw ex; } | The stack trace now starts here, not where it failed | throw; |
name!.Length | ! only silences the compiler, so a null still throws NullReferenceException | Check it: name?.Length ?? 0 |
points[0].X = 5; on a List<Point> of structs | Error CS1612: the indexer returns a copy | Replace the whole item, or make it a class |
new HttpClient() for every request | Runs out of sockets under load | One shared instance, or IHttpClientFactory in ASP.NET Core |
DateTime.Now in stored timestamps | Local time, which moves with the clocks | DateTime.UtcNow or DateTimeOffset.UtcNow |
s += word; in a long loop | Copies the whole string every time round | StringBuilder or string.Join |
Common questions
Which version of C# does this cheat sheet cover?
C# 14 on .NET 10, the current long-term support (LTS) release, checked with the .NET 10 SDK. Many projects are still on .NET 8, the previous LTS, which ships C# 12, so that is the baseline here: anything that needs something newer, such as the field keyword or extension members (C# 14), params spans (C# 13), CountBy and Task.WhenEach (.NET 9) or LeftJoin and dotnet run app.cs (.NET 10), says so in the notes column.
What is the difference between C# and .NET?
C# is the language. .NET is the runtime that runs compiled C#, plus the standard library (collections, files, HTTP, JSON) and the dotnet command-line tools. Each .NET release ships a C# version with it: .NET 8 has C# 12, .NET 9 has C# 13 and .NET 10 has C# 14. F# and Visual Basic run on .NET too. The old Windows-only .NET Framework stopped at version 4.8; new projects use the cross-platform .NET, which runs on Windows, macOS and Linux.
What is the difference between a class, a struct and a record in C#?
A class is a reference type: assigning it copies the reference, so two variables can point at the same object, and == compares identity. A struct is a value type: assigning it copies the whole value, and it can never be null, which suits small things like a point or an amount of money. A record is a class (or, as a record struct, a struct) that the compiler fills in for data: equality by value, a readable ToString, deconstruction and with for making changed copies.
What does the question mark after a type mean in C#?
On a value type, int? is a nullable int, a real type that can hold a number or null. On a reference type, string? is an annotation for the compiler: with nullable references turned on it says this variable may be null, and the compiler warns you if you use it without checking. Without the ?, a string is promised not to be null and the compiler warns if you assign null to it. The annotation does nothing at run time, so a null from outside code can still get through.
What is LINQ in C#?
LINQ (Language Integrated Query) is a set of methods like Where, Select, OrderBy and GroupBy that work on any collection, and on databases through libraries such as Entity Framework Core. You chain them to filter, transform, sort and group data without writing loops. A LINQ query is lazy: it runs when you loop over it or call ToList, Count, First and similar, and it runs again each time you do.
Why should I avoid async void and .Result?
An async void method cannot be awaited, so the caller cannot tell when it has finished, and an exception inside it cannot be caught by the caller and usually crashes the process. Return Task instead; async void is only for event handlers. Calling .Result or .Wait() on a Task blocks the thread until the task finishes, which wastes a thread on a server and can deadlock in a UI app. Use await all the way up instead.
Can I run a C# file without creating a project?
Yes, on .NET 10. Put your code in a file such as app.cs and run dotnet run app.cs. The file holds plain top-level statements with no class or Main method, and it can pull in NuGet packages with a #:package line at the top. When it grows, dotnet project convert app.cs turns it into a normal project. On older versions you need dotnet new console first.
How is C# different from Java?
They look alike and solve similar problems, but differ in the details. C# has properties instead of getters and setters, value types you can define yourself (structs), LINQ, async and await built into the language, and no checked exceptions. Methods are not virtual unless marked virtual, and == compares strings by their text. Java has sealed hierarchies that make a switch provably complete, and its generics are erased at run time where C#'s are not.
