Implementing IDisposable to Suppress Finalizer Calls in .NET

Preventing Redundant Cleanup Work

If you've ever written a class that manages unmanaged resources like file handles or database connections, you've probably added a finalizer for safety. The problem is that finalizers run during garbage collection even when you've already cleaned up in Dispose, creating redundant work that slows down your application.

The GC.SuppressFinalize method tells the garbage collector to skip finalization for your object since you've already released resources. This prevents the object from entering the finalization queue, reduces GC pressure, and improves performance. Without this call, your object survives to generation 2, making collection slower and keeping memory allocated longer than necessary.

You'll build a resource-managing class that implements IDisposable correctly, learn when to suppress finalization, and understand how this affects garbage collection behavior. You'll also see the performance difference between proper disposal and letting finalizers do the work.

Understanding the Dispose Pattern

The IDisposable pattern gives callers a way to release resources deterministically instead of waiting for garbage collection. When you implement Dispose and call GC.SuppressFinalize, you tell the runtime that finalization is unnecessary since cleanup already happened.

Here's the standard pattern that combines Dispose with finalizer suppression. This approach ensures resources are freed promptly when Dispose is called, while still providing a safety net through the finalizer if someone forgets to dispose.

ResourceManager.cs
using System;
using System.Runtime.InteropServices;

public class UnmanagedResourceWrapper : IDisposable
{
    private IntPtr _unmanagedHandle;
    private bool _disposed = false;

    public UnmanagedResourceWrapper()
    {
        // Allocate unmanaged memory (simulated)
        _unmanagedHandle = Marshal.AllocHGlobal(1024);
    }

    public void Dispose()
    {
        // Call the virtual Dispose method
        Dispose(disposing: true);

        // Suppress finalization since we cleaned up
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        if (disposing)
        {
            // Free managed resources here
            // (none in this example)
        }

        // Free unmanaged resources
        if (_unmanagedHandle != IntPtr.Zero)
        {
            Marshal.FreeHGlobal(_unmanagedHandle);
            _unmanagedHandle = IntPtr.Zero;
        }

        _disposed = true;
    }

    ~UnmanagedResourceWrapper()
    {
        // Finalizer calls Dispose without managed cleanup
        Dispose(disposing: false);
    }
}

The GC.SuppressFinalize call is crucial here. Without it, the finalizer runs even after you call Dispose, performing the same cleanup twice. This wastes CPU cycles and keeps the object alive longer since finalized objects survive an extra GC cycle.

Why Suppressing Finalization Matters

Objects with finalizers follow a special garbage collection path. When first allocated, they're registered in a finalization queue. During collection, instead of being freed immediately, they're moved to a separate queue where the finalizer thread processes them. Only after finalization completes can the memory be reclaimed in the next GC cycle.

This means finalized objects always survive at least one extra collection and typically get promoted to generation 2, where collections are rare and expensive. By calling GC.SuppressFinalize in Dispose, you remove the object from the finalization queue so it can be collected normally in generation 0 or 1.

FileWrapper.cs
using System;
using System.IO;

public class FileWrapper : IDisposable
{
    private FileStream? _fileStream;
    private bool _disposed = false;

    public FileWrapper(string filePath)
    {
        _fileStream = new FileStream(filePath, FileMode.OpenOrCreate,
            FileAccess.ReadWrite);
    }

    public void Write(byte[] data)
    {
        if (_disposed)
            throw new ObjectDisposedException(nameof(FileWrapper));

        _fileStream?.Write(data, 0, data.Length);
    }

    public void Dispose()
    {
        Dispose(disposing: true);

        // Critical: prevent finalizer from running
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        if (disposing)
        {
            // Dispose managed resources
            _fileStream?.Dispose();
            _fileStream = null;
        }

        // No unmanaged resources in this case
        _disposed = true;
    }

    ~FileWrapper()
    {
        // Finalizer for safety if Dispose not called
        Dispose(disposing: false);
    }
}

When callers use this class with a using statement or explicit Dispose call, the file gets closed immediately and GC.SuppressFinalize prevents the finalizer from running. If they forget to dispose, the finalizer provides cleanup during GC, though much later than ideal.

Using SafeHandle to Avoid Manual Finalization

For most unmanaged resource scenarios, you should use SafeHandle instead of writing custom finalizers. SafeHandle wraps critical finalization logic and handles GC.SuppressFinalize automatically when disposed, reducing the chance of mistakes.

SafeResourceHandle.cs
using System;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;

// Custom SafeHandle for native resource
public class SafeNativeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    public SafeNativeHandle() : base(ownsHandle: true)
    {
    }

    protected override bool ReleaseHandle()
    {
        // Free the native resource
        if (handle != IntPtr.Zero)
        {
            Marshal.FreeHGlobal(handle);
            return true;
        }
        return false;
    }
}

// Wrapper using SafeHandle
public class NativeResourceManager : IDisposable
{
    private readonly SafeNativeHandle _handle;

