Skip to main content
Published / updated

Exceptions, Files, and JSON

Before you start

You need: classes (Article 04) and collections (Article 05).

Time: about 55 minutes, plus the practice.

Learning objective

Handle failures without hiding them, and read and write School Management System data as text, CSV and JSON.

Topics

  • What an exception is
  • try, catch, finally
  • Catching specific exceptions
  • Throwing, and custom exceptions
  • The catch block that hides bugs
  • using and disposal
  • Reading and writing files
  • CSV
  • JSON with System.Text.Json
  • Enums, DateTime and strings in stored data

What an exception is

An exception is a run-time failure the program can catch and respond to.

int marks = int.Parse("NCA-2024-0012");
System.FormatException: The input string 'NCA-2024-0012' was not in a correct format.
at System.Number.ThrowOverflowOrFormatException(...)
at NexCoding.SchoolConsole.Program.Main(String[] args) in Program.cs:line 24

Uncaught, it stops the program. Caught, you decide what happens.

ExceptionCause
NullReferenceExceptionUsing something that is null
FormatExceptionParsing a string that is not in the expected form
IndexOutOfRangeExceptionArray index outside 0 to Length - 1
ArgumentOutOfRangeExceptionList index outside its bounds
InvalidOperationExceptionWrong state — First() on an empty sequence
KeyNotFoundExceptionDictionary key absent
DivideByZeroExceptionInteger division by zero
FileNotFoundExceptionThe file is not there
UnauthorizedAccessExceptionNo permission
IOExceptionThe file is locked or the disk failed

Exceptions are for exceptional conditions, not for control flow. A student not being found is normal — return null and let the caller check. A configuration file being absent at startup is exceptional.

try, catch, finally

try
{
string content = File.ReadAllText("students.csv");
Console.WriteLine(content);
}
catch (FileNotFoundException ex)
{
Console.WriteLine($"File not found: {ex.FileName}");
}
catch (UnauthorizedAccessException)
{
Console.WriteLine("Permission denied reading the student file.");
}
finally
{
Console.WriteLine("Read attempt finished.");
}
BlockRuns
tryUntil something throws
catchIf a matching exception was thrown
finallyAlways — exception or not

Order catch blocks from most specific to most general. catch (Exception) first would swallow everything and the compiler rejects an unreachable specific block below it.

finally runs even when the try returns. It is for cleanup that must happen regardless — though for anything disposable, using is better and comes below.

Catching specifically

// Too broad — hides the cause
try
{
ProcessFeePayment(studentId, amount);
}
catch (Exception ex)
{
Console.WriteLine("Something went wrong.");
}
// Specific — each failure gets the right response
try
{
ProcessFeePayment(studentId, amount);
}
catch (StudentNotFoundException ex)
{
Console.WriteLine($"No student with id {ex.StudentId}.");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Payment rejected: {ex.Message}");
}

Catch what you can actually handle. Everything else should reach a higher level that can log it and stop — because "something went wrong" is not a message anyone can act on.

when filters a catch:

catch (IOException ex) when (ex.Message.Contains("being used by another process"))
{
Console.WriteLine("The file is open in another program. Close it and retry.");
}

Throwing

public static void RecordPayment(FeeAccount account, decimal amount)
{
if (account == null)
{
throw new ArgumentNullException(nameof(account));
}

if (amount <= 0)
{
throw new ArgumentException("Payment amount must be greater than zero.", nameof(amount));
}

decimal balance = account.TotalFees - account.DiscountAmount - account.PaidAmount;

if (amount > balance)
{
throw new InvalidOperationException($"Payment of {amount:N2} exceeds the balance of {balance:N2}.");
}

account.PaidAmount = account.PaidAmount + amount;
}
ExceptionThrow when
ArgumentNullExceptionA required argument is null
ArgumentExceptionAn argument's value is invalid
ArgumentOutOfRangeExceptionAn argument is outside its allowed range
InvalidOperationExceptionThe object is in the wrong state for this call
NotSupportedExceptionThe operation is not available here

nameof(amount) gives the parameter name to the message without hard-coding a string that a rename would leave stale.

A custom exception when the caller needs to distinguish it:

public class StudentNotFoundException : Exception
{
public int StudentId { get; }

public StudentNotFoundException(int studentId)
: base($"No student found with id {studentId}.")
{
StudentId = studentId;
}
}

Carry the data the caller needs on the exception, as StudentId is carried here. A message string alone forces the caller to parse text.

Rethrowing:

try
{
ProcessFeePayment(studentId, amount);
}
catch (Exception ex)
{
LogError(ex);
throw; // preserves the original stack trace
}

throw; preserves the stack trace. throw ex; resets it to this line, discarding where the failure actually happened. Use the bare throw;.

The catch block that hides bugs

// The worst pattern in this article
public static decimal GetBalance(int studentId)
{
try
{
FeeAccount account = _repository.GetByStudentId(studentId);
return account.TotalFees - account.DiscountAmount - account.PaidAmount;
}
catch (Exception)
{
return 0m;
}
}

