The base class library

C# itself is a small language. Almost everything you do with it, from printing a line to calling a web API, comes from the base class library (BCL) that ships with .NET. It is organized in namespaces, and you either write a using line for a namespace or the full name of a type. A file-based app and a new console project already import the most common namespaces through implicit usings: System, System.Collections.Generic, System.IO, System.Linq, System.Net.Http, System.Threading and System.Threading.Tasks. Everything else on this page needs its own using.

Namespace What you find there Implicit?
System Basic types, Console, Math, DateTime, DateTimeOffset, DateOnly, TimeSpan, Environment, Convert yes
System.IO File, Directory, Path, FileInfo, streams, readers and writers yes
System.Text.Json JsonSerializer, JsonDocument; the sub-namespace System.Text.Json.Nodes has JsonNode no
System.Text StringBuilder, Encoding no
System.Globalization CultureInfo for number and date formats per language and country no
System.Text.RegularExpressions Regex no
System.Diagnostics Stopwatch, Process, Debug, activity tracing no
System.Security.Cryptography Hashing (SHA256), secure random numbers (RandomNumberGenerator), encryption no
System.Net.Http HttpClient (see Asynchronous Programming) yes

The API reference on Microsoft Learn lists every type and member, usually with an example. When you need something that is not there, such as a database driver or a PDF library, it comes as a NuGet package (see Projects, NuGet and Building).

System.IO: paths, files and folders

System.IO is the .NET namespace for working with the file system and with streams of bytes. Its three static classes cover most daily work: Path for building and taking apart path strings, File for whole files, and Directory for folders. FileInfo and DirectoryInfo offer the same operations as objects that describe one file or folder.

Building paths with Path

A path is just a string, but do not glue pieces together with + and slashes yourself. Path.Combine uses the right separator for the operating system (/ on Linux and macOS, \ on Windows), and the other Path methods take a path apart without string surgery:

string folder = Path.Combine("data", "meters", "2026-10");
string file = Path.Combine(folder, "KE-1042.csv");

Console.WriteLine(file);
Console.WriteLine(Path.GetFileName(file));
Console.WriteLine(Path.GetFileNameWithoutExtension(file));
Console.WriteLine(Path.GetExtension(file));
Console.WriteLine(Path.GetDirectoryName(file));
Console.WriteLine(Path.ChangeExtension(file, ".json"));
Console.WriteLine(Path.IsPathRooted(file));
data/meters/2026-10/KE-1042.csv
KE-1042.csv
KE-1042
.csv
data/meters/2026-10
data/meters/2026-10/KE-1042.json
False

The output above comes from Linux; on Windows the same code prints data\meters\2026-10\KE-1042.csv. A path that does not start at the root is relative: it is resolved against the current directory, which is wherever the program was started, not where the .exe or .cs file lives. For files that belong to your application, build the path from AppContext.BaseDirectory (the folder of the compiled program); for temporary files use Path.GetTempPath().

Folders with Directory

Directory.CreateDirectory creates a folder including any missing parent folders, and does nothing if it already exists. Directory.GetFiles lists files, optionally filtered by a pattern such as *.csv. The order in which the operating system returns files is not guaranteed, so sort them when order matters:

string month = Path.Combine("exports", "2026-10");
Directory.CreateDirectory(month);
foreach (string name in new[] { "KE-1043.csv", "KE-1042.csv", "notes.txt" })
{
    File.WriteAllText(Path.Combine(month, name), "meter,kwh\n");
}

string[] csvFiles = Directory.GetFiles(month, "*.csv");
Array.Sort(csvFiles, StringComparer.Ordinal);
foreach (string f in csvFiles)
{
    Console.WriteLine(f);
}

var exports = new DirectoryInfo("exports");
foreach (FileInfo fi in exports.EnumerateFiles("*", SearchOption.AllDirectories)
                               .OrderBy(f => f.Name))
{
    Console.WriteLine($"{fi.Name,-12} {fi.Length,3} bytes");
}

