Files, JSON and the .NET Libraries
Getting real work done with the .NET libraries: reading and writing files and paths, JSON with System.Text.Json, dates and times, and where to find the rest of the base class library.
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 aPlatformNotSupportedExceptionon every use; old tutorials that still show it will not run on current .NET. UseSystem.Text.Json(orXmlSerializer/DataContractSerializerfor 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 |