Back to Blog
C#

Implementing the C# Dispose Pattern

c# dispose pattern: Learn how to implement the C# dispose pattern correctly, covering IDisposable, finalizers, using statements, async disposal, and common failure modes.

dispose patternIDisposableresource cleanupgarbage collectionIAsyncDisposable
A clean diagram illustrating the C# dispose pattern flow from the Dispose method through managed and unmanaged resource cleanup paths.

The Problem the Dispose Pattern Solves

The C# dispose pattern is the standard way to release unmanaged resources deterministically. When a class wraps a file handle, a network socket, or a native allocation, the garbage collector cannot reclaim those resources on its own schedule. The dispose pattern gives the class a controlled cleanup path that callers can invoke explicitly.

The garbage collector manages managed memory, but it knows nothing about OS handles, native memory, or database connections. When an object holding such a resource becomes unreachable, the GC will eventually collect the managed object, but the underlying resource may remain open indefinitely. The dispose pattern provides a deterministic cleanup mechanism: the caller decides when cleanup happens, not the GC.

This is why IDisposable exists:

public interface IDisposable { void Dispose(); }

A class that implements IDisposable signals that it holds a resource that should be released explicitly. The using statement makes this convenient, but the pattern itself has more structure than a single Dispose method.

The Basic IDisposable Implementation

For a sealed class that only holds managed resources, a simple implementation is usually sufficient:

public sealed class FileLogger : IDisposable { private StreamWriter _writer; public FileLogger(string path) { _writer = new StreamWriter(path); } public void Dispose() { _writer?.Dispose(); _writer = null; } }

This works because the class is sealed. No derived class can add its own resources that need cleanup, so the base Dispose method is the only cleanup path. The null assignment prevents accidental reuse after disposal, though it does not prevent a second call to Dispose.

The implementation should be idempotent. Calling Dispose twice must not throw. The null-conditional operator handles this naturally here, but a class with more complex cleanup should track disposal state explicitly.

The Full Dispose Pattern for Unsealed Classes

When a class is not sealed, derived classes may hold their own resources. The full dispose pattern accounts for this by splitting cleanup into a virtual method that derived classes can override:

