AlgoMaster Logo

IDisposable & using Statement

High Priority22 min readUpdated June 6, 2026

The garbage collector handles managed memory automatically, but plenty of resources sit outside its reach: open file handles, network sockets, database connections, native memory buffers, and operating-system primitives like mutexes and timers. These resources need to be released at a precise moment, not "eventually when the GC feels like it." This lesson covers the IDisposable interface, the using statement and using declaration that drive deterministic cleanup, the full Dispose pattern for class hierarchies, and the async equivalents introduced in C# 8.

About Deterministic Cleanup

The garbage collector is good at reclaiming managed memory. It tracks references, identifies unreachable objects, and frees their memory in its own time. For pure managed memory, that's fine: a List<int> that goes out of scope can sit on the heap for a while before the GC gets to it, and nothing breaks in the meantime.

External resources don't work that way. When your code opens a file, the operating system allocates a file handle, marks the file as in use, and (depending on the open mode) refuses to let other processes touch it. That handle is not managed memory. The GC has no idea it exists. The only thing the GC sees is the small managed FileStream object that holds a reference to the handle.

If you forget to release the handle, the file stays locked until either the GC eventually collects the FileStream and its finalizer runs to free the handle, or the process exits. Both can take minutes or longer. In a long-running server, you can exhaust the operating system's handle limit before the GC ever notices a problem.

Forgetting to close a FileStream can keep a file locked for tens of seconds or longer, depending on GC pressure. On Windows, the file can't even be deleted by another process during that window. The same problem in a tight loop (open file, "forget" to close, repeat 10,000 times) will throw IOException: Too many open files long before the GC catches up.

The list of resources that need this kind of treatment is long. File handles, network sockets, HTTP request streams, database connections, named pipes, shared memory regions, native memory allocated via Marshal.AllocHGlobal, COM objects, GDI graphics handles, semaphores, and CancellationTokenSource timers all behave the same way: the OS or runtime gave you a resource, and the OS or runtime expects you to give it back, promptly.

C# answers this with a simple contract called IDisposable and two pieces of syntax that make using it almost automatic.

The IDisposable Interface

IDisposable is one of the smallest interfaces in the .NET base class library. It has exactly one member.

When a type implements IDisposable, it's making a promise: "I'm holding something that needs to be released. Call Dispose() when you're done with me, and I'll release it." The interface itself doesn't say what's being released or how. That's the implementing type's business.

The contract has three rules that every implementation should honor.

  1. `Dispose()` releases all unmanaged resources the object owns (and optionally the managed IDisposable resources it owns too).
  2. `Dispose()` must be idempotent. Calling it twice on the same object should not throw, should not corrupt state, and should not double-free anything. Once disposed, calling it again is a no-op.
  3. `Dispose()` should not throw. A failure inside Dispose() runs during cleanup, often when an exception is already on the way out, and throwing again replaces the original exception with the cleanup one. Implementations should catch and swallow, log, or otherwise contain failures rather than propagate them.

A first, deliberately bare implementation looks like this:

The _disposed flag is doing real work. It lets Dispose() short-circuit on the second call, and it lets every method that touches the resource check the flag and throw ObjectDisposedException if the object has already been cleaned up. Without that check, calling WriteLine after Dispose() might write to a closed file handle, with results ranging from a confusing error to silent data loss.

The using Statement

Calling Dispose() manually has one big problem: if anything between construction and disposal throws, the call to Dispose() never runs. The fix is the using statement, which wraps construction, use, and cleanup in a single block that guarantees disposal even when an exception escapes.

The classic block form looks like this:

The compiler translates the using block into a try/finally underneath. The expanded form is what's really running:

The finally block runs no matter how the try exits: normal completion, an exception, a return, a break, even a goto. That's the whole point. The variable declared in the using statement is in scope only inside the block, and after the block its Dispose() has already run.

A diagram of the lifecycle makes the guarantee concrete.

The green box (Dispose) sits on every path out of the block. That's the guarantee the using statement buys you. Whether the body finishes cleanly or throws halfway through, the resource gets released before control leaves the block.

A using block is essentially free. The compiler emits a try/finally, and a finally that runs once per resource is a rounding error compared to the cost of the I/O the resource is wrapping. There's no reason to skip using for "performance reasons."

You can stack multiple using blocks. Each acquires a resource, and disposal runs in reverse order at the end.

Stacking like this is common enough that it's a recognized idiom. Two using lines, no extra braces, both resources cleaned up in the correct order. The reverse-order disposal mirrors how scopes nest: the inner resource is released before the outer one, the same way local variables of a nested block fall out of scope before the outer block's variables.

The using Declaration

C# 8 introduced a lighter-weight form called the using declaration. Instead of opening a new block, you put using in front of a local variable declaration, and the compiler treats it as if the variable were inside a using block that extends to the end of the enclosing scope.

