Polymorphism in Java Explained (With Examples)
backend
11 min read
A senior dev's guide to polymorphism in Java - overloading, overriding, interfaces, and the real-world patterns that keep your code flexible without turning it into a maze.

Published By: Nelson Djalo | Date: April 29, 2026
Polymorphism is the OOP pillar that sounds the most academic and turns out to be the most useful. The textbook definition - "many forms" - tells you nothing. The real value shows up the first time you write a single method that handles five different payment providers, or a single loop that renders ten different shapes, without a tangle of if and instanceof checks.
This post walks through what polymorphism in Java actually means in real code, the difference between compile-time and runtime variants, and the patterns that turn it from a buzzword into a tool you reach for every day.
Polymorphism is the ability for a single piece of code to work with values of different types, choosing the right behaviour based on the runtime type. In Java this happens any time you call a method through a reference whose declared type is more general than the object behind it.
Imagine a remote control. The button is the same whether you point it at a TV, a stereo, or a fan. Each device responds differently because each implements power() in its own way. Your finger does not care - it just presses the button. That is polymorphism in one sentence: same call, different behaviour.
In Java the mechanism is built on two things: a common type (a class or an interface) and methods that subtypes override. The compiler sees the general type. The JVM sees the actual object and dispatches to the right method.
Animal animal = new Dog();
animal.speak(); // prints "Woof", not the generic Animal.speak()
The variable's declared type is Animal, but the object is a Dog. When speak() is called, the JVM picks the Dog version. That late binding is what makes polymorphism work.
Java has two flavours of polymorphism, and getting them straight saves a lot of confusion.
Compile-time polymorphism is method overloading. The compiler picks which method to call based on the argument types you pass. It is decided when the code is compiled, not when it runs.
Runtime polymorphism is method overriding. The compiler does not know which version will run - it depends on the actual object at runtime. This is where the real power of OOP lives.
// Compile-time: overloading
public class Logger {
public void log(String message) { /* ... */ }
public void log(String message, Throwable error) { /* ... */ }
public void log(int level, String message) { /* ... */ }
}
// Runtime: overriding
public class Animal {
public String speak() { return "..."; }
}
public class Dog extends Animal {
@Override
public String speak() { return "Woof"; }
}
Both are polymorphism. Both let one name represent many forms. But they solve different problems and behave very differently at runtime, so it is worth keeping the labels separate in your head.
Overriding is where polymorphism earns its keep. A subclass replaces a method inherited from its parent, and any call through the parent type dispatches to the subclass version.
public class Shape {
public double area() {
return 0;
}
}
public class Circle extends Shape {
private final double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
public class Rectangle extends Shape {
private final double width;
private final double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
}
Now you can write code that works on any Shape without caring which concrete type it is.
List<Shape> shapes = List.of(new Circle(5), new Rectangle(3, 4));
double total = shapes.stream().mapToDouble(Shape::area).sum();
The loop has no idea what shapes are inside. It does not need to. Each object knows how to compute its own area, and the JVM picks the right method on every iteration.
The @Override annotation is not optional in serious code. It tells the compiler "this is meant to override a parent method". If the signature does not match - a typo, a wrong parameter type, a missing method in the parent - you get a compile error instead of a silent bug. Always use it.
Overloading lets the same method name accept different parameter lists. The compiler picks the right version based on the arguments at the call site.
public class StringFormatter {
public String format(String value) {
return value.trim();
}
public String format(int value) {
return String.format("%,d", value);
}
public String format(BigDecimal value) {
return value.setScale(2, RoundingMode.HALF_UP).toString();
}
}
Overloading is convenient but easy to abuse. The danger is that you create methods that share a name but mean very different things, which makes the API harder to read. A good rule: overloads should do the same conceptual work on different input types. If the behaviour diverges, give them different names.
Overloading also has a subtle limit - it cannot be virtual. The compiler picks the overload at compile time based on the declared type of the argument, not the runtime type. That trips up developers expecting overloading and overriding to compose.
public class Printer {
public void print(Animal a) { System.out.println("Animal"); }
public void print(Dog d) { System.out.println("Dog"); }
}
Animal a = new Dog();
new Printer().print(a); // prints "Animal", not "Dog"
If you need behaviour that depends on the runtime type, override a method on the type itself. Do not try to fake it with overloading.
Inheritance is one way to get polymorphism, but interfaces are usually the better choice. They let unrelated classes share a contract without forcing a class hierarchy.
public interface PaymentProcessor {
PaymentResult charge(Money amount, Card card);
}
public class StripeProcessor implements PaymentProcessor {
@Override
public PaymentResult charge(Money amount, Card card) {
// call Stripe API
return PaymentResult.success();
}
}
public class PayPalProcessor implements PaymentProcessor {
@Override
public PaymentResult charge(Money amount, Card card) {
// call PayPal API
return PaymentResult.success();
}
}
The rest of your code only depends on the PaymentProcessor interface.
public class CheckoutService {
private final PaymentProcessor processor;
public CheckoutService(PaymentProcessor processor) {
this.processor = processor;
}
public Receipt checkout(Cart cart, Card card) {
PaymentResult result = processor.charge(cart.total(), card);
return Receipt.from(result);
}
}
Swap Stripe for PayPal by changing one line of wiring. Add a third processor and the checkout code does not change at all. That is the practical payoff of polymorphism - new behaviour without rewriting the callers.
This is the kind of pattern that makes object-oriented Java actually pay off in production code, and it is one of the things our Java for Beginners course works through with real examples rather than abstract definitions.
Here is a pattern you will write in some form on most real backends. You need to send notifications, and the channel can be email, SMS, or push - sometimes all three. Polymorphism keeps the dispatch code clean.
public interface NotificationSender {
void send(User user, String message);
}
public class EmailSender implements NotificationSender {
@Override
public void send(User user, String message) {
// SMTP call
}
}
public class SmsSender implements NotificationSender {
@Override
public void send(User user, String message) {
// Twilio call
}
}
public class PushSender implements NotificationSender {
@Override
public void send(User user, String message) {
// Firebase call
}
}
The service that fans out a notification does not care which channels are configured.
public class NotificationService {
private final List<NotificationSender> senders;
public NotificationService(List<NotificationSender> senders) {
this.senders = List.copyOf(senders);
}
public void notify(User user, String message) {
for (NotificationSender sender : senders) {
sender.send(user, message);
}
}
}
Add Slack notifications? Write a SlackSender, register it in the list, ship. No if (channel.equals("slack")) ladder. No switch statement growing every quarter. The NotificationService is closed for modification but open for extension - the open/closed principle, made real by polymorphism.
Pricing rules are another classic place where polymorphism beats conditionals. Instead of one giant calculateDiscount() method full of branches, model each rule as its own type.
public interface DiscountStrategy {
BigDecimal apply(BigDecimal price);
}
public class PercentageDiscount implements DiscountStrategy {
private final BigDecimal percentage;
public PercentageDiscount(BigDecimal percentage) {
this.percentage = percentage;
}
@Override
public BigDecimal apply(BigDecimal price) {
BigDecimal multiplier = BigDecimal.ONE.subtract(percentage.divide(BigDecimal.valueOf(100)));
return price.multiply(multiplier);
}
}
public class FlatDiscount implements DiscountStrategy {
private final BigDecimal amount;
public FlatDiscount(BigDecimal amount) {
this.amount = amount;
}
@Override
public BigDecimal apply(BigDecimal price) {
return price.subtract(amount).max(BigDecimal.ZERO);
}
}
public class NoDiscount implements DiscountStrategy {
@Override
public BigDecimal apply(BigDecimal price) {
return price;
}
}
The checkout never branches on discount type. It just asks the strategy to apply itself.
BigDecimal finalPrice = strategy.apply(basePrice);
When marketing invents a new discount type next quarter - "buy one get one free", "tiered loyalty", whatever - you write a new class. You do not touch the checkout. That is the test of good polymorphic design: new requirements arrive as new types, not as new branches in old methods.
@Override. Without it, a method with a slightly wrong signature silently becomes a new method instead of an override. The annotation turns that into a compile error.instanceof chains. If you find yourself writing if (shape instanceof Circle) ... else if (shape instanceof Square) ..., polymorphism is asking to be applied. Move the behaviour onto the types themselves.Square extends Rectangle throws on setWidth(), you have broken the contract and your polymorphism is a trap.Strategy, Comparator, and Runnable interfaces all do one job - that is why they compose well.@Override. It costs nothing and catches real bugs.instanceof ladder or type-tagged switch is a candidate for refactoring into a type with a method.Polymorphism in Java is the difference between code that scales with new requirements and code that fights you every time something changes. The mechanics are not complicated - a shared type, a few overridden methods, late binding by the JVM. The discipline is what takes practice: spotting where conditionals should be types, choosing interfaces over inheritance, and writing subclasses that genuinely substitute for their parents.
Get this right and your code reads like a description of the domain rather than a list of special cases. New payment providers, new notification channels, new pricing rules - all of them slot in as new types instead of new branches. That is what senior devs mean when they say polymorphism makes code "open for extension, closed for modification".
If you want to build the muscle for this kind of design properly, the Java for Beginners course covers polymorphism alongside the rest of object-oriented Java with the same real-world patterns you have seen here, so you can stop fighting instanceof chains and start writing code that bends instead of breaks.

Skip the generic recommendations. These 9 books changed how I write code, lead teams, and think about systems - from Clean Code to books most devs haven't heard of.

The exact skills, tools, and learning order to go from zero to hired as a Java full stack developer. Covers Spring Boot, React, databases, Docker, and what employers actually look for.

Abstract classes in Java are one of the most misunderstood OOP concepts. Here's a practical guide with real-world examples, code you can actually use, and the mistakes most devs make.
Join thousands of developers mastering in-demand skills with Amigoscode. Try it free today.