Directory.Delete("exports", recursive: true);
Console.WriteLine($"Still there: {Directory.Exists("exports")}");
exports/2026-10/KE-1042.csv
exports/2026-10/KE-1043.csv
KE-1042.csv   10 bytes
KE-1043.csv   10 bytes
notes.txt     10 bytes
Still there: False

GetFiles builds the whole array at once; EnumerateFiles returns the entries one by one, which is better for folders with many thousands of files. Be careful with Directory.Delete(path, recursive: true): like rm -r, it deletes everything below the folder without asking and without a recycle bin.

Reading and writing files

The File class is a static class in System.IO with one-call methods that open a file, read or write it completely, and close it again. For files that fit comfortably in memory, such as configuration, a CSV export or a report, these are all you need:

Method What it does
File.ReadAllText(path) the whole file as one string
File.ReadAllLines(path) a string[] with one element per line
File.ReadLines(path) the lines one at a time, as they are read (lazy); for large files
File.WriteAllText(path, text) creates or overwrites the file
File.WriteAllLines(path, lines) writes each element as a line
File.AppendAllText(path, text) adds to the end; creates the file if needed
File.Exists, File.Copy, File.Move, File.Delete check, copy, move or rename, delete
string path = "readings.csv";
File.WriteAllLines(path, ["meter,kwh", "KE-1042,412", "KE-1043,398"]);
File.AppendAllText(path, "KE-1044,455\n");

string[] lines = File.ReadAllLines(path);
Console.WriteLine($"{lines.Length} lines, header: {lines[0]}");

int total = 0;
foreach (string line in File.ReadLines(path).Skip(1))
{
    string[] parts = line.Split(',');
    total += int.Parse(parts[1]);
}
Console.WriteLine($"Total: {total} kWh");
Console.WriteLine($"Size: {new FileInfo(path).Length} bytes");

File.Delete(path);
Console.WriteLine($"Exists after delete: {File.Exists(path)}");
4 lines, header: meter,kwh
Total: 1265 kWh
Size: 46 bytes
Exists after delete: False

File.ReadLines combines well with LINQ: it reads the next line only when the query asks for it, so .Skip(1) skips the header and a ten-gigabyte log can be filtered without loading it into memory. Real CSV files have quoted fields with commas inside; for those, use a CSV library from NuGet rather than Split(',').

Text encoding

Text methods in .NET read and write UTF-8 by default, and they write it without a byte order mark. When reading, a byte order mark is recognized and skipped. Characters outside ASCII take more than one byte, which is why the byte count can differ from string.Length:

string path = "tariff.txt";
await File.WriteAllTextAsync(path, "Tarief: € 0,27 per kWh");

string text = await File.ReadAllTextAsync(path);
byte[] bytes = await File.ReadAllBytesAsync(path);
Console.WriteLine(text);
Console.WriteLine($"{text.Length} characters, {bytes.Length} bytes");

File.Delete(path);
Tarief: € 0,27 per kWh
22 characters, 24 bytes

The euro sign is one character but three bytes in UTF-8. This example also uses the Async variants that most File methods have; in a web server or desktop app, prefer them so that no thread waits for the disk (see Asynchronous Programming with async and await). To read an old file in another encoding, pass an Encoding object as an extra argument.

When a file operation fails

File operations depend on the world outside your program: the file may be missing, locked by another program, or not readable for your account. These problems are reported as exceptions. Without a handler, the program stops:

Directory.SetCurrentDirectory("/tmp");   // as if the program was started in /tmp
Console.WriteLine($"Current directory: {Environment.CurrentDirectory}");
string text = File.ReadAllText("tariffs-2027.csv");
Console.WriteLine(text);
Current directory: /tmp
Unhandled exception. System.IO.FileNotFoundException: Could not find file '/tmp/tariffs-2027.csv'.

The message contains the full path, which tells you which folder the program was really looking in; that alone solves many "but the file is right there" puzzles: the relative name tariffs-2027.csv was looked up in the current directory, /tmp. Catch the specific exceptions you can do something about: FileNotFoundException and DirectoryNotFoundException both derive from IOException, and a permission problem is an UnauthorizedAccessException:

