Day 23: IDisposable and Resource Management - Cleaning Up After Yourself
arrow_back All Posts
April 06, 2026 10 min read .NET/C#

Day 23: IDisposable and Resource Management - Cleaning Up After Yourself

Yesterday you learned how to iterate over collections with IEnumerable and build custom iterators using yield return — making your data flow lazily like a cat deciding whether to get off the couch. Today we're talking about the other side of resource management: what happens when your code opens something it needs to close. File handles, database connections, network sockets — the garbage collector can't save you here. Welcome to IDisposable, C#'s formal handshake for saying "I'm done with this, clean it up."

1. Managed vs. Unmanaged Resources — What the Garbage Collector Can't Handle

Here's the deal. C#'s garbage collector (GC) is pretty good at its job. It tracks your objects, notices when nothing references them anymore, and quietly reclaims that memory. You don't have to think about it — which is the whole point.

But memory isn't the only resource your program uses.

When you open a file, the operating system gives your process a file handle — a little token that says "you have permission to read/write this file." When you connect to a database, you get a connection object backed by a network socket. When you start a timer or open an HTTP connection, there are OS-level resources tied to those objects.

The garbage collector knows nothing about these. It deals in memory. It doesn't know that your SqlConnection is hogging one of the 100 available slots in the connection pool. It doesn't know that your FileStream is locking report.csv so no other process can touch it.

These are called unmanaged resources — things that live outside the .NET runtime's managed heap. And if you don't explicitly release them, they stay locked until the process exits (or worse, until the OS gets fed up).

Here's a quick breakdown:

Resource Type Example What Happens If You Don't Release It
File handles FileStream, StreamWriter File stays locked — other programs can't open it
Database connections SqlConnection Connection pool exhaustion — app grinds to a halt
Network sockets HttpClient, TcpClient Port exhaustion, failed requests
OS timers System.Threading.Timer Timer keeps firing, memory leaks
Graphics handles Bitmap, Graphics GDI handle leaks on Windows

The GC might eventually finalize these objects and release the resources — but "might" and "eventually" aren't words you want in production code.

2. The IDisposable Interface — The Dispose() Contract

C# solves this with a simple interface: IDisposable. It lives in System and looks like this:

public interface IDisposable
{
    void Dispose();
}

That's it. One method. One job: release unmanaged resources immediately, don't wait for the garbage collector.

Any class that holds onto something the GC can't clean up should implement IDisposable. When you're done with the object, you call Dispose(), and it cleans up after itself. Think of it as a "please leave the campsite the way you found it" rule.

var reader = new StreamReader("notes.txt");
string content = reader.ReadToEnd();
reader.Dispose(); // releases the file handle RIGHT NOW

Simple enough. But there's a problem — what if an exception gets thrown between opening the reader and calling Dispose()? That file handle just leaked. You could wrap it in try/finally:

StreamReader reader = null;
try
{
    reader = new StreamReader("notes.txt");
    string content = reader.ReadToEnd();
    Console.WriteLine(content);
}
finally
{
    reader?.Dispose();
}

This works, but it's ugly. And programmers are lazy — in the best possible way. So C# gave us something better.

3. The using Statement (Block Form) — Automatic Cleanup

The using statement is syntactic sugar that wraps your disposable object in a try/finally block automatically. You've probably seen it already without knowing what it does under the hood:

using (var reader = new StreamReader("notes.txt"))
{
    string content = reader.ReadToEnd();
    Console.WriteLine(content);
} // reader.Dispose() is called here automatically — even if an exception fires

The compiler transforms this into the try/finally pattern from the previous section. But you don't have to write it yourself. The Dispose() call is guaranteed to run when execution leaves the using block — whether that's a normal exit, a return, or an unhandled exception.

You can even stack multiple using blocks:

using (var input = new StreamReader("source.txt"))
using (var output = new StreamWriter("destination.txt"))
{
    string line;
    while ((line = input.ReadLine()) != null)
    {
        output.WriteLine(line.ToUpper());
    }
}
// both streams are disposed here

Rule of thumb: if a class implements IDisposable, wrap it in a using statement. Every time. No exceptions (well — except when you intentionally want the resource to outlive the current scope, like a long-lived HttpClient).

