All articles

IDisposable, Finalizers and the Dispose Pattern in .NET

Deterministic cleanup with using, the full dispose pattern explained line by line, IAsyncDisposable, SafeHandle, and why you almost never write a finalizer.

0 · log in to like, save & follow Share on LinkedIn Share on X
IDisposable, Finalizers and the Dispose Pattern in .NET

The garbage collector manages memory — and nothing else. File handles, sockets, database connections, and native buffers don't free themselves when objects become unreachable; they free when something calls Dispose. This post walks .NET's resource-cleanup machinery on .NET 10 with a runnable demo: IDisposable and using, the full dispose pattern, IAsyncDisposable, finalizers as a safety net, and the modern advice that makes most of the ceremony unnecessary.

IDisposable, Finalizers and the Dispose Pattern in .NET

Deterministic cleanup: IDisposable and using

using (var writer = new ReportWriter("report.tmp"))
{
    writer.WriteLine("hello");
}   // Dispose runs HERE — exception or not

using compiles to try/finally; disposal is deterministic and exception-safe. The declaration form (using var writer = ...;) disposes at end of scope and removes a nesting level — prefer it except when you want the resource's lifetime visibly shorter than the method.

Verified from the demo run:

-- deterministic cleanup with using --
  Dispose(disposing: True)

The dispose pattern, explained line by line

The canonical pattern exists for one scenario: a class that owns an unmanaged resource (or is designed for inheritance while owning resources):

public class ReportWriter : IDisposable
{
    private readonly StreamWriter _writer;       // managed resource
    private IntPtr _fakeNativeHandle = new(42);  // stand-in for an unmanaged one
    private bool _disposed;

    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);               // finalizer no longer needed
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;
        if (disposing)
        {
            _writer.Dispose();                   // managed: only on the explicit path
        }
        _fakeNativeHandle = IntPtr.Zero;         // unmanaged: on both paths
        _disposed = true;
    }

    ~ReportWriter() => Dispose(disposing: false); // safety net, GC thread
}

Why the disposing flag: when the finalizer calls Dispose(false), other managed objects (like _writer) may already be finalized — touching them is undefined behavior territory. So managed resources are released only on the explicit path (disposing: true), unmanaged ones on both. GC.SuppressFinalize tells the GC not to run the finalizer for properly disposed objects — finalizable objects survive an extra GC generation, so suppressing is a real performance courtesy.

The demo proves the safety net fires when someone forgets:

-- forgotten Dispose: the finalizer safety net fires on GC --
  Dispose(disposing: False)

What you should actually write in 2026

The full pattern above is mostly a reading skill, not a writing skill:

  • Own only managed disposables? Implement Dispose() that disposes them, done. No finalizer, no Dispose(bool), no virtual anything for sealed classes.
  • Own a real OS handle? Wrap it in a SafeHandle subclass instead of an IntPtr. SafeHandle has its own rock-solid critical finalizer; your class goes back to the simple case and you delete your finalizer.
  • Writing a finalizer yourself is justified roughly never in application code — they run on a dedicated thread at an unpredictable time, resurrect object graphs for a generation, and turn cleanup bugs into heisenbugs. ObjectDisposedException.ThrowIf(_disposed, this) in public members completes the contract.

Async disposal

Flushing a network stream or closing a database connection may itself be I/O. IAsyncDisposable lets cleanup await:

public class ResourceStream : IAsyncDisposable
{
    public async ValueTask DisposeAsync()
    {
        await Task.Delay(10);                    // e.g. FlushAsync over the network
        Console.WriteLine("  DisposeAsync ran");
    }
}

await using (var stream = new ResourceStream())
{
    await stream.WorkAsync();
}
-- async disposal --
  working...
  DisposeAsync ran

await using is the async twin of using. Types like DbContext and Stream implement both; prefer await using in async methods so a slow flush doesn't block a thread. If you implement both interfaces, the guidance is DisposeAsync does the real work and Dispose remains a synchronous fallback.

The rules that prevent real bugs

  • Whoever creates it disposes it. Factories that return IDisposable transfer ownership to the caller; document it.
  • Injected dependencies are not yours to dispose — the DI container owns their lifetime. Disposing an injected HttpClient or DbContext breaks the next consumer; this is the most common dispose bug in ASP.NET Core code, and DI lifetimes are the other half of that story.
  • Fields that are disposables make your class disposable too — propagate the interface upward until a using at some scope terminates the chain.
  • Dispose must be idempotent (the _disposed check) — callers may double-dispose, and the contract says that's fine.

The GC will eventually reclaim memory whether you do any of this or not. What it won't do is return your file locks, sockets, and connection-pool slots on time — that part is yours, and it's three keywords: using, await using, and (almost never) ~. The complete runnable demo is in the companion repository.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.