Abstraction in Python with the abc Module

python abstraction is about defining what a class must do, without dictating how it does it — exposing an essential interface while hiding implementation complexity behind it. This article covers the abc module python provides for formalizing that interface, including @abstractmethod, abstract properties, and how Abstract Base Classes compare to the more lightweight typing.Protocol covered in the earlier polymorphism article.

What Is Abstraction?

Abstraction means hiding implementation complexity and exposing only the essential interface — defining what a class needs to do, without dictating how it accomplishes it internally.

A real-world framing

Think of it like a federal banking regulation: the regulation states that a bank must be able to process a withdrawal. It says absolutely nothing about how any particular bank actually implements that internally — the database technology, the specific validation steps, the internal ledger system. Each bank builds its own implementation however it sees fit, as long as the interface the regulation demands — "must process a withdrawal" — is genuinely satisfied.

Where abstraction fits among the OOP pillars

This article completes a set covered across this series: encapsulation (controlling access to an object's internal state), inheritance (building new classes on top of existing ones), polymorphism (the same method behaving differently depending on the object), and now abstraction — defining a required interface, explicitly, that other classes must conform to. Abstraction and encapsulation are closely related but distinct: encapsulation hides an object's data; abstraction hides implementation details, focusing purely on defining the contract a class must fulfill.

2. Creating an Abstract Base Class with abc

Basic setup

Python's abc module provides ABC (a base class to inherit from) and abstractmethod (a decorator marking a method as required):

from abc import ABC, abstractmethod

class PaymentProvider(ABC):
    @abstractmethod
    def process_payment(self, amount):
        pass

PaymentProvider inherits from ABC, marking it as an abstract base class. process_payment() is decorated with @abstractmethod, declaring it as a required placeholder — a method every subclass must implement, with no default implementation provided here at all.

What happens if a subclass doesn't implement it
class Incomplete(PaymentProvider):
    pass   # forgot to implement process_payment()

provider = Incomplete()
# TypeError: Can't instantiate abstract class Incomplete with abstract method process_payment

This is the entire point of using an ABC over plain duck typing: the error happens immediately, at the moment Incomplete() is called — not buried somewhere much later in your program's execution, whenever some far-away piece of code finally happens to call process_payment() on an incomplete object and gets a confusing AttributeError at an inconvenient time. Abstract base classes convert what would otherwise be a silent, deferred bug into an immediate, unmissable one.

A working implementation
class Stripe(PaymentProvider):
    def process_payment(self, amount):
        print(f"Processing  via Stripe")

stripe = Stripe()
stripe.process_payment(100)   # Processing $100 via Stripe

Stripe genuinely implements process_payment(), so it instantiates without any issue.

Concrete Methods and Abstract Properties in ABCs

ABCs can include fully implemented methods too

An abstract base class isn't limited to abstract methods alone — it can also include fully implemented (concrete) methods that subclasses inherit and reuse directly, without needing to redefine them:

from abc import ABC, abstractmethod

class PaymentProvider(ABC):
    @abstractmethod
    def process_payment(self, amount):
        pass

    def log_transaction(self, amount):   # concrete — not abstract, no decorator
        print(f"Logging transaction: ")

class Stripe(PaymentProvider):
    def process_payment(self, amount):
        self.log_transaction(amount)   # reusing the inherited concrete method
        print(f"Processing  via Stripe")

stripe = Stripe()
stripe.process_payment(50)
# Logging transaction: $50
# Processing $50 via Stripe

log_transaction() is shared, reusable behavior every subclass gets for free, while process_payment() remains something each subclass must define for itself.

A subtlety: abstract methods can have partial implementations

This is a genuinely useful, somewhat lesser-known detail: an abstract method itself can still contain real code — subclasses are required to override it, but they can call that partial implementation via super() to extend it, rather than fully replacing it from scratch:

from abc import ABC, abstractmethod

class PaymentProvider(ABC):
    @abstractmethod
    def process_payment(self, amount):
        print(f"Base processing steps for ")

class Stripe(PaymentProvider):
    def process_payment(self, amount):
        super().process_payment(amount)   # runs the abstract method's own partial logic first
        print("Stripe-specific processing")

stripe = Stripe()
stripe.process_payment(75)
# Base processing steps for $75
# Stripe-specific processing

Even though process_payment is marked @abstractmethod and must be overridden, its own body still runs correctly when explicitly called via super() — this can be genuinely useful for sharing common setup logic across every subclass's implementation, while still requiring each subclass to provide its own specific behavior on top.

Abstract properties

Combining @property (covered in the earlier encapsulation article) with @abstractmethod lets you require subclasses to implement a specific attribute-like interface, not just a method:

from abc import ABC, abstractmethod

class Animal(ABC):
    @property
    @abstractmethod
    def species(self):
        pass

class Dog(Animal):
    @property
    def species(self):
        return "Canis familiaris"

dog = Dog()
print(dog.species)   # Canis familiaris — accessed like a plain attribute

Any subclass of Animal that doesn't define its own species property will fail to instantiate at all, with the same clear TypeError shown earlier — exactly the same enforcement as an abstract method, applied specifically to something meant to be accessed as a property rather than called as a method.

A Practical Example: Enforcing a Consistent Interface

Here's a fuller worked example combining several concepts covered so far — an abstract Employee class with required behavior, implemented differently by two concrete subclasses:

from abc import ABC, abstractmethod

class Employee(ABC):
    def __init__(self, name):
        self.name = name

    @abstractmethod
    def calculate_pay(self):
        pass

    def display_info(self):   # concrete — shared by every subclass
        print(f"{self.name}: ")

class SalariedEmployee(Employee):
    def __init__(self, name, annual_salary):
        super().__init__(name)
        self.annual_salary = annual_salary

    def calculate_pay(self):
        return self.annual_salary / 12

class HourlyEmployee(Employee):
    def __init__(self, name, hourly_rate, hours_worked):
        super().__init__(name)
        self.hourly_rate = hourly_rate
        self.hours_worked = hours_worked

    def calculate_pay(self):
        return self.hourly_rate * self.hours_worked

employees = [
    SalariedEmployee("Alex", 72000),
    HourlyEmployee("Sam", 25, 160),
]

for employee in employees:
    employee.display_info()
# Alex: $6000.00
# Sam: $4000.00

Both subclasses genuinely calculate pay in completely different ways, but display_info() — defined once, on the abstract base class — works identically for both, purely because both subclasses correctly fulfilled the calculate_pay() contract the abstract class required.

Why this matters in real projects

The genuine, practical payoff here is preventing what's sometimes called the "Wild West" problem: without an enforced interface, different developers working on the same codebase (or even the same developer, working months apart) can end up building inconsistent classes that quietly violate assumptions elsewhere in the system — a class that's supposed to support calculate_pay() but is missing it, silently breaking any code that assumes every Employee supports that method. An ABC catches this immediately and loudly, at the moment an incomplete class is instantiated, rather than allowing that inconsistency to sit undetected until it eventually causes a real, confusing failure somewhere else entirely.

A brief mention of register()

The abc module also supports declaring "virtual subclasses" via .register() — classes that are recognized as satisfying an ABC's interface for isinstance()/issubclass() checks, without any actual inheritance relationship at all:

class DuckPayment:
    def process_payment(self, amount):
        print(f"Processing  the duck-typed way")

PaymentProvider.register(DuckPayment)

print(isinstance(DuckPayment(), PaymentProvider))   # True — even without inheriting from it

This is a fairly advanced, less commonly used escape hatch — genuinely useful in specific scenarios where you want a class to be recognized as fulfilling an ABC's contract for type-checking purposes, without actually restructuring its class hierarchy to formally inherit from that ABC.

ABC vs. typing.Protocol: Choosing the Right Tool

The core distinction

As introduced in the earlier polymorphism article: ABCs enforce the contract at instantiation time, through actual inheritance — a subclass must formally inherit from the ABC, and Python will refuse to create an instance if required methods are missing. typing.Protocol (from the typing module, available since Python 3.8) formalizes duck typing specifically for static type checkers, without requiring any inheritance relationship at all — a class satisfies a Protocol purely by happening to have the right methods with the right signatures, checked by a tool like Mypy rather than enforced by Python itself at runtime.

from abc import ABC, abstractmethod
from typing import Protocol

# ABC — enforced at runtime, requires inheritance
class Speaker(ABC):
    @abstractmethod
    def speak(self) -> str:
        pass

# Protocol — checked by static analysis, no inheritance required
class SpeakerProtocol(Protocol):
    def speak(self) -> str:
        ...
When to reach for each

Reach for an ABC in larger codebases, and especially in plugin-style architectures, where genuine runtime enforcement matters — you want Python itself to actively refuse to create an incomplete implementation, catching mistakes immediately rather than trusting every developer to remember every required method.

Reach for typing.Protocol when you want a lightweight, formal interface definition — genuinely useful for static type checking and editor tooling — without dictating an actual class hierarchy. This is particularly valuable when you're defining an interface against classes you don't own or can't modify (say, from a third-party library), since Protocol never requires those classes to be changed to explicitly inherit from anything at all.

The quick takeaway

Abstraction isn't about making code more complex for its own sake — it's about making the required interface explicit, so that new subclasses (or new implementations, in the Protocol case) can be added safely, later, by anyone, without needing to read through and understand the entire existing codebase first, and without risk of silently breaking assumptions that other code already depends on.