try
{
    string text = File.ReadAllText("tariffs-2027.csv");
    Console.WriteLine(text);
}
catch (FileNotFoundException ex)
{
    Console.WriteLine($"Not found: {Path.GetFileName(ex.FileName)}");
}
catch (IOException ex)
{
    Console.WriteLine($"Could not read the file: {ex.Message}");
}
Not found: tariffs-2027.csv

Checking File.Exists first does not replace the try: the file can disappear between the check and the read. See Exceptions and Error Handling for the general rules.

Streams, readers and writers

A stream is a .NET object (a subclass of the abstract class Stream) that gives sequential access to a sequence of bytes, whether they come from a file (FileStream), memory (MemoryStream), a network connection or a compressed archive. You read or write a chunk at a time, so the data never has to fit in memory at once. Streams work with bytes; to work with text, wrap one in a StreamReader or StreamWriter, which translate between bytes and characters:

using (var writer = new StreamWriter("outages.log"))
{
    writer.WriteLine("2026-10-03T08:00:00Z INC-31902 opened");
    writer.WriteLine("2026-10-03T08:42:00Z INC-31902 resolved");
}   // the writer is flushed and the file closed here

using var reader = new StreamReader("outages.log");
string? line;
int number = 0;
while ((line = reader.ReadLine()) != null)
{
    number++;
    Console.WriteLine($"{number}: {line}");
}
1: 2026-10-03T08:00:00Z INC-31902 opened
2: 2026-10-03T08:42:00Z INC-31902 resolved

Streams, readers and writers hold an open file handle, so they implement IDisposable and belong in a using statement or declaration (see Exceptions and Error Handling). A writer that is not disposed may never write its last buffered lines, and an open file may stay locked for other programs. ReadLine returns null at the end of the file, which is why line is declared as the nullable string? (see Nullable Reference Types).

Many APIs accept or return a Stream rather than a file name: HttpContent.ReadAsStreamAsync, compression with GZipStream, and the JSON serializer below. Because they all share the same base class, you can plug them together: read a compressed download straight into the JSON parser without ever writing a file.

JSON with System.Text.Json

JSON is the common format for web APIs and configuration files. System.Text.Json is the JSON library built into .NET: it converts .NET objects to JSON text and back, and it can also read and change JSON whose shape you do not know in advance. Older projects often use the Newtonsoft.Json package (Json.NET) instead; it is still widely used, but new code normally needs no extra package.

Serializing and deserializing

JsonSerializer is the static class in System.Text.Json whose Serialize method turns an object into a JSON string and whose Deserialize<T> method turns JSON into an object of type T (this is serialization and deserialization). Public properties become JSON properties. Records are a natural fit:

#:property PublishAot=false
using System.Text.Json;

var reading = new MeterReading("KE-1042", 412.5m, new DateOnly(2026, 10, 3));
string json = JsonSerializer.Serialize(reading);
Console.WriteLine(json);

MeterReading? back = JsonSerializer.Deserialize<MeterReading>(json);
Console.WriteLine(back);
Console.WriteLine($"Equal to the original: {back == reading}");

record MeterReading(string MeterId, decimal Kwh, DateOnly Date);
{"MeterId":"KE-1042","Kwh":412.5,"Date":"2026-10-03"}
MeterReading { MeterId = KE-1042, Kwh = 412.5, Date = 10/03/2026 }
Equal to the original: True

The first line, #:property PublishAot=false, is a directive for .NET 10 file-based apps: they are set up for Native AOT, which switches off the reflection that JsonSerializer uses by default, so without the line the program builds with warnings and then throws an InvalidOperationException. In a normal project you do not need it (see source generation for the AOT-friendly way). Deserialize returns a nullable type because the JSON text could be null. Note the JSON date is in ISO 8601 (2026-10-03), while the record's own ToString uses the current culture, here the invariant culture with the month first.

Naming and case sensitivity