4. The using Declaration (C# 8+) — No Braces Needed

C# 8 introduced the using declaration — same idea, less nesting. Instead of wrapping your disposable in a block, you just slap using in front of the variable declaration:

using var reader = new StreamReader("notes.txt");
string content = reader.ReadToEnd();
Console.WriteLine(content);
// reader.Dispose() is called when the enclosing scope ends (usually the method)

No braces. No extra indentation. The object gets disposed when the enclosing scope — typically the method — ends. This is the modern, preferred style in most C# codebases.

Here's the file-copy example rewritten:

void CopyFileUppercase(string source, string destination)
{
    using var input = new StreamReader(source);
    using var output = new StreamWriter(destination);

    string? line;
    while ((line = input.ReadLine()) != null)
    {
        output.WriteLine(line.ToUpper());
    }
    // both disposed at end of method
}

Much cleaner. The only trade-off: you lose control over exactly when disposal happens within the method. If you need a resource released halfway through a method, the block form gives you that precision. But 90% of the time, the declaration form is what you want.

5. Implementing IDisposable in Your Own Classes — The Dispose Pattern

Sooner or later, you'll write a class that holds a disposable resource. Maybe it wraps a file, manages a database connection, or holds a reference to a Timer. When that happens, your class needs to implement IDisposable too.

Here's the simplest version:

public class LogWriter : IDisposable
{
    private readonly StreamWriter _writer;
    private bool _disposed = false;

    public LogWriter(string path)
    {
        _writer = new StreamWriter(path, append: true);
    }

    public void Log(string message)
    {
        if (_disposed)
            throw new ObjectDisposedException(nameof(LogWriter));

        _writer.WriteLine($"[{DateTime.Now:HH:mm:ss}] {message}");
    }

    public void Dispose()
    {
        if (!_disposed)
        {
            _writer.Dispose();
            _disposed = true;
        }
    }
}

A few things to notice:

  • The _disposed flag prevents double-disposal. Calling Dispose() twice shouldn't throw — that's part of the contract.
  • After disposal, calling Log() throws ObjectDisposedException — the standard way to say "you already threw this away, stop using it."
  • Dispose() calls Dispose() on the inner StreamWriter — you're chaining cleanup.

Now your class plays nicely with using:

using var log = new LogWriter("app.log");
log.Log("Application started");
log.Log("Doing important stuff");
// file handle released at end of scope

For classes that directly hold unmanaged resources (like a raw Win32 handle), you'd implement the full Dispose pattern with a finalizer. But honestly, that's rare — most of the time you're wrapping other IDisposable objects, and the simple version above is all you need.

6. Common Disposable Types You'll Meet in the Wild

Here's a cheat sheet of types you'll encounter that implement IDisposable. If you see any of these, your using reflex should kick in immediately:

Type Namespace What It Holds
StreamReader / StreamWriter System.IO File handles
FileStream System.IO File handles
HttpClient System.Net.Http Socket connections (see note below)
SqlConnection Microsoft.Data.SqlClient Database connection pool slots
SqlCommand Microsoft.Data.SqlClient Command resources
Timer System.Threading OS timer handles
CancellationTokenSource System.Threading Kernel event handles
Bitmap / Graphics System.Drawing GDI+ handles
MemoryStream System.IO Technically disposable, but no unmanaged resources — disposing is a no-op

The HttpClient exception: HttpClient is disposable, but you typically should not create and dispose it per-request. Doing so causes socket exhaustion because the underlying TCP connections linger in a TIME_WAIT state. The recommended approach is to use IHttpClientFactory or keep a single long-lived HttpClient instance. It's one of those "yes it's disposable, no don't put it in a using block every time" situations. Ironic? Absolutely.

7. The Danger of Forgetting to Dispose — Leaked Handles, Locked Files, and Connection Pool Exhaustion

Let's look at what actually goes wrong when you forget to dispose things. These aren't hypothetical — they're the kinds of bugs that show up at 2 AM on a Friday.

Scenario 1: The locked file

// BAD — no using, no Dispose
void ReadFile()
{
    var reader = new StreamReader("data.csv");
    string content = reader.ReadToEnd();
    Console.WriteLine(content);
    // reader is NEVER disposed
    // the file stays locked until GC finalizes this object... eventually
}

Meanwhile, your coworker's import script can't write to data.csv and they're blaming the network.

Scenario 2: Connection pool exhaustion

// BAD — connections leak on every call
void GetUserCount()
{
    var connection = new SqlConnection(connectionString);
    connection.Open();
    var command = new SqlCommand("SELECT COUNT(*) FROM Users", connection);
    int count = (int)command.ExecuteScalar();
    Console.WriteLine($"Users: {count}");
    // connection never closed, never returned to pool
}

Call this method 100 times and your connection pool is maxed out. The 101st call hangs forever, waiting for a connection that's never coming back. Your app looks frozen. The database looks fine. You spend three hours debugging the wrong thing.

The fix is always the same:

void GetUserCount()
{
    using var connection = new SqlConnection(connectionString);
    connection.Open();
    using var command = new SqlCommand("SELECT COUNT(*) FROM Users", connection);
    int count = (int)command.ExecuteScalar();
    Console.WriteLine($"Users: {count}");
    // both disposed, connection returned to pool
}

Two extra words — using var — and the bug disappears entirely.

How to catch these in code review: if you see new SomeDisposableType(...) without a using keyword on the same line, raise a flag. Modern analyzers like CA2000 will also catch these automatically.

8. Your Homework: Build a TempFile Class

Time to put this into practice. Build a TempFile class that:

  1. Creates a temporary file in the constructor (use Path.GetTempFileName())
  2. Exposes the file path as a property
  3. Lets you write text to the file
  4. Deletes the file when Dispose() is called
  5. Works with using statements

Here's the skeleton — fill in the implementation:

public class TempFile : IDisposable
{
    private bool _disposed = false;

    public string FilePath { get; }

    public TempFile()
    {
        FilePath = Path.GetTempFileName();
        Console.WriteLine($"Created temp file: {FilePath}");
    }

    public void WriteText(string content)
    {
        if (_disposed)
            throw new ObjectDisposedException(nameof(TempFile));

        File.WriteAllText(FilePath, content);
    }

    public string ReadText()
    {
        if (_disposed)
            throw new ObjectDisposedException(nameof(TempFile));

        return File.ReadAllText(FilePath);
    }

    public void Dispose()
    {
        if (!_disposed)
        {
            if (File.Exists(FilePath))
            {
                File.Delete(FilePath);
                Console.WriteLine($"Deleted temp file: {FilePath}");
            }
            _disposed = true;
        }
    }
}

Test it like this:

using var temp = new TempFile();
temp.WriteText("This data is temporary. Like my motivation on Mondays.");
Console.WriteLine(temp.ReadText());
Console.WriteLine($"File exists: {File.Exists(temp.FilePath)}");
// after this scope ends, the file is deleted

// verify cleanup
// Console.WriteLine($"File exists after dispose: {File.Exists(temp.FilePath)}");

Bonus challenge: modify TempFile so it accepts an optional file extension in the constructor (e.g., .csv, .json) instead of always getting the .tmp extension that GetTempFileName() produces. Hint: you'll need to rename or recreate the file with the new extension.

Summary of Day 23

  • The garbage collector handles memory but knows nothing about file handles, database connections, network sockets, and other unmanaged resources.
  • IDisposable is a one-method interface (Dispose()) that gives you a standard way to release those resources immediately.
  • The using statement (block form) wraps your disposable in a try/finally — guaranteed cleanup even when exceptions fly.
  • The using declaration (C# 8+) does the same thing with less nesting — the object is disposed when the enclosing scope ends.
  • When your own class holds disposable resources, implement IDisposable — chain Dispose() calls, use a _disposed flag, and throw ObjectDisposedException on post-disposal access.
  • HttpClient is the famous exception — it's disposable, but you shouldn't dispose it per-request. Use IHttpClientFactory instead.
  • Forgetting to dispose causes leaked file handles, locked files, and connection pool exhaustion — bugs that are maddening to track down.

Tomorrow: we'll tackle Working with Files and Streams — reading, writing, and manipulating files on disk. Now that you know how to clean up after yourself, it's time to actually do something with those file handles. 📂

See you on Day 24!

Share
FM

Farhad Mammadov

.NET Engineer & Cloud Architect · Bayern, Germany. Writing about scalable backend systems, AWS, and SRE.