Viorel Buliga
About me
← All articles
C#.NETOOPFundamentalsBackend

Polymorphism in .NET Explained: Virtual Dispatch, the new vs override Trap, and When to Use It

August 2, 2026·11 min read
Quick answer: Polymorphism in .NET comes in two forms. Compile-time polymorphism is method overloading and generics - the compiler picks the target. Run-time polymorphism is virtual/override - the CLR looks up the object's actual type and calls the most-derived override, even through a base-class reference. The single most common bug in this area is using new instead of override, which hides a method rather than overriding it, so the method called depends on the reference type, not the object.

Most polymorphism explanations stop at "many forms" and a Shape.Draw() example. That is the easy 20%. The parts that actually bite in production are the ones underneath: why a base-typed variable sometimes calls the "wrong" method, why forgetting a keyword changes behaviour silently, and why an override can lose an overload-resolution contest it looks like it should win. Here is the whole picture, grounded in the C# spec and the docs.

Polymorphism in .NET: one base-class call dispatching to different derived implementations at run time

What is polymorphism, really?

The docs call it "the third pillar of object-oriented programming, after encapsulation and inheritance", and give it "two distinct aspects":

  • "At run time, objects of a derived class can be treated as objects of a base class... the object's declared type is no longer identical to its run-time type."
  • Base classes define virtual methods, derived classes override them, and "at run-time, when client code calls the method, the CLR looks up the run-time type of the object, and invokes that override".

That gap - between the declared type of a variable and the run-time type of the object it points at - is the whole subject. Everything else is detail about who resolves the call, and when.

And it is universal in .NET: "every type is polymorphic because all types, including user-defined types, inherit from Object." Even int can be handed to a method that takes object.

Compile-time or run-time? The distinction that organises everything

The word covers two mechanisms that resolve at completely different moments.

KindMechanismWho decides, and when
Compile-time (static)Method overloadingThe compiler, from the argument types at the call site.
Compile-time (static)GenericsThe compiler, from the type argument - one algorithm, many types.
Run-time (dynamic)virtual / overrideThe CLR, from the object's actual type when the call executes.
Run-time (dynamic)InterfacesThe CLR, dispatching to whatever type implements the interface.

When people say "polymorphism" without qualification, they almost always mean the run-time kind - so that is where the depth is. But keep the split in mind, because the classic bug lives exactly on the seam between them.

How does virtual dispatch actually work?

A virtual method is a promise that the real implementation is chosen late. Every object carries a reference to its type's method table; a virtual call does not jump to a fixed address, it looks up the slot for that method in the table of the object's actual type and calls whatever is there. That indirection is the run-time cost of polymorphism, and it is small - but it is not zero, which matters later.

Two keywords set it up. The base declares virtual; the derived class writes override. A crucial default from the docs: "By default, C# methods are not virtual." If you do not write virtual, no amount of overriding will happen.

public abstract class Notification
{
    public abstract string Channel { get; }

    public virtual void Send(string message)
    {
        Console.WriteLine($"[{Channel}] {message}");
    }
}

public class SmsNotification : Notification
{
    public override string Channel => "SMS";

    public override void Send(string message)
    {
        // SMS has a hard length limit - enforce it, then delegate.
        base.Send(message.Length > 160 ? message[..160] : message);
    }
}

public class EmailNotification : Notification
{
    public override string Channel => "Email";
    // No override of Send - inherits the base behaviour unchanged.
}

Now the payoff. You can hold any of them in a Notification variable and call Send without knowing the concrete type - the CLR routes each call to the right override:

List<Notification> channels =
[
    new SmsNotification(),
    new EmailNotification()
];

foreach (var channel in channels)
    channel.Send("Your order shipped");   // SMS truncates; Email does not

Note the base.Send(...) call in SmsNotification. The docs recommend it explicitly: "virtual members use base to call the base class implementation... Letting the base class behavior occur enables the derived class to concentrate on implementing behavior specific to the derived class."

abstract vs virtual, in one line. virtual ships a default the derived class may replace; abstract ships no body and forces every concrete derived class to supply one. Use abstract when there is no sensible default - like Channel above, where the base has nothing meaningful to return.

The trap: new hides, it does not override

This is the single most common polymorphism bug, and the docs state the mechanism plainly: "With method hiding, the method that gets called depends on the compile-time type of the variable, not the run-time type of the object."

Write new instead of override - or forget the keyword entirely - and you get a method that hides the base one. Virtual dispatch does not apply. The call resolves against the type of the reference, so the same object behaves differently depending on how you are holding it.

public class BaseHandler
{
    public virtual void Handle() => Console.WriteLine("Base");
}

public class GoodHandler : BaseHandler
{
    public override void Handle() => Console.WriteLine("Overridden");
}

public class BadHandler : BaseHandler
{
    public new void Handle() => Console.WriteLine("Hidden");   // new, not override
}
BaseHandler good = new GoodHandler();
good.Handle();   // "Overridden"  - virtual dispatch, run-time type wins

BaseHandler bad = new BadHandler();
bad.Handle();    // "Base"        - hiding, compile-time type wins (!)

((BadHandler)bad).Handle();   // "Hidden" - same object, different reference type

Same object, two answers, decided entirely by the declared type of the variable. In a polymorphic loop over List<BaseHandler>, BadHandler silently runs the base behaviour - a bug that passes every unit test written against the concrete type and fails only through the base-class reference.

