A managed object can hold a file handle, connection, timer or other resource whose useful lifetime is shorter than the time until garbage collection. Waiting for memory reclamation can leave that resource unavailable to another operation. Disposal provides an explicit cleanup boundary. It is also an ownership rule: the code responsible for a resource needs to know whether it should close that resource or merely borrow it.
This guide uses .NET 8 and C# 12. You will observe deterministic cleanup with a small disposable object, implement a wrapper that optionally owns a stream, and compare synchronous with asynchronous disposal. The examples use memory streams, so their output is independent of filesystem paths and external services.
Garbage collection and disposal have different jobs
Garbage collection reclaims managed memory that is no longer reachable. It does not provide a prompt application-level guarantee that a file, socket or similar resource is released exactly when an operation finishes. IDisposable.Dispose gives callers a method for that cleanup contract.
Calling Dispose does not immediately delete the managed object from memory. A reference can still exist, so the class should define what subsequent operations mean. Most resource wrappers reject use after disposal through ObjectDisposedException. Cleanup is normally idempotent: a second call should not repeat destructive work or fail merely because the first call succeeded. A finalizer is a separate fallback mechanism for certain unmanaged ownership patterns, not something every class with IDisposable needs. Prefer established safe-handle abstractions when directly wrapping native handles.
Using defines a deterministic scope
A using declaration disposes its resource when execution leaves the containing scope. A using statement narrows that scope to its body. Cleanup occurs on normal completion and when control exits through an exception. Multiple resources declared in nested scopes are disposed in reverse acquisition order, which suits dependencies that must outlive their users.
Choose the scope deliberately. A using declaration at the top of a long method can retain a resource longer than intended. A narrower statement can release it before unrelated work starts. Do not return a stream or deferred iterator from a scope that immediately disposes the underlying resource unless the return contract is intentionally a completed snapshot. The visible variable's lifetime is not necessarily the consumer's required resource lifetime.
Ownership should be explicit at the boundary
A wrapper receiving a stream must say whether it takes ownership or borrows it. If every wrapper independently disposes the same stream, one component can unexpectedly break another. A leaveOpen option is a common way to let callers preserve ownership. Whatever convention you choose, document it and test it.
Dependency injection introduces another ownership boundary: a container commonly disposes disposable services that it creates under its lifetime rules. Code receiving an injected service should not casually dispose it as though it had constructed the service itself. Similarly, passing a resource to a helper does not inherently transfer ownership. Use a clear factory, constructor contract or method name when transfer is intended. Sharing a resource does not erase the need to identify one responsible owner.
Async cleanup must be awaited
Some resources need asynchronous work to flush or release their state efficiently. IAsyncDisposable exposes DisposeAsync, and await using awaits it when leaving the scope. It is available from C# 8. Do not call an asynchronous disposal method and discard its returned operation: the caller may then proceed before cleanup completes.
An implementation may offer both synchronous and asynchronous cleanup, but that requires a coherent policy for releasing the same owned state exactly once. A sealed wrapper around an async-disposable resource can often delegate directly; an inheritable type needs a more careful pattern. Avoid blocking on an asynchronous method with .Result or .Wait() as a casual substitute for asynchronous cleanup. Carry the async requirement through the caller when the resource contract requires it.
Test cleanup as observable behavior
Use a small tracking fake to test that an owned dependency is disposed once and a borrowed dependency remains available. Test normal completion, an exception in the operation, repeated disposal and use after disposal. If async cleanup is involved, ensure the caller actually awaits completion rather than merely initiating it.
Do not add empty Dispose methods to unrelated classes to satisfy a pattern. A class needs disposal when it owns disposable or unmanaged resources, or when it participates in a meaningful cleanup contract. Fields containing ordinary strings or lists alone do not require it. Keep cleanup free of unexpected long-running work when possible, and make the required lifetime clear to callers so correctness does not depend on guessing when garbage collection occurs.
Official references
C# Disposal and Resource Ownership Examples
Each example shows the code, the output it produces and what each part does.
Observe cleanup after an exception
The complete example uses a simple operation-owned resource. Its output makes the cleanup order visible without opening a real file.
The compiler arranges cleanup around the scope. The exception propagates only after cleanup has been attempted. Real cleanup can itself fail, so a class should not claim that every disposal operation is infallible; design how important failures are reported at the application boundary.
using System;
try
{
using var resource = new TraceResource();
resource.Work();
throw new InvalidOperationException("Example failure");
}
catch (InvalidOperationException)
{
Console.WriteLine("Caller handled failure");
}
public sealed class TraceResource : IDisposable
{
private bool disposed;
public void Work()
{
ObjectDisposedException.ThrowIf(disposed, this);
Console.WriteLine("Working");
}
public void Dispose()
{
if (disposed)
return;
disposed = true;
Console.WriteLine("Resource released");
}
}Working
Resource released
Caller handled failureC# Disposal and Resource Ownership in Real Projects
Worked exercise: a wrapper with borrowed ownership
The task is to create a wrapper that can either own or borrow a stream. After disposing a borrowing wrapper, the caller must still be able to write to its stream. The wrapper itself must reject further writes. This complete example demonstrates both parts of that contract with a memory stream.
using System;
using System.IO;
using var stream = new MemoryStream();
var writer = new ByteWriter(stream, leaveOpen: true);
writer.Write(65);
writer.Dispose();
writer.Dispose();
stream.WriteByte(66);
Console.WriteLine($"Bytes: {stream.Length}");
try
{
writer.Write(67);
}
catch (ObjectDisposedException)
{
Console.WriteLine("Wrapper is closed");
}
public sealed class ByteWriter : IDisposable
{
private readonly Stream stream;
private readonly bool leaveOpen;
private bool disposed;
public ByteWriter(Stream stream, bool leaveOpen)
{
this.stream = stream ??
throw new ArgumentNullException(nameof(stream));
this.leaveOpen = leaveOpen;
}
public void Write(byte value)
{
ObjectDisposedException.ThrowIf(disposed, this);
stream.WriteByte(value);
}
public void Dispose()
{
if (disposed)
return;
disposed = true;
if (!leaveOpen)
stream.Dispose();
}
}Bytes: 2
Wrapper is closedThe outer using owns the stream. The wrapper borrows it and closes only its own interface. Repeating wrapper disposal is harmless. With leaveOpen: false, ownership would instead require the wrapper to dispose the stream.
C# Disposal and Resource Ownership FAQ
Does Dispose force garbage collection?
No. Disposal releases resources under the class's contract; garbage collection independently reclaims unreachable managed memory. The object can remain referenced after disposal, which is why use-after-dispose behavior matters. Calling garbage collection manually is not a replacement for correct ownership. Use a deterministic scope for operation-owned resources and let the runtime manage ordinary memory reclamation.
Who disposes an injected service?
Usually the dependency-injection container disposes services it creates according to the registered lifetime and scope. A consumer borrowing that service should not close it independently. Ownership can differ for externally supplied instances, so check the registration contract. Creating a resource yourself is a strong clue that you own it, but APIs can deliberately transfer ownership and should document that transfer.
Should every IDisposable class have a finalizer?
No. A class that owns other managed disposable objects normally disposes those objects without adding its own finalizer. Direct unmanaged ownership needs an appropriate fallback pattern, commonly using a safe handle. Finalizers add complexity and have nondeterministic timing. They cannot replace prompt cleanup when a resource must be released before the next application operation.
When should I choose await using?
Use it when the resource implements IAsyncDisposable and its asynchronous cleanup contract is appropriate for the operation. The containing method must support awaiting. It ensures cleanup is awaited on scope exit, including exceptional exit. Do not discard DisposeAsync or block synchronously without a documented reason. An API offering both disposal forms should explain their behavior and ownership consistency.
C# Disposal and Resource Ownership Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
A resource has asynchronous cleanup that must complete before proceeding. Which form expresses that contract?
C# Disposal and Resource Ownership Quiz
2 questions to check what you learned.
What does using primarily guarantee?
- A disposal attempt when execution leaves the defined scope.
- Immediate reclamation of the object's managed memory.
- That Dispose can never throw.
- That the resource becomes safe to share across threads.
A wrapper receives a borrowed stream with leaveOpen true. What should disposal do?
- Close the stream because every parameter transfers ownership.
- Close the wrapper's own interface while preserving the caller-owned stream.
- Leave both the wrapper and stream usable.
- Force a collection to release the stream eventually.