This turns a crash into a plausible wrong number. The student appears to owe nothing. Nothing is logged. The accounts team acts on it.

An unhandled exception is better than a silently wrong answer, because it gets investigated.

public static decimal GetBalance(int studentId)
{
FeeAccount account = _repository.GetByStudentId(studentId);

if (account == null)
{
throw new StudentNotFoundException(studentId);
}

return account.TotalFees - account.DiscountAmount - account.PaidAmount;
}

An empty catch block — catch (Exception) { } — is never correct. If you genuinely intend to ignore a failure, log it and write a comment explaining why.

using and disposal

Files, database connections and network streams hold operating-system resources that must be released.

// Leaks the handle if an exception is thrown
StreamReader reader = new StreamReader("students.csv");
string line = reader.ReadLine();
reader.Close();

// Correct — disposed even on an exception
using (StreamReader reader = new StreamReader("students.csv"))
{
string line = reader.ReadLine();
}

using calls Dispose when the block ends, whether it ended normally or by exception. It is try/finally written compactly.

using StreamReader reader = new StreamReader("students.csv");

The declaration form disposes at the end of the enclosing method. Both are correct; the block form makes the lifetime obvious.

Anything implementing IDisposable belongs in a using. A file left open blocks other processes; a database connection left open exhausts the pool, which appears as a timeout that looks like a slow query and is not.

Files

using System.IO;

// Whole file at once — fine for small files
string content = File.ReadAllText("students.csv");
string[] lines = File.ReadAllLines("students.csv");

File.WriteAllText("report.txt", "NexCoding Academy — Fee Report");
File.WriteAllLines("rolls.txt", rollNumbers);
File.AppendAllText("audit.log", $"{DateTime.UtcNow:o} Fee report generated\n");

// Line by line — right for large files
using (StreamReader reader = new StreamReader("students.csv"))
{
string line = reader.ReadLine();

while (line != null)
{
Console.WriteLine(line);
line = reader.ReadLine();
}
}

ReadAllText loads the entire file into memory. For a 20-row seed file that is fine; for a 500 MB export it is not. StreamReader reads one line at a time.

if (!File.Exists(path))
{
Console.WriteLine($"File not found: {path}");
return;
}

File.Exists reduces the chance of an exception but does not remove it — the file can be deleted between the check and the read. Keep the try/catch.

string folder = Path.Combine("data", "exports");
string path = Path.Combine(folder, "students.csv");

Directory.CreateDirectory(folder); // no-op if it already exists

Use Path.Combine, never string concatenation with \. It gets the separator right on every operating system, and the tracks that follow run on Linux build servers.

CSV

public static List<Student> ImportStudents(string path)
{
List<Student> students = new List<Student>();
string[] lines = File.ReadAllLines(path);

for (int i = 1; i < lines.Length; i++) // skip the header row
{
string line = lines[i];

if (string.IsNullOrWhiteSpace(line))
{
continue;
}

string[] fields = line.Split(',');

if (fields.Length < 4)
{
Console.WriteLine($"Row {i + 1} skipped: expected 4 fields, found {fields.Length}.");
continue;
}

Student student = new Student();
student.RollNumber = fields[0].Trim();
student.Name = fields[1].Trim();
student.ClassName = fields[2].Trim();
student.Section = fields[3].Trim();

students.Add(student);
}

return students;
}
RollNumber,Name,ClassName,Section
NCA-2024-0012,Ravi Kumar,10th,A
NCA-2024-0013,Priya Sharma,9th,B
NCA-2024-0014,Arjun Reddy,9th,A

Three things that break naive CSV parsing:

  • A comma inside a field. "Kumar, Ravi" splits into two. Real CSV quotes such fields, and Split(',') does not understand quotes.
  • A missing trailing field. fields[3] throws — hence the length check above.
  • Whitespace. Trim() every field, or a roll number silently fails to match.

Report the row number when skipping a row. "Import failed" is useless; "row 47 skipped: expected 4 fields, found 3" is a fix.

For anything beyond a simple export, use a CSV library rather than Split.

JSON

using System.Text.Json;

Student student = new Student
{
Id = 12,
Name = "Ravi Kumar",
RollNumber = "NCA-2024-0012",
ClassName = "10th",
Section = "A",
Status = StudentStatus.Active
};

JsonSerializerOptions options = new JsonSerializerOptions();
options.WriteIndented = true;

string json = JsonSerializer.Serialize(student, options);
File.WriteAllText("student.json", json);
{
"Id": 12,
"Name": "Ravi Kumar",
"RollNumber": "NCA-2024-0012",
"ClassName": "10th",
"Section": "A",
"Status": 0
}
string json = File.ReadAllText("student.json");
Student loaded = JsonSerializer.Deserialize<Student>(json);
List<Student> students = JsonSerializer.Deserialize<List<Student>>(json);