The compiler warns you - do not ignore it. If you write neither new nor override, the docs say "the compiler will issue a warning and the method will behave as if the new keyword were present" (warning CS0108). That warning is the compiler telling you that you are hiding when you probably meant to override. Treat it as an error in your build.

The subtle one even seniors miss: override and overload resolution

Here is a corner that surprises people who have written C# for years. An override does not count as a method "declared on" the class that overrides it - and that changes overload resolution.

public class Base
{
    public virtual void DoWork(int param) => Console.WriteLine("int");
}

public class Derived : Base
{
    public override void DoWork(int param) => Console.WriteLine("int override");
    public void DoWork(double param) => Console.WriteLine("double");
}
int val = 5;
Derived d = new();

d.DoWork(val);          // "double" (!) - not the int override
((Base)d).DoWork(val);  // "int override" - forced through the base list

Straight from the docs: "Override methods are not considered as declared on a class, they are new implementations of a method declared on a base class." So the compiler first looks at methods genuinely declared on Derived - which is only DoWork(double) - and since an int converts implicitly to double, that wins. The int override is only reached by casting to Base. The docs' own advice: "avoid declaring new methods with the same name as virtual methods."

Interfaces: polymorphism without inheritance

A class has one base class but can implement many interfaces, which makes interfaces the more flexible route to run-time polymorphism. The dispatch is the same idea - the CLR calls whichever type implements the member - but you are not locked into a single hierarchy.

public interface IExportable
{
    string Export();

    // Default interface method (C# 8+): implementers inherit this
    // unless they choose to provide their own.
    string ExportWithHeader() => $"# Export\n{Export()}";
}

public class Invoice : IExportable
{
    public string Export() => "invoice-data";
}

Since C# 8, an interface can ship a default implementation, so adding ExportWithHeader to an interface no longer breaks every existing implementer. It is a controlled way to evolve an interface - close to what a base class gives you, without forcing inheritance.

sealed: stopping the chain, and a free optimisation

A virtual member stays virtual all the way down a hierarchy - unless a class stops it. Putting sealed before override ends the chain: "the method... is no longer virtual to any class derived from" that point.

public class C : B
{
    public sealed override void DoWork() { }   // no further overriding
}

There is a performance angle worth knowing. When the JIT can prove a call has exactly one possible target - because the type or method is sealed, or the object's concrete type is known - it can devirtualise the call, replacing the method-table lookup with a direct call and sometimes inlining it. Sealing types you never intend to derive from is a cheap way to hand the JIT that certainty. It is a micro-optimisation, not a headline, but it is real and it is free.

When should you reach for it - and when not?

Virtual dispatch is the right tool when you expect new types and a fixed set of operations. Adding a new Notification subtype touches one new file and nothing else - the existing loop just works. That is the open/closed principle paying off.

It is the wrong tool when the shape is inverted: a fixed set of types and a growing set of operations. Every new operation would mean touching every subtype. Modern C# offers a cleaner answer there - pattern matching keeps the operation in one place:

decimal Fee(Notification n) => n switch
{
    SmsNotification   => 0.02m,
    EmailNotification => 0.00m,
    _                 => throw new NotSupportedException(n.Channel)
};

The trade-off is exactly reversed from virtual dispatch. Pattern matching makes adding an operation trivial and adding a type a chore (you must revisit every switch); virtual methods make adding a type trivial and adding an operation a chore. Pick the one whose "trivial" axis matches how your code actually grows. That single question settles most "should this be virtual?" debates.

Frequently asked questions

What is the difference between overloading and overriding?

Overloading is compile-time polymorphism: several methods share a name but differ in parameters, and the compiler picks one from the argument types at the call site. Overriding is run-time polymorphism: a derived class replaces a base class virtual method, and the CLR picks the implementation from the object's actual type when the call runs. Overloading is resolved before the program runs; overriding is resolved while it runs.

Why does my base-class variable call the wrong method?

Almost always because the derived method uses new (or no keyword) instead of override, so it hides the base method rather than overriding it. With hiding, the method called depends on the compile-time type of the variable, not the run-time type of the object. Change new to override - and make sure the base method is virtual or abstract - and the call will dispatch to the derived version through any reference.

Are C# methods virtual by default?

No. The docs state it directly: by default, C# methods are not virtual. This is the opposite of some languages. A method only participates in run-time polymorphism if the base declares it virtual (or abstract) and the derived class marks its version override.

What happens if I forget both new and override?

The compiler issues warning CS0108 and treats the method as if you had written new - so it hides rather than overrides. Because it compiles and runs, the bug is easy to miss. Enable warnings-as-errors, or at least never dismiss CS0108, since it usually means you meant to override.

Can an interface have method implementations?

Yes, since C# 8, via default interface methods. An interface member can carry a body that implementers inherit unless they provide their own. It lets you add members to an existing interface without breaking every implementer, which used to require a base class.

Should I use virtual methods or pattern matching?

It depends on which axis grows. Virtual dispatch makes adding new types easy and adding new operations costly, because a new operation touches every subtype. Pattern matching makes adding operations easy and adding types costly, because a new type touches every switch. Choose the one whose cheap direction matches how your code evolves.

Official resources

  • Polymorphism - C#, Microsoft Learn
  • Versioning with the override and new keywords - C#, Microsoft Learn
  • virtual keyword - C# reference, Microsoft Learn
  • Default interface methods - C# feature spec, Microsoft Learn
Share on LinkedIn