By default the JSON property names are exactly the C# names, in PascalCase, and reading is case-sensitive. Most web APIs use camelCase (meterId). The ready-made JsonSerializerOptions.Web uses camelCase for writing and ignores case when reading; copy it into a new options object to change more settings, such as indentation:

#:property PublishAot=false
using System.Text.Json;

var ticket = new Ticket("INC-31902", "Portal logins fail", 2, ["portal", "login"]);
var options = new JsonSerializerOptions(JsonSerializerOptions.Web) { WriteIndented = true };
Console.WriteLine(JsonSerializer.Serialize(ticket, options));

record Ticket(string Id, string Title, int Priority, string[] Tags);
{
  "id": "INC-31902",
  "title": "Portal logins fail",
  "priority": 2,
  "tags": [
    "portal",
    "login"
  ]
}

Case sensitivity bites when you read camelCase JSON with the default options. There is no error: the properties simply do not match, and you get default values, even a null in a string that the compiler considers non-nullable:

#:property PublishAot=false
using System.Text.Json;

string json = """{"meterId":"KE-1042","kwh":412.5}""";

var strict = JsonSerializer.Deserialize<Reading>(json);
Console.WriteLine($"Default: {strict}");

var web = JsonSerializer.Deserialize<Reading>(json, JsonSerializerOptions.Web);
Console.WriteLine($"Web:     {web}");

record Reading(string MeterId, decimal Kwh);
Default: Reading { MeterId = , Kwh = 0 }
Web:     Reading { MeterId = KE-1042, Kwh = 412.5 }

Invalid JSON

Malformed JSON, or a value of the wrong type, throws a JsonException. Its message includes a JSON path ($.Kwh) and a position, which help you find the problem in a large document:

#:property PublishAot=false
using System.Text.Json;

try
{
    var r = JsonSerializer.Deserialize<Reading>("""{"MeterId":"KE-1042","Kwh":"lots"}""");
}
catch (JsonException ex)
{
    Console.WriteLine($"JsonException: {ex.Message}");
}

record Reading(string MeterId, decimal Kwh);
JsonException: The JSON value could not be converted to Reading. Path: $.Kwh | LineNumber: 0 | BytePositionInLine: 33.

JSON without a matching class

Sometimes you only need two values from a large response, or the shape varies. JsonDocument parses JSON into a read-only tree of JsonElement values, and JsonNode (in System.Text.Json.Nodes) gives a tree you can change. Neither needs reflection, so this example needs no #:property line:

using System.Text.Json;
using System.Text.Json.Nodes;

string json = """
    {
      "ticket": "INC-31902",
      "affected": { "service": "portal", "users": 1840 },
      "tags": ["login", "major"]
    }
    """;

using JsonDocument doc = JsonDocument.Parse(json);
JsonElement root = doc.RootElement;
Console.WriteLine(root.GetProperty("ticket").GetString());
Console.WriteLine(root.GetProperty("affected").GetProperty("users").GetInt32());
foreach (JsonElement tag in root.GetProperty("tags").EnumerateArray())
{
    Console.WriteLine($"tag: {tag.GetString()}");
}
Console.WriteLine($"Has resolvedAt: {root.TryGetProperty("resolvedAt", out _)}");

JsonNode node = JsonNode.Parse(json)!;
node["affected"]!["users"] = 1902;
node["status"] = "resolved";
Console.WriteLine(node.ToJsonString());
INC-31902
1840
tag: login
tag: major
Has resolvedAt: False
{"ticket":"INC-31902","affected":{"service":"portal","users":1902},"tags":["login","major"],"status":"resolved"}

GetProperty throws a KeyNotFoundException when the property is missing; use TryGetProperty for optional values. The JSON here is a raw string literal ("""…"""), which needs no escaped quotes (see Strings and String Interpolation).

Source generation

Instead of inspecting your types with reflection at run time, System.Text.Json can generate the serialization code at compile time. You declare a partial class that derives from JsonSerializerContext and list the types it must handle; the compiler writes the rest. This works with Native AOT and trimming, starts faster, and is the reason this example runs without #:property PublishAot=false:

