Last Updated: May 17, 2026
The Inheritance section introduced three keywords that all let a derived class declare a method with the same name as a base method: virtual, override, and new. The syntax is settled by this point; what hasn't been settled is the way they interact during dispatch. This lesson compares the three side by side, with the focus squarely on which method actually runs when you call through a base reference and why.
A short refresher before the comparison, just enough to anchor the syntax.
`virtual` marks a base-class member as overridable. The keyword goes between the access modifier and the return type, and it tells the compiler "derived classes may replace this body." A virtual method still has a default implementation that runs when no derived class overrides it.
`override` is the matching keyword on the derived side. The derived method declares the same signature as the base, prefixes it with override, and replaces the inherited body. The two halves are required: you can't override without virtual on the base, and you can't drop override from a derived method without the compiler raising a warning.
`new` (used as a member modifier, not the object-creation operator) declares that a derived member intentionally hides a base member that shares its name. The new keyword silences the CS0108 warning and documents intent. The base method does not need to be virtual. Hiding is its own mechanism, separate from overriding.
That's the syntactic surface. The interesting question is what happens at runtime when you mix these keywords with base-class references, and that's where dispatch behavior splits the three into two camps.
Every polymorphism question boils down to a single sentence: given a variable typed as Base that actually holds a Derived object, which version of method M runs when you write b.M()?
Three pieces of information feed into that decision:
Base).Derived).virtual + override, or new, or no modifier at all).For virtual + override, only the runtime type matters. The compiler emits a virtual call instruction, the runtime looks up the method through the object's method table, and the most-derived override body wins. This is the behavior the _Method Overriding_ lesson covered as runtime polymorphism.
For new, only the static type matters. The compiler resolves the call at compile time using the variable's declared type, picks the method body that belongs to that type, and emits a direct call. The runtime never asks the object what it actually is. This is the behavior the _new Keyword (Method Hiding)_ lesson called "the dispatch surprise."
Here is the contrast in one program. Two classes, one method, only the modifiers differ between the two examples.
Now flip the modifiers to use hiding:
Same call site, same object on the heap, opposite result. The single difference is whether M participates in virtual dispatch. Override puts it in, new keeps it out. Everything else in this chapter is consequences of that one fact.
The mental model worth keeping in your head: every variable has two types associated with it, and which one the compiler or runtime consults depends on the method's modifiers.
The static type (also called the declared type or compile-time type) is whatever you wrote on the left side of the variable declaration. In Cart c = new LoyaltyCart(), the static type of c is Cart. This is the only type the compiler sees during compilation. It's what shows up in IntelliSense, what the compiler uses to check method access, and what it uses for resolving non-virtual calls.
The runtime type (also called the dynamic type or actual type) is whatever object the variable is pointing to right now. In the same line, the runtime type of the object is LoyaltyCart. This is the type the runtime uses when it needs to make a polymorphic decision: looking up a virtual method, evaluating is and as expressions, or returning a value from GetType().
A short example makes the split visible:
Both queries land on Book because both depend on the actual object, not the variable declaration. If you swap Kind() to be non-virtual and add new to the derived version, the Kind() call switches to Product, but GetType() still returns Book. GetType() is purely runtime; the virtual-method lookup is too. Non-virtual calls are the only piece that cares about the static type.
A diagram captures how each modifier routes a call.
The diagram is one question: is M virtual in the base? If yes, the runtime type drives the call. If no, the static type drives it. Hiding lives on the right branch; overriding lives on the left.
Two-level examples cover the basics, but the polymorphism question gets interesting when an inheritance chain mixes override and new at different levels. Walk through this hierarchy carefully.
Three classes, one method name, three different bodies, with a mix of override and new along the way. Now consider four calls that all point to the same HolidayGiftCart instance.
Predict the output, then keep reading.
The output is:
Walk through each line.
For h.Summary(): the static type is HolidayGiftCart. The compiler looks at HolidayGiftCart.Summary, which is a new method (not an override), so it emits a direct (non-virtual) call to that body. The runtime type doesn't matter because the call isn't virtual. Output: HolidayGiftCart.
For g.Summary(): the static type is GiftCart. The compiler looks at GiftCart.Summary, which is an override of Cart.Summary (a virtual chain). It emits a virtual call. At runtime, the system walks the method table for the actual object's type, looking for the most-derived override of the virtual chain that started at `Cart`. HolidayGiftCart.Summary is marked new, so it is not part of that chain. The most-derived override in the chain is GiftCart.Summary. Output: GiftCart.
For c.Summary(): the static type is Cart. The compiler emits a virtual call, same as before, because Cart.Summary is virtual. The runtime does the same lookup against the same virtual chain, finds the same most-derived override, and runs GiftCart.Summary. Output: GiftCart again.
The HolidayGiftCart.Summary body is only reachable through a reference declared as HolidayGiftCart. Through any base reference, it's invisible, even though it's physically present on the object. This is what people mean when they say new breaks polymorphism: the override chain stops at GiftCart, and the leaf class participates in dispatch only when callers know its concrete type.
Putting the three forms next to each other clarifies which one belongs where. The columns below are the questions every dispatch decision hinges on.
| Form | What the compiler does | What the runtime does | Polymorphic? | When to use |
|---|---|---|---|---|
virtual in base, override in derived | Emits a virtual call (callvirt) | Looks up the method via the object's type | Yes, derived body always runs through any base reference | Designed-for-extension methods in a class hierarchy |
| Non-virtual in base, no derived override | Emits a direct call (call) on the declared type | Calls the static-type method directly | N/A, only one body exists | Methods that aren't meant to be replaced |
Non-virtual in base, new in derived | Resolves the call against the declared type at compile time | Calls whichever body matches the static type | No, the derived body is unreachable through base references | Defensive shadowing after a base-library change |
virtual in base, no override on derived (hidden by new or implicitly) | Emits the call against the static type's matching body | Runs the static-type body | No for the hidden member; the override chain stops at the hiding class | Rare, almost always a smell |
abstract in base, override in derived | Emits a virtual call; base has no body | Looks up the most-derived override | Yes, and the override is required | Forcing derived classes to supply behavior |
The first and the last rows of that table are the two patterns you want in your toolbox. The middle three exist because the language allows them, but only the first row delivers what most readers mean when they say "polymorphism."
The abstract/override row is closely related to virtual/override: an abstract method is a virtual method without a body. The base class can't be instantiated directly, and every concrete derived class must supply an override. Dispatch behavior is identical to the virtual/override row. The _Runtime Polymorphism_ lesson covered the mechanics of abstract dispatch; we mention it here for completeness in the comparison.
A short decision flow helps when you find yourself staring at a base class and a method you want to vary in the derived class. Run the checks in order.
The flow makes the trade-off explicit. Override is almost always the answer when the base is virtual. When the base isn't virtual but you can change it, the right move is usually to make it virtual rather than reach for new. When you genuinely can't change the base and you want polymorphism, the inheritance relationship itself is wrong; compose or rename instead. The new branch is narrow: you don't control the base, the collision is incidental, and you don't need polymorphism for that method.
Cost: Virtual calls go through the method table, which is one extra indirection compared to a direct call. The cost is measured in single-digit nanoseconds per call and almost never matters for application code. It can matter in tight inner loops that call millions of times, which is when sealing classes or methods lets the JIT devirtualize and remove the indirection.
Reading rules is one thing; predicting output trains the muscle that catches dispatch bugs during code review. Three scenarios, each with a prediction and a verification.
Predict: the array's static type is Product[], so each p is statically typed as Product. Label is virtual, so each call dispatches on the runtime type. The three objects are Product, Book, Movie. Output should be Product, Book, Movie.
Confirmed. This is the canonical "for each subclass, the right body runs" scenario that polymorphism delivers. Override is doing the work; the static type of the loop variable is irrelevant.
Predict: email.Title() runs through a reference statically typed as EmailNotification, so the hidden derived body runs. Inside ShowTitle, the parameter n is statically typed as Notification, so the compiler binds the call to Notification.Title. Output should be Email then Generic.
Confirmed. The same object produces two different answers depending on the static type of the reference holding it. Notice that the second call goes through a method parameter; the moment you pass a hidden object up to base-typed code, the derived behavior disappears.
Predict: the virtual chain runs Order → PriorityOrder. GiftOrder.Status uses new, so it's outside that chain.
g.Status(): static type GiftOrder, direct call to GiftOrder.Status. Output: Gift order placed.p.Status(): static type PriorityOrder. The compiler emits a virtual call (Status is virtual in Order). The runtime looks for the most-derived override on the chain. That's PriorityOrder.Status. Output: Priority order placed.o.Status(): static type Order. Virtual call again. Same chain, same answer. Output: Priority order placed.Confirmed. The GiftOrder body is only reachable when the caller holds the concrete GiftOrder type. Through any base reference, the virtual chain stops at PriorityOrder and runs that body. If GiftOrder.Status had been declared override instead of new, all three calls would print Gift order placed.
A handful of mistakes show up so often that they're worth naming. Each one has the same root cause: dispatch behavior depending on a modifier that's easy to write wrong or forget entirely.
override and Accidentally HidingThe most common pitfall. The base method is virtual, the derived class writes a method with the same signature, but forgets the override keyword:
What's wrong with this code?
The compiler emits CS0114:
The code compiles, but the derived Save silently became a hiding method. The output is Saving order, not Saving express order, because the call site holds an Order reference and the derived body isn't part of the virtual chain.
Fix: add override:
Now the call dispatches through the virtual chain and runs the derived body. Treat CS0114 as a near-certain bug whenever it appears.
A second pattern that catches people: casting a derived reference to the base type and expecting the derived behavior to follow. With override it does; with hiding it doesn't.
The cast doesn't change the object, but it does change the static type the compiler uses when binding the call. The second call resolves to Receipt.Print because the expression's type is Receipt. If Print had been virtual + override, both calls would print Detailed receipt. The diagnostic question to ask: "what is the static type of the expression at this call site?"
new When You Meant OverrideSometimes developers reach for new because the base method isn't virtual and they want their own version. The code compiles, and quick local testing through the derived reference works. The bug surfaces later, when some other code holds a base reference and gets the base behavior:
The intent was for the loyalty calculator to charge $80, but CheckoutService.Charge receives a CartCalculator reference, and the hidden method is unreachable through that reference. The fix is to make CartCalculator.CalculateTotal virtual and use override instead of new. Hiding is the wrong tool whenever you need polymorphic behavior across method calls that go through a base reference.
A subtler pitfall: virtual dispatch behaves unusually when called from a constructor. The runtime type is fully established before the base constructor runs, which means a virtual call inside the base constructor can dispatch to a derived override that runs before the derived constructor has finished initializing its fields.
The Init call inside the Product constructor dispatches polymorphically and runs DiscountedProduct.Init. At that moment, the derived constructor hasn't run yet, so DiscountPercent is still its default value (0). The fix is to not call virtual methods from constructors at all. If you need to do initialization that depends on derived state, do it after the object is fully constructed or use a different pattern, such as factory methods that build the object and call setup separately.
This pitfall doesn't apply to new-hidden methods the same way, because hiding never goes through virtual dispatch. A call to a hidden method from the base constructor binds to the base class's body (the static type at the call site is the base class), so the derived hidden body simply doesn't run. That's safer in this narrow scenario, but it's a side effect of hiding's design, not a reason to use it.
One scenario the language was specifically designed to handle is the versioning problem. Library authors release new versions of base classes, and those new versions sometimes add methods. If a derived class in user code happens to have a method with the same name, what should the compiler do?
The C# answer is: keep the derived method working as it always did, but ask the user to acknowledge the situation. Consider this timeline.
In version 1 of a library, the base class looks like this:
A consumer subclasses it and adds their own Validate method:
Now the library author releases v2, which adds a Validate method to the base class:
When the consumer recompiles against v2, their LoyaltyCartProcessor.Validate suddenly shares a name with a base method that didn't exist before. Without language support, the consumer would face a hard choice: rename the method (breaking their own callers) or somehow merge with the new base method.
C# handles this gracefully. The compiler emits CS0114 warning the consumer that hiding is happening, and the consumer can add new to acknowledge it:
The derived method continues to do exactly what it did before. Code that holds a LoyaltyCartProcessor reference still gets the loyalty validation. The base class's new Validate is reachable for callers that hold a CartProcessor reference, which is fine because they're new callers who didn't exist when v1 was the only version.
The subtlety is in the warning the compiler emits. When the base method in v2 is virtual (as it is here), the warning is CS0114, the same one that fires when you forget override. The compiler can't tell from the source code whether the consumer is upgrading or whether they always meant to override. The consumer has to decide:
Validate to participate in the base's virtual chain (so code with a CartProcessor reference also gets the loyalty validation), they should use override.Validate to be a separate concept that happens to share a name (preserving the original behavior of v1 for existing callers), they should use new.Most upgrades pick new, because preserving the v1 behavior is the conservative choice. Changing to override could break code that was written expecting the base behavior when it held a base reference. The compiler can't make that decision for the consumer, which is why it asks.
The takeaway: new exists in part to make library versioning survivable. The keyword tells future readers "yes, the name clash is intentional, I'm preserving the v1 contract." It's also why the C# team chose new (not replace or shadow) as the keyword: the derived member is, semantically, a new declaration that happens to live alongside an inherited member with the same name.
virtual + override uses the runtime type; new and plain non-virtual methods use the static type.override always wins, but a new-hidden method is invisible. This is the central difference and the source of most dispatch bugs.override and new in a chain means the virtual chain stops at the last override. Below that, the hidden body is reachable only through references typed at the hiding class or lower.CS0108 (implicit hiding, use new), CS0114 (you hid a virtual method, probably meant override). Treat CS0114 as a near-certain bug.override; if you control the base, make it virtual and override; if you don't control it and you need polymorphism, compose instead of inherit; only use new for defensive shadowing or to handle library versioning.new exists in part to solve the library versioning problem. When a base class gains a method that collides with an existing derived method, new preserves the original behavior without breaking callers.