No extra braces. No extra indentation. The variable lives until the end of the method (or the end of whatever scope it's declared in), and disposal happens at that point. For methods with a single resource and a flat structure, the using declaration reads more cleanly than the block form.

The scope rule matters. The declaration extends to the end of the enclosing block, not to the end of the method when the declaration sits inside a nested block.

The writer lives until the closing brace of the if block, and that's where its Dispose() runs. The line after the if executes after the resource is already released. If you wanted the resource to live longer, you'd either use the block form with an explicit scope or move the declaration to the method's outer level.

Two forms, same compiled output. Pick the form that reads better for the situation:

SituationForm to prefer
Single resource, simple methodusing declaration
Multiple resources at onceEither form; stacked declarations are clean too
Need to control exact disposal pointusing block (explicit braces)
Resource used only inside an inner scopeusing declaration inside that scope
Legacy codebase using block form throughoutMatch the local style

The Dispose Pattern for Class Hierarchies

The simple Dispose() implementation shown earlier works fine for a sealed class with no inheritance. The picture gets more involved when a class meant to be subclassed implements IDisposable, because each derived class may add its own resources that also need to be released, and the base class needs to give subclasses a way to participate in cleanup.

The convention is called the Dispose pattern. The pattern centers on a protected virtual method called Dispose(bool disposing) that subclasses override, plus a public Dispose() method that callers see.

Four things are doing real work in this layout.

  1. The public `Dispose()` is a thin wrapper. It calls the protected Dispose(true). Callers using a using block or calling Dispose() manually go through this path.
  1. The protected `Dispose(bool disposing)` is where the actual cleanup lives. This is what derived classes override.
  1. The `disposing` parameter distinguishes the two ways `Dispose(bool)` can be reached. disposing: true means a caller invoked Dispose() through the normal path, and it's safe to touch other managed objects (which themselves may still be alive). disposing: false means a finalizer is running, and other managed objects may already have been finalized, so touching them is not safe. The finalizer side of this is covered in detail in the _Finalizers (Destructors)_ lesson; for now, the takeaway is that disposing: true is the only path your normal using statements take.
  1. The `_disposed` flag is still there, and it's the very first check in Dispose(bool). Idempotency, again.

A derived class participates by overriding Dispose(bool disposing) and chaining to the base.

The derived class has its own _disposed flag, its own resource to release, and its own override of Dispose(bool). The crucial line is base.Dispose(disposing) at the end, which lets the base class clean up the resources it owns. Without that chain, the base's _writer would never get disposed.

The base class also implements the public Dispose() once, and derived classes inherit it. That's the whole point of the pattern: callers only ever call the public Dispose(), and the virtual Dispose(bool) mechanism takes care of routing cleanup down the inheritance chain.

For a sealed class (one that can't be subclassed), the full pattern is overkill. Without subclasses, there's nothing to virtual-dispatch, and the disposing parameter only matters when finalizers are in the mix. A sealed class can collapse the whole pattern into a single private cleanup method.

For most classes you write, the sealed shortcut is what you want. Use the full pattern only when you genuinely expect the class to be a base class, and even then, prefer composition over inheritance when it's a reasonable option. Finalizers (which are the other reason the full pattern exists) pair with the disposing flag to handle GC-triggered cleanup.

The full Dispose pattern with protected virtual Dispose(bool) doesn't add measurable runtime cost, but it doubles the surface area of the class and introduces a subtle correctness requirement (every derived class must remember base.Dispose(disposing)). Use sealed plus the short pattern whenever you can.

ObjectDisposedException

The _disposed flag has a second job beyond making Dispose() idempotent: it lets the rest of the class refuse to do work on a disposed object.

The convention is to throw ObjectDisposedException from any public method or property that depends on the released resource. .NET 7 added a helper ObjectDisposedException.ThrowIf(condition, instance) that compresses the common check into one line.

The check at the top of CountOrders is what saves the caller from a much more confusing error. Without it, calling CountOrders after Dispose() would dereference a null _connection and throw NullReferenceException with no hint about why. ObjectDisposedException says exactly what's wrong: the object you're using has already been cleaned up.

Pre-.NET 7, the same check is a manual two-liner:

Either form is fine. The helper is just less typing.

Throwing ObjectDisposedException is no cheaper than any other exception, but it's also a debugging exception, not a control-flow one. If your code is throwing it in production, you've got a bug to fix, not a perf issue to optimize.

IAsyncDisposable and await using

Some resources don't release cleanly on a synchronous call. A network connection might need to flush a buffer over the wire before closing, which involves async I/O. A DbContext in modern EF Core might need to send a final round-trip to the database. Blocking those operations on a synchronous Dispose() works, but it pins a thread that should be free to do other work, and on some sync contexts it can deadlock outright.

C# 8 added IAsyncDisposable for exactly this case.

The contract is the same as IDisposable, except cleanup is asynchronous. Resources that implement it pair with the await using statement, which is the async cousin of using.

The mechanics mirror the synchronous form. The compiler emits a try/finally, but the finally calls await DisposeAsync() instead of Dispose(). The current method's Task doesn't complete until the async cleanup has finished, which means the caller can chain work after the await with the resource already released.

The flow for await using adds an extra wrinkle: the finally block itself awaits the cleanup, which can suspend the method.

Some types implement both IDisposable and IAsyncDisposable. StreamWriter and FileStream are good examples. If you're in an async context, prefer await using because the async path avoids blocking. If the type only has IDisposable, regular using is fine. The compiler will pick the right method based on which interfaces the type implements: await using requires IAsyncDisposable, plain using requires IDisposable.

A custom implementation looks much like the synchronous form, just with a ValueTask-returning method.

The ValueTask return type is what IAsyncDisposable specifies. It's slightly cheaper than Task for the common synchronous-completion case (no allocation when there's nothing actually async to do), which is why the interface uses it instead of Task.

await using adds the cost of the await (a possible suspension and continuation), which is real but minor. Don't avoid it for performance; the alternative is blocking a thread during cleanup, which is much worse on real workloads.

IDisposable in the BCL

A handful of base class library types implement IDisposable, and they're the ones you'll wrap in using blocks day in and day out. Recognizing them by name is the difference between writing leaky code and writing solid code.

TypeResourceNotes
FileStream, StreamReader, StreamWriterOS file handleAlways wrap in using. Forgetting locks the file.
SqlConnection, NpgsqlConnection, SqliteConnectionDatabase connection (pooled)Returning to the pool requires disposal.
HttpRequestMessage, HttpResponseMessageBuffers and underlying streamDispose after reading the body.
CancellationTokenSourceOS timer (if CancelAfter was used)Dispose to avoid timer leaks.
SemaphoreSlim, ManualResetEventSlimOS sync primitiveDispose when the wait pattern is done.
ProcessOS process handleDispose after starting and reading output.
Timer (System.Threading)OS timerImplements both IDisposable and IAsyncDisposable.
DbContext (EF Core)Connection plus tracked entitiesUse await using in async code.

HttpClient deserves its own paragraph because it's a famous trap. HttpClient implements IDisposable, and a learner's first instinct is to wrap every use in a using block. That instinct is wrong for HttpClient specifically. Each HttpClient owns a connection pool, and disposing one closes the underlying sockets in a way that can leave them in the TIME_WAIT state for minutes. A web app that creates and disposes an HttpClient per request can run out of available sockets under load. The standard guidance is to share a long-lived HttpClient (or use IHttpClientFactory from Microsoft.Extensions.Http) and not wrap it in using. The interface is implemented, but the lifetime model is different from a file stream.

A correct FileStream pattern, by contrast, is the textbook case:

The using declaration releases the file handle at the end of the enclosing scope. No need to think about it; the compiler emits the try/finally and the OS gets its handle back.

CancellationTokenSource is a sneaky one. The base case (a CancellationTokenSource you cancel manually) leaks very little. But once you call CancelAfter(TimeSpan.FromSeconds(5)), the source owns an OS timer that won't be released until the source is disposed. In a long-running server, those timer references can accumulate.

The using declaration on cts ensures the timer is released as soon as the method ends, even if the awaited task threw or was canceled.

A Custom IDisposable on an E-Commerce Type

A complete example pulls the pieces together. ReceiptWriter wraps a StreamWriter to format receipts for an online store. It implements IDisposable so callers can use it with a using declaration, and it throws ObjectDisposedException from every public method that would touch the wrapped stream after disposal.

Five things are worth pointing at. The class is sealed, so it gets the short Dispose pattern instead of the full virtual form. The _writer field is nullable (StreamWriter?) so the Dispose() method can null it out, which both helps the GC reclaim it sooner and makes the disposal idempotent without an extra flag check. Every public method calls ObjectDisposedException.ThrowIf so calling them after disposal produces a clear, specific error. The using block in Main guarantees disposal even if an AddItem or Finish throws. And after disposal, File.ReadAllText can read the file because the OS handle was released cleanly before the next line of code ran.

The OrderExporter from the earlier IAsyncDisposable section is the async sibling of this class. In a real codebase, you'd pick one based on the surrounding code: if the calling method is async and writes are big enough to benefit from async I/O, use the async exporter. If the work is small or synchronous, the sync receipt writer is simpler.

Common Mistakes

Three mistakes account for the majority of IDisposable bugs in real code.

Mistake 1: forgetting `using` altogether. A method opens a FileStream, writes to it, and never disposes it. The handle stays open until the GC runs, which can be much later. Under load, the process runs out of handles. The fix is always the same: wrap the resource in using (or await using for async types).

Mistake 2: disposing a long-lived resource. This is the HttpClient story in miniature. Some types are designed to be reused across the lifetime of the application or component. Wrapping them in using and disposing them per call defeats the design and can introduce subtle problems (socket exhaustion for HttpClient, connection-pool churn for some DB clients used incorrectly). Check the documentation: if the type is designed to be a singleton or factory-managed, treat it as such.

Mistake 3: throwing from inside `Dispose()`. A Dispose() that throws can mask the original exception that was already on its way out of the block, leaving the caller with a confusing cleanup error instead of the root cause. The rule of thumb is to make Dispose() boring: it releases what it owns, swallows or logs any cleanup failure, and returns. If a cleanup failure genuinely matters (rare), expose it through a separate Close() or FlushAsync() method that callers can opt into, and keep Dispose() quiet.

Quiz

IDisposable & using Quiz

10 quizzes