Quick Answer

The Repository pattern puts one interface between your business logic and your data storage — methods like get_active_users() or add(user) instead of raw SQL or ORM queries scattered through your code. Your business logic depends only on that interface, never on the database driver or ORM underneath it, so you can swap SQLite for Postgres, or a real database for an in-memory fake in tests, without touching a single line of business logic.

The Problem: Business Logic Tangled With Data Access

Most codebases without a Repository layer have the same smell: a function that's nominally "business logic" — say, "deactivate every user who hasn't logged in for 90 days" — also contains SQL, or ORM query syntax, or both, mixed directly into the same function. The rule about who should be deactivated and the mechanics of how to fetch and update rows in a specific database are one inseparable block of code.

That coupling costs you in three specific ways. First, testing that business rule means either running against a real database in every test (slow, and it needs seeding and cleanup) or mocking a specific ORM's query builder (brittle, and it tests your mock more than your logic). Second, changing databases — or even just changing ORMs — means hunting down every function that touches that data and rewriting its query code, even though the business rule itself didn't change at all. Third, and easy to underestimate: reading the business logic requires reading through database syntax to find the actual rule, because the two are interleaved in the same lines.

The Repository pattern's fix is almost too simple to sound like a "pattern": define an interface with methods named after what the business logic needs (get_active_users(), add(user), deactivate(user_id)), write one or more classes that implement that interface against a real storage mechanism, and make sure the business logic only ever calls the interface — never the storage mechanism directly.

The Interface Is the Whole Point

A repository interface is deliberately shaped around the domain, not around the database. Compare get_active_users() to what it's likely hiding — something like SELECT * FROM users WHERE is_active = 1 in one implementation, or a Mongo find({is_active: true}) in another, or a plain list comprehension over a dict in a third. The caller never sees any of that; it just calls a method whose name describes what it needs in business terms.

class UserRepository(ABC):
    @abstractmethod
    def add(self, user: User) -> None: ...

    @abstractmethod
    def get_by_id(self, user_id: int): ...

    @abstractmethod
    def get_active_users(self): ...

    @abstractmethod
    def deactivate(self, user_id: int) -> None: ...

This is a port in the "ports and adapters" (hexagonal architecture) sense — a boundary your application logic defines based on what it needs, which the infrastructure code then has to satisfy. Notice the direction of the dependency: the interface lives on the business-logic side, and the database-specific code depends on it, not the other way around. That inversion is what actually decouples the two — if the interface lived next to the SQL implementation instead, you'd just have renamed the coupling, not removed it.

Two Real Implementations of the Same Interface

To prove the interface actually decouples anything, here's the same business function run against two genuinely different implementations: an in-memory dictionary-backed one, and a real SQLite database. Both were actually executed to produce the output below.

class InMemoryUserRepository(UserRepository):
    def __init__(self):
        self._users = {}

    def add(self, user: User) -> None:
        self._users[user.id] = user

    def get_by_id(self, user_id: int):
        return self._users.get(user_id)

    def get_active_users(self):
        return [u for u in self._users.values() if u.is_active]

    def deactivate(self, user_id: int) -> None:
        if user_id in self._users:
            self._users[user_id].is_active = False