Deserialisation needs a parameterless constructor and settable properties. A class with only a validating constructor and private set properties will deserialise into nulls and zeros, silently. This is the same trap the API track meets with model binding.

Enums serialise as numbers by default. Status: 0 is unreadable and breaks if the enum is reordered:

JsonSerializerOptions options = new JsonSerializerOptions();
options.WriteIndented = true;
options.Converters.Add(new JsonStringEnumConverter());

string json = JsonSerializer.Serialize(student, options); // "Status": "Active"

Store enums as strings in any file another system reads. Inserting a value into the middle of the enum changes every stored number's meaning.

Property name casing:

JsonSerializerOptions options = new JsonSerializerOptions();
options.PropertyNameCaseInsensitive = true;
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;

Web APIs conventionally use camelCase — rollNumber, not RollNumber. Case-insensitive reading avoids a whole class of "the field is null but it's right there in the file" confusion.

Never serialise a whole entity to a file a user can see without checking what it contains. A Teacher includes Salary; a Student includes ParentPhone. Project into a summary class first, as Article 06 showed.

Errors you will hit

MessageCauseFix
System.IO.FileNotFoundExceptionWrong path, or the file is not where you thinkCheck File.Exists, and remember the working folder is bin\Debug\net9.0
System.IO.DirectoryNotFoundExceptionA folder in the path does not existDirectory.CreateDirectory first
System.UnauthorizedAccessExceptionNo permission, or the path is a folderCheck the path and the folder's permissions
System.IO.IOException: being used by another processThe file is open in Excel or Notepad, or you did not dispose a readerClose it; use using
System.Text.Json.JsonException: The JSON value could not be convertedType mismatch between JSON and your classCheck the property types
Deserialised object has all nulls and zerosProperties have no public setter, or names do not matchAdd setters; set PropertyNameCaseInsensitive
CS0160: A previous catch clause already catches all exceptionscatch (Exception) placed above a specific catchOrder catches most specific first

The working folder catches every beginner. A file next to Program.cs is not next to the running program — the build copies to bin\Debug\net9.0, and that is where relative paths resolve. In Solution Explorer, select the file, then in the Properties window set Copy to Output Directory to Copy if newer.

Common mistakes

  • catch (Exception) returning a default value
  • Empty catch blocks
  • throw ex; instead of throw;
  • Catching an exception you cannot do anything about
  • Using exceptions for ordinary outcomes like "not found"
  • Files or connections without using
  • ReadAllText on a large file
  • String concatenation for paths instead of Path.Combine
  • Split(',') on CSV containing quoted commas
  • No field-count check before indexing split results
  • Not trimming imported fields
  • Enums serialised as numbers into shared files
  • Deserialising into a class whose properties have no setters

Practice

  1. Parse "NCA-2024-0012" with int.Parse and read the exception type and line.
  2. Trigger each of NullReferenceException, IndexOutOfRangeException, KeyNotFoundException and InvalidOperationException deliberately.
  3. Write a try/catch/finally around a file read and confirm finally runs on both paths.
  4. Put catch (Exception) above catch (FileNotFoundException) and read the compiler error.
  5. Write RecordPayment throwing three different exception types, and test each.
  6. Write StudentNotFoundException carrying StudentId, and catch it by type.
  7. Use throw; and throw ex; in turn, and compare the stack traces.
  8. Write the catch (Exception) { return 0m; } version of GetBalance. Give it a student with no fee account and record what the report shows.
  9. Rewrite it to throw, and compare which failure you would rather debug.
  10. Open a StreamReader without using, throw inside the block, and confirm the file stays locked.
  11. Read a 20-row file with ReadAllLines and the same file with StreamReader line by line.
  12. Build a path with Path.Combine and with "data" + "\\" + "file.csv". Compare.
  13. Import the three-student CSV above into List<Student>.
  14. Add a row with a missing field and confirm your length check reports the row number.
  15. Add a row containing "Kumar, Ravi" and watch Split(',') produce five fields.
  16. Serialise a Student to JSON, inspect Status, then add JsonStringEnumConverter and compare.
  17. Deserialise into a class with private set properties and confirm the values are missing.
  18. Serialise a Teacher and check whether Salary appears in the file. Project into a summary class that omits it.

Exercises 8 and 9 together are the article's central point. Exercise 18 is the one that becomes a data-exposure bug in the API track.

You can now

  • Catch specific exceptions and let the rest surface
  • Say why catch (Exception) { return 0m; } is worse than a crash
  • Use throw; rather than throw ex;
  • Wrap files and connections in using
  • Read and write text, CSV and JSON
  • Report a bad CSV row by number instead of failing the whole import
  • Serialise enums as strings, and say why

Review questions

  1. Why is an unhandled exception better than catch (Exception) { return 0m; }?
  2. What is the difference between throw; and throw ex;?
  3. What does using guarantee that a manual Close() does not?
  4. Why should enums be serialised as strings in a file another system reads?

Next: Async and await