using System.Text.Json;
using System.Text.Json.Serialization;

var reading = new MeterReading("KE-1042", 412.5m, new DateOnly(2026, 10, 3));
string json = JsonSerializer.Serialize(reading, AppJson.Default.MeterReading);
Console.WriteLine(json);

MeterReading? back = JsonSerializer.Deserialize(json, AppJson.Default.MeterReading);
Console.WriteLine(back);

record MeterReading(string MeterId, decimal Kwh, DateOnly Date);

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(MeterReading))]
partial class AppJson : JsonSerializerContext;
{"meterId":"KE-1042","kwh":412.5,"date":"2026-10-03"}
MeterReading { MeterId = KE-1042, Kwh = 412.5, Date = 10/03/2026 }

AppJson.Default.MeterReading is generated type information for MeterReading, passed instead of a type argument. The options attribute sets the naming policy once for the whole context. Native AOT itself is explained on Projects, NuGet and Building.

Safe deserialization

Deserialization creates objects from data that often comes from outside: an API response, an upload, a message queue. Treat that data as untrusted.

  • Never use BinaryFormatter. This old .NET serializer could be tricked by crafted input into creating arbitrary types and running code. Starting with .NET 9 its built-in implementation throws a PlatformNotSupportedException on every use; old tutorials that still show it will not run on current .NET. Use System.Text.Json (or XmlSerializer / DataContractSerializer for XML) instead.
  • Deserialize into specific types. Use records or classes that describe exactly what you expect, rather than settings that let the input choose which .NET type to create (such as type-name handling in Newtonsoft.Json).
  • Validate after parsing. Check ranges, lengths and required values before using them, as described in input validation. A well-formed JSON document can still contain a negative meter reading.
  • Limit sizes. Do not read an unbounded upload into memory; set request size limits and read streams.

Dates and times

Time is where many bugs hide: time zones, daylight saving time, and date formats that differ per country. .NET has several types, each for a different job:

Type Represents Use for
DateTimeOffset a moment: date, time and offset from UTC timestamps, logs, events, anything stored or exchanged
DateOnly a calendar date without a time billing dates, birthdays, due dates
TimeOnly a time of day without a date opening hours, peak-tariff windows
TimeSpan a duration timeouts, the length of an outage
DateTime a date and time with a Kind: Utc, Local or Unspecified older APIs; prefer the types above in new code

DateTimeOffset for moments in time

A DateTimeOffset is a .NET value type that stores a date and time together with its offset from Coordinated Universal Time (UTC), such as +02:00, so it always identifies one exact moment. A DateTime only has a Kind flag, which is easily lost or misread. A classic surprise: parsing a UTC timestamp into a DateTime converts it to the server's local time:

using System.Globalization;

DateTime parsed = DateTime.Parse("2026-10-03T12:00:00Z");
Console.WriteLine($"DateTime.Parse kind: {parsed.Kind}");

DateTimeOffset opened = DateTimeOffset.Parse("2026-10-03T14:05:00+02:00", CultureInfo.InvariantCulture);
Console.WriteLine($"As given: {opened:yyyy-MM-dd HH:mm zzz}");
Console.WriteLine($"In UTC:   {opened.UtcDateTime:yyyy-MM-dd HH:mm}");
Console.WriteLine($"ISO 8601: {opened.ToUniversalTime():O}");

DateTimeOffset resolved = opened.AddMinutes(42);
TimeSpan duration = resolved - opened;
Console.WriteLine($"Outage lasted {duration.TotalMinutes} minutes");
DateTime.Parse kind: Local
As given: 2026-10-03 14:05 +02:00
In UTC:   2026-10-03 12:05
ISO 8601: 2026-10-03T12:05:00.0000000+00:00
Outage lasted 42 minutes

Store and exchange moments in UTC, written in ISO 8601 (the "O" round-trip format), and convert to local time only to show it to a person. For the current moment use DateTimeOffset.UtcNow. In code you want to test, ask a TimeProvider for the time instead of reading the clock directly, so a test can supply a fixed time (see Testing and Code Quality).