class SqliteUserRepository(UserRepository):
    def __init__(self, connection):
        self._conn = connection
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS users (
                id INTEGER PRIMARY KEY, name TEXT NOT NULL,
                email TEXT NOT NULL, is_active INTEGER NOT NULL DEFAULT 1
            )
        """)
        self._conn.commit()

    def add(self, user: User) -> None:
        self._conn.execute(
            "INSERT INTO users (id, name, email, is_active) VALUES (?, ?, ?, ?)",
            (user.id, user.name, user.email, int(user.is_active)),
        )
        self._conn.commit()

    def get_active_users(self):
        rows = self._conn.execute(
            "SELECT id, name, email, is_active FROM users WHERE is_active = 1"
        ).fetchall()
        return [User(id=r[0], name=r[1], email=r[2], is_active=bool(r[3])) for r in rows]

    # get_by_id / deactivate follow the same shape, using SQL underneath

And here is the business logic — onboard three users, then deactivate every user with an even ID — written once, with zero knowledge of which implementation it's running against:

def onboard_and_prune(repo: UserRepository):
    repo.add(User(id=1, name="Asha Verma", email="[email protected]"))
    repo.add(User(id=2, name="Rohit Singh", email="[email protected]"))
    repo.add(User(id=3, name="Meera Iyer", email="[email protected]"))

    for user in repo.get_active_users():
        if user.id % 2 == 0:
            repo.deactivate(user.id)

Running it against the in-memory repository, then against a real SQLite connection, produces identical results from the caller's point of view — this is the actual terminal output from both runs:

Running business logic against InMemoryUserRepository:
  Active users after onboarding: ['Asha Verma', 'Rohit Singh', 'Meera Iyer']
  Active users after pruning even ids: ['Asha Verma', 'Meera Iyer']
  Direct lookup of id=2: name=Rohit Singh, is_active=False

Running the SAME business logic against SqliteUserRepository (real DB file):
  Active users after onboarding: ['Asha Verma', 'Rohit Singh', 'Meera Iyer']
  Active users after pruning even ids: ['Asha Verma', 'Meera Iyer']
  Direct lookup of id=2: name=Rohit Singh, is_active=False

Not one line of onboard_and_prune changed between the two runs. That's the entire value proposition of the pattern, demonstrated rather than asserted: swap SQLite for a dict, or later for Postgres, and the function that encodes your actual business rule is untouched.

Why This Makes Testing Faster (and More Honest)

Once business logic depends on an interface instead of a concrete database, your test suite gets to choose which implementation to run against — and for the vast majority of unit tests, the in-memory implementation is the right choice. There's no database to spin up, no schema migration to run before the test, no rows to clean up after it, and no risk of one test's leftover data breaking the next test that happens to run after it. A test suite of a few hundred cases against an in-memory repository typically runs in well under a second; the same suite against a real database, even a fast local one, usually adds real setup and teardown time per test.

This isn't "mocking" in the fragile sense of the word — you're not asserting that get_active_users was called with specific arguments and stubbing a return value by hand. You're running real code (the in-memory repository's actual dict logic) against real business logic, just swapping which storage the repository happens to be backed by. That's a meaningfully stronger test than a mock, because a bug in how the business logic iterates or filters the returned users would still be caught — only bugs specific to SQL or to a particular database engine would need the SQLite (or real Postgres) implementation to catch them, and those are exactly the cases worth a smaller number of slower integration tests.

Repository vs. ORM: They're Not Competing

It's easy to assume the Repository pattern is an alternative to using an ORM, since both claim to "hide the database." They're not at the same layer, and in most real codebases they work together: the ORM (SQLAlchemy, Prisma, Django's ORM, Mongoose) is what your repository's concrete implementation uses internally to talk to the database, and the repository interface is what the rest of your application sees.

Without a repository, ORM query objects and model classes tend to leak everywhere — into route handlers, into view templates, into every service function — so replacing the ORM later means touching all of those places. With a repository in front of it, the ORM is an implementation detail of a handful of repository classes; the business logic never imports it, never sees its query syntax, and doesn't care whether it's there at all. The N+1 query problem, lazy loading, and other ORM-specific concerns are real and worth understanding on their own terms, but they live and get solved entirely inside the repository implementation, never in the business logic that calls it.

Where It Fits: Dependency Inversion in Practice

The Repository pattern is usually introduced through dependency injection: a service or use-case class takes a repository as a constructor argument (typed as the interface, not a concrete class), and something at the top of the application — a composition root, a framework's DI container, or just a small factory function — decides which concrete implementation to hand it at runtime.

class OrderService:
    def __init__(self, orders: OrderRepository):
        self._orders = orders   # depends on the interface only

    def cancel(self, order_id: int):
        order = self._orders.get_by_id(order_id)
        # business rule lives here, not inside OrderRepository
        if order.status not in ("pending", "paid"):
            raise ValueError("Cannot cancel an order that has already shipped")
        order.status = "cancelled"
        self._orders.save(order)

Production wiring hands OrderService a Postgres-backed repository; the test suite hands it an in-memory one; a data-migration script could hand it a repository that reads from a legacy system and writes to the new one. OrderService itself never changes across any of those. This is the same dependency-inversion idea used throughout what's often called hexagonal or "ports and adapters" architecture — the repository interface is a port, each concrete implementation is an adapter, and the business logic sits in the middle, depending on neither.

When the Repository Pattern Is Overkill

This pattern earns its cost on business logic that's genuinely reused across multiple call sites, tested in isolation, or likely to face a storage change (a startup moving off a spreadsheet-backed prototype, a service migrating databases). It costs real overhead too: an extra interface, at least one extra implementation class, and one more layer to trace through when reading code for the first time.

For a small script, an admin tool with a handful of screens, or a CRUD endpoint that really is just "map this HTTP request to one query and back," wrapping it in a repository interface is ceremony without payoff — you'd be building an abstraction for a database swap that will never happen, to support tests that could just as easily run against a real local SQLite file in under a second anyway. The pattern is a tool for the specific pain of business logic entangled with data access at a scale where that entanglement actually hurts, not a default to apply to every function that touches a database.

Frequently Asked Questions

Is the Repository pattern the same as the Data Access Object (DAO) pattern? They're closely related and often used interchangeably. The common distinction is that a DAO tends to expose table/entity-shaped methods, while a Repository is meant to expose domain/collection-shaped methods, like get_active_users instead of find_by_status.
Do I need an abstract base class in Python to implement this? No — Python's duck typing means any object with the right method names works. ABC (or a Protocol) just makes the contract explicit and lets tools flag a missing method earlier, which matters more as the codebase and team grow.
Does using a Repository mean I can't use my ORM's advanced features? No — those features live inside your concrete repository implementation. The interface only constrains what the rest of the app can see, not what the implementation is allowed to do internally.
Should every database table have its own repository? Not necessarily — repositories are usually scoped to an aggregate or a meaningful business concept, which sometimes spans more than one table, and sometimes a table is purely internal to another aggregate and doesn't need its own repository at all.
How is this different from just wrapping database calls in a service layer? A service layer typically holds business rules; a repository's job is narrower, pure data access with no business logic inside it. Mixing the two back together, like validation rules living inside a repository method, reintroduces the exact coupling the pattern exists to remove.