    public NativeResourceManager(int size)
    {
        var ptr = Marshal.AllocHGlobal(size);
        _handle = new SafeNativeHandle();
        _handle.SetHandle(ptr);
    }

    public void Dispose()
    {
        // SafeHandle handles suppression internally
        _handle?.Dispose();
    }

    // No finalizer needed - SafeHandle has one
}

SafeHandle manages the finalization lifecycle for you. Its internal finalizer ensures cleanup happens if you forget to dispose, and it calls GC.SuppressFinalize when disposed properly. This is safer and less error-prone than manual finalizer management.

Mistakes to Avoid

Forgetting to call GC.SuppressFinalize is the most common mistake. When this happens, the finalizer runs unnecessarily, the object survives an extra GC cycle, and performance suffers. Always pair finalizers with Dispose and always call SuppressFinalize in Dispose.

Another pitfall is calling GC.SuppressFinalize before cleanup completes. If an exception occurs in Dispose before you suppress finalization, the finalizer might not run when needed. Always place the SuppressFinalize call after cleanup succeeds, or use a try-finally block to ensure it happens only on success.

Some developers suppress finalization in the wrong place, such as in the protected Dispose(bool) method. This causes issues with derived classes. The SuppressFinalize call belongs in the public Dispose() method, after calling the virtual cleanup method. This ensures derived classes can extend cleanup without worrying about suppression timing.

Finally, don't implement a finalizer unless you actually manage unmanaged resources. Finalizers add GC overhead even when suppressed. If your class only holds managed resources like database connections or streams, implement IDisposable without a finalizer and skip the SuppressFinalize call entirely.

Try It Yourself

Build a small console app that compares disposal with and without GC.SuppressFinalize to see the impact on garbage collection behavior.

Steps

  1. Create a new project: dotnet new console -n FinalizerDemo
  2. Navigate to folder: cd FinalizerDemo
  3. Replace the Program.cs content with the code below
  4. Execute: dotnet run
FinalizerDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>
Program.cs
using System;

class ResourceWithSuppression : IDisposable
{
    private bool _disposed = false;

    public void Dispose()
    {
        if (_disposed) return;
        Console.WriteLine("Dispose called - suppressing finalizer");
        _disposed = true;
        GC.SuppressFinalize(this);
    }

    ~ResourceWithSuppression()
    {
        Console.WriteLine("Finalizer called (should not see this)");
    }
}

class ResourceWithoutSuppression : IDisposable
{
    private bool _disposed = false;

    public void Dispose()
    {
        if (_disposed) return;
        Console.WriteLine("Dispose called - NOT suppressing finalizer");
        _disposed = true;
        // Missing GC.SuppressFinalize(this)
    }

    ~ResourceWithoutSuppression()
    {
        Console.WriteLine("Finalizer called (redundant!)");
    }
}

Console.WriteLine("=== Test 1: Proper suppression ===");
using (var res = new ResourceWithSuppression())
{
    // Use resource
}
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine();

Console.WriteLine("=== Test 2: Missing suppression ===");
using (var res = new ResourceWithoutSuppression())
{
    // Use resource
}
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine();

Console.WriteLine("Comparison complete.");

What you'll see

=== Test 1: Proper suppression ===
Dispose called - suppressing finalizer

=== Test 2: Missing suppression ===
Dispose called - NOT suppressing finalizer
Finalizer called (redundant!)

Comparison complete.

The first test shows no finalizer execution because GC.SuppressFinalize prevented it. The second test runs both Dispose and the finalizer, demonstrating the redundant work that happens without suppression.

Common Questions

Why should I call GC.SuppressFinalize in Dispose?

Calling GC.SuppressFinalize tells the garbage collector to skip finalization for your object since you already cleaned up resources in Dispose. This improves performance by avoiding the finalization queue and reducing GC pressure. Without it, the finalizer runs unnecessarily even after manual cleanup.

What happens if I forget to call GC.SuppressFinalize?

Your finalizer still runs during garbage collection even though Dispose already cleaned up. This creates redundant work, keeps objects alive longer, and slows down GC. The object survives to Gen 2, adding memory pressure. Always call SuppressFinalize in Dispose to avoid this waste.

Is GC.SuppressFinalize thread-safe?

Yes, GC.SuppressFinalize is thread-safe and can be called from any thread. However, your Dispose implementation should protect against concurrent calls using proper locking or a disposed flag check to prevent double-disposal issues.

Can I call SuppressFinalize without a finalizer?

Yes, calling GC.SuppressFinalize on an object without a finalizer is safe but unnecessary. It does nothing since there's no finalizer to suppress. Include it only if your class or its base class defines a finalizer, or if following a consistent IDisposable pattern across types.

Should I use SafeHandle instead of finalizers?

Yes, SafeHandle and SafeHandleZeroOrMinusOneIsInvalid are preferred for managing unmanaged resources like file handles or native pointers. They handle finalization correctly and are exception-safe. Only write custom finalizers when SafeHandle doesn't fit your scenario, which is rare.

Back to Articles