DateOnly and TimeOnly

DateOnly is a .NET value type for a calendar date without a time of day or time zone, available since .NET 6. A billing date is not a moment: storing it as a DateTime at midnight invites bugs when the value is converted between time zones and slips to the day before. TimeOnly is its partner for a time of day:

var billingDate = new DateOnly(2026, 10, 3);
Console.WriteLine($"{billingDate:yyyy-MM-dd} is a {billingDate.DayOfWeek}");

DateOnly due = billingDate.AddDays(30);
Console.WriteLine($"Due: {due:yyyy-MM-dd}");
Console.WriteLine($"Days between: {due.DayNumber - billingDate.DayNumber}");

var peakStart = new TimeOnly(17, 0);
var peakEnd = new TimeOnly(21, 0);
var readingTime = new TimeOnly(18, 30);
Console.WriteLine($"18:30 in peak hours: {readingTime.IsBetween(peakStart, peakEnd)}");
2026-10-03 is a Saturday
Due: 2026-11-02
Days between: 30
18:30 in peak hours: True

Time zones

TimeZoneInfo converts a moment to the local time of a region, including daylight saving time. Since .NET 6, FindSystemTimeZoneById accepts both IANA IDs such as Europe/Amsterdam and Windows IDs such as W. Europe Standard Time on every operating system, and TimeZoneInfo.TryConvertIanaIdToWindowsId converts between them.

TimeZoneInfo amsterdam = TimeZoneInfo.FindSystemTimeZoneById("Europe/Amsterdam");

var autumn = new DateTimeOffset(2026, 10, 3, 12, 0, 0, TimeSpan.Zero);
var winter = new DateTimeOffset(2026, 12, 3, 12, 0, 0, TimeSpan.Zero);

Console.WriteLine($"{TimeZoneInfo.ConvertTime(autumn, amsterdam):yyyy-MM-dd HH:mm zzz}");
Console.WriteLine($"{TimeZoneInfo.ConvertTime(winter, amsterdam):yyyy-MM-dd HH:mm zzz}");
2026-10-03 14:00 +02:00
2026-12-03 13:00 +01:00

The same 12:00 UTC is 14:00 in Amsterdam in October (summer time) and 13:00 in December. Never add a fixed "+1 hour" yourself.

Parsing and formatting dates

Formatting and parsing follow a culture. On a Dutch PC 3-10-2026 means 3 October; on an American one, 10 March. For data, use a fixed format and CultureInfo.InvariantCulture; use a real culture only for text a person reads (see Strings and String Interpolation for number formats):

using System.Globalization;

var nl = new CultureInfo("nl-NL");
var date = new DateOnly(2026, 10, 3);

Console.WriteLine(date.ToString("D", nl));
Console.WriteLine(date.ToString("d", CultureInfo.InvariantCulture));
Console.WriteLine(date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));

DateOnly fromDutch = DateOnly.ParseExact("03-10-2026", "dd-MM-yyyy", CultureInfo.InvariantCulture);
Console.WriteLine(fromDutch.ToString("O"));
Console.WriteLine($"31-02-2026 valid: {DateOnly.TryParse("31-02-2026", nl, out _)}");
zaterdag 3 oktober 2026
10/03/2026
2026-10-03
2026-10-03
31-02-2026 valid: False

ParseExact accepts only the format you name, and TryParse returns false for input that is not a valid date instead of throwing, which suits user input.

More of the library

A few more classes that save you from writing code yourself. Each one is a starting point for the API reference:

Need Class
Pattern matching in text Regex (see the Regular Expressions topic)
Measuring how long something takes Stopwatch
Environment variables, machine name, command line Environment
Starting another program Process
Building long strings StringBuilder
Hashes and secure random values SHA256, RandomNumberGenerator (not Random, which is not meant for security)
Compression ZipFile, GZipStream
Unique identifiers Guid
The current time in testable code TimeProvider