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
usingand disposal- Reading and writing files
- CSV
- JSON with
System.Text.Json - Enums,
DateTimeand 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.
| Exception | Cause |
|---|---|
NullReferenceException | Using something that is null |
FormatException | Parsing a string that is not in the expected form |
IndexOutOfRangeException | Array index outside 0 to Length - 1 |
ArgumentOutOfRangeException | List index outside its bounds |
InvalidOperationException | Wrong state — First() on an empty sequence |
KeyNotFoundException | Dictionary key absent |
DivideByZeroException | Integer division by zero |
FileNotFoundException | The file is not there |
UnauthorizedAccessException | No permission |
IOException | The 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.");
}
| Block | Runs |
|---|---|
try | Until something throws |
catch | If a matching exception was thrown |
finally | Always — 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;
}
| Exception | Throw when |
|---|---|
ArgumentNullException | A required argument is null |
ArgumentException | An argument's value is invalid |
ArgumentOutOfRangeException | An argument is outside its allowed range |
InvalidOperationException | The object is in the wrong state for this call |
NotSupportedException | The 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, andSplit(',')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
| Message | Cause | Fix |
|---|---|---|
System.IO.FileNotFoundException | Wrong path, or the file is not where you think | Check File.Exists, and remember the working folder is bin\Debug\net9.0 |
System.IO.DirectoryNotFoundException | A folder in the path does not exist | Directory.CreateDirectory first |
System.UnauthorizedAccessException | No permission, or the path is a folder | Check the path and the folder's permissions |
System.IO.IOException: being used by another process | The file is open in Excel or Notepad, or you did not dispose a reader | Close it; use using |
System.Text.Json.JsonException: The JSON value could not be converted | Type mismatch between JSON and your class | Check the property types |
| Deserialised object has all nulls and zeros | Properties have no public setter, or names do not match | Add setters; set PropertyNameCaseInsensitive |
CS0160: A previous catch clause already catches all exceptions | catch (Exception) placed above a specific catch | Order 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 ofthrow;- Catching an exception you cannot do anything about
- Using exceptions for ordinary outcomes like "not found"
- Files or connections without
using ReadAllTexton 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
- Parse
"NCA-2024-0012"withint.Parseand read the exception type and line. - Trigger each of
NullReferenceException,IndexOutOfRangeException,KeyNotFoundExceptionandInvalidOperationExceptiondeliberately. - Write a
try/catch/finallyaround a file read and confirmfinallyruns on both paths. - Put
catch (Exception)abovecatch (FileNotFoundException)and read the compiler error. - Write
RecordPaymentthrowing three different exception types, and test each. - Write
StudentNotFoundExceptioncarryingStudentId, and catch it by type. - Use
throw;andthrow ex;in turn, and compare the stack traces. - Write the
catch (Exception) { return 0m; }version ofGetBalance. Give it a student with no fee account and record what the report shows. - Rewrite it to throw, and compare which failure you would rather debug.
- Open a
StreamReaderwithoutusing, throw inside the block, and confirm the file stays locked. - Read a 20-row file with
ReadAllLinesand the same file withStreamReaderline by line. - Build a path with
Path.Combineand with"data" + "\\" + "file.csv". Compare. - Import the three-student CSV above into
List<Student>. - Add a row with a missing field and confirm your length check reports the row number.
- Add a row containing
"Kumar, Ravi"and watchSplit(',')produce five fields. - Serialise a
Studentto JSON, inspectStatus, then addJsonStringEnumConverterand compare. - Deserialise into a class with
private setproperties and confirm the values are missing. - Serialise a
Teacherand check whetherSalaryappears 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 thanthrow 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
- Why is an unhandled exception better than
catch (Exception) { return 0m; }? - What is the difference between
throw;andthrow ex;? - What does
usingguarantee that a manualClose()does not? - Why should enums be serialised as strings in a file another system reads?
Next: Async and await