public class ResourceHolder : IDisposable { private bool _disposed; private IntPtr _nativeHandle; ~ResourceHolder() { Dispose(false); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (_disposed) { return; } if (disposing) { // Release managed resources here. } // Release unmanaged resources here. _nativeHandle = IntPtr.Zero; _disposed = true; } }

The Dispose(bool) method is the heart of the pattern. When called with true, it releases both managed and unmanaged resources. When called with false, it releases only unmanaged resources. The false path runs from the finalizer, where touching managed objects is unsafe because they may already be collected.

Derived classes override Dispose(bool) and call the base implementation:

public class DerivedResourceHolder : ResourceHolder { private Stream _stream; private bool _disposed; protected override void Dispose(bool disposing) { if (_disposed) { return; } if (disposing) { _stream?.Dispose(); } base.Dispose(disposing); _disposed = true; } }

The derived class tracks its own _disposed flag because the base flag is private. The base call must happen after the derived cleanup so that base resources are released last.

When the Finalizer Is Actually Needed

The finalizer exists as a safety net for callers who forget to call Dispose. If the caller never calls Dispose, the finalizer eventually runs and releases unmanaged resources. This is important for native handles because they would otherwise leak until the process exits.

However, the finalizer is not free. An object with a finalizer is placed on the finalization queue when it becomes unreachable. The finalizer thread runs later, and the object survives at least one garbage collection. This delays memory reclamation and adds GC pressure.

For this reason, a class should only implement a finalizer when it directly owns an unmanaged resource. If a class only wraps managed IDisposable objects, a finalizer adds cost without benefit. The managed wrappers already have their own cleanup paths.

GC.SuppressFinalize(this) in Dispose is what removes the object from the finalization queue when the caller does the right thing. Without it, the finalizer would run even after explicit disposal, wasting a GC cycle.

Using Statements and Their Runtime Behavior

The using statement is the standard way to invoke the dispose pattern:

using (var logger = new FileLogger("app.log")) { logger.Write("startup complete"); }

The compiler transforms this into a try/finally block that calls Dispose in the finally clause. If an exception occurs inside the using body, Dispose still runs. This is the key behavioral guarantee: cleanup happens on both the normal path and the exception path.

C# 8 introduced using declarations, which scope the disposal to the enclosing block:

using var logger = new FileLogger("app.log"); logger.Write("startup complete");

The disposal happens at the end of the enclosing scope rather than at the end of a dedicated block. This is more concise but changes the timing of cleanup, which matters when the resource should be released before the scope ends.

Common Mistakes and Failure Modes

One frequent mistake is throwing from Dispose. If Dispose throws, it can mask the original exception from the using body. The caller sees the Dispose exception instead of the actual failure. Dispose should be written to avoid throwing, which usually means swallowing exceptions from underlying resource disposal or ensuring that cleanup operations cannot fail.

Another mistake is not making Dispose idempotent. The pattern's _disposed flag exists precisely so that repeated calls are safe. A Dispose implementation that assumes a single call will fail when the same instance is disposed twice, which happens more often than developers expect because using blocks can be nested or cleanup paths can overlap.

A related issue is accessing a disposed object. After Dispose runs, the object should not be used. The pattern does not enforce this automatically; the class must check its _disposed flag and throw ObjectDisposedException when appropriate. Many implementations skip this check, which turns a clear error into a confusing NullReferenceException or InvalidOperationException.

Performance and GC Implications

The dispose pattern has measurable runtime implications. The most significant cost is the finalizer. An object with a finalizer that is not suppressed will be promoted to the next GC generation before it is collected. In a server application with many such objects, this can cause premature full collections and increased pause times.

The pattern also affects allocation behavior. Each Dispose call is a virtual call through the interface, which is cheap but not free. The _disposed flag check adds a branch per call. These costs are negligible in most applications, but they matter in hot paths where disposal happens frequently, such as connection pooling or per-request resource cleanup.

The more important performance concern is the finalizer thread. Finalizers run on a dedicated thread, and a slow finalizer blocks other finalizers. If a finalizer performs expensive cleanup, it delays the cleanup of every other finalized object in the queue. The dispose pattern avoids this by letting the caller perform cleanup on the calling thread.

Choosing the Right Variant of the Pattern

The dispose pattern has several variants, and the correct choice depends on the class design:

Class designPattern to useFinalizer needed
Sealed, managed resources onlySimple Dispose()No
Unsealed, managed resourcesVirtual Dispose(bool)No
Directly owns unmanaged resourcesFull pattern with finalizerYes
Cleanup performs I/OIAsyncDisposableDepends

A sealed class that wraps only managed IDisposable objects should use the simple implementation. Adding a finalizer would only add GC cost without any safety benefit, because the wrapped managed resources have their own finalizers.

An unsealed class needs the virtual Dispose(bool) method even if it holds no unmanaged resources. Without it, a derived class cannot add cleanup logic without breaking the base contract. The derived class overrides Dispose(bool), performs its own cleanup, and calls base.Dispose(disposing).

A class that directly allocates native memory or opens native handles should include a finalizer. The finalizer is the only guarantee that the native resource is released when the caller forgets to call Dispose. The cost of the finalizer is justified by the cost of the leak it prevents.

The async variant applies when cleanup performs I/O. A synchronous Dispose that blocks on network or disk I/O stalls the calling thread. IAsyncDisposable lets the caller await the cleanup without blocking:

public sealed class AsyncResource : IAsyncDisposable { private Stream _stream; public async ValueTask DisposeAsync() { if (_stream != null) { await _stream.DisposeAsync(); _stream = null; } } }

The await using statement mirrors the synchronous version:

await using var resource = new AsyncResource();

The choice between IDisposable and IAsyncDisposable is driven by the nature of the cleanup work. If disposal performs I/O, the async variant prevents thread blocking. If disposal is purely in-memory, the synchronous pattern is simpler and avoids the async state machine overhead. A class that implements both interfaces must ensure that both Dispose and DisposeAsync lead to the same final state without double-releasing a resource, which typically means routing both through a shared cleanup path guarded by a single _disposed flag.

c# dispose pattern: Practical Usage and Code Examples | RYUSLOG DEV