A deep dive into SQLAlchemy’s modern declarative mapping using registry() and as_declarative_base(). Learn how to structure scalable, maintainable model layers for Python backend projects.
When you're building a backend with an ORM like SQLAlchemy, your models are the heart of the system. But as your project grows, writing every model from scratch becomes painful, repetitive, and error-prone. That’s where reusable models and mixins come in.
In this post, we'll talk about how to build clean, maintainable, and scalable model architectures using Declarative Base, custom base classes, and mixins in SQLAlchemy. We'll start with the basics, build a solid foundation, and then move into more advanced usage patterns.
An ORM (Object-Relational Mapper) is a way to interact with a database using Python objects. Instead of writing raw SQL queries, you define Python classes that map to database tables. SQLAlchemy is one of the most powerful and flexible ORMs in Python.
For example when you need to query for a user, you don’t say:
SELECT * FROM users WHERE id = 1;
Instead, you write:
user = session.get(User, 1)
SQLAlchemy takes care of turning your Python objects into database rows and vice versa. And that’s huge. It means we can focus more on business logic and less on boilerplate SQL.
With SQLAlchemy, you can also define models like this:
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
name = Column(String)
Here, we:
declarative_base()
User
class with a table name and a few columnsThis works fine for small apps, but what happens when you have dozens of models that all need the same fields like id
, created_at
, and updated_at
?
SQLAlchemy needs a base class that all your models can inherit from. Think of it like the blueprint for all your tables.
from sqlalchemy.orm import declarative_base
Base = declarative_base()
Now, when you write this:
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
SQLAlchemy understands that this class is a database table.
Why not just use SQLAlchemy’s default base? Because we often want custom behavior in all our models like timestamps, soft deletes, UUIDs, etc. So we create a custom base class and give it more power.
Imagine you’re working on a project with 20+ models. Most of them have:
id
columncreated_at
and updated_at
__tablename__
If you repeat this code everywhere, it’s hard to maintain. If you change one thing, you have to change it in 20 places. A better way is to centralize this logic and make it reusable.
Now, let’s build something real step by step!
The Declarative Base is where all your models inherit from. Instead of using SQLAlchemy's default Base
, we create our own with some shared logic:
from sqlalchemy.orm import declarative_base
Base = declarative_base()
This base class becomes the foundation of every model in your project. It helps SQLAlchemy know which classes to register as database tables.
Now let’s extend the base class by injecting common fields and behaviors:
from sqlalchemy import Column, Integer, DateTime
from sqlalchemy.ext.declarative import declared_attr
import datetime
class CustomBase:
id = Column(Integer, primary_key=True)
@declared_attr
def __tablename__(cls):
return cls.__name__.lower()
id
is defined once and automatically included in all child models.__tablename__
is generated dynamically based on the class name.Now pass this class to the declarative_base
factory:
Base = declarative_base(cls=CustomBase)
From now on, any class that inherits from Base
gets all these fields for free.
Mixins are reusable building blocks. They let you add specific features to a model only when you need them. Each mixin is just a class with some fields or methods.
class TimestampMixin:
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
Use this when you want to track creation and update times.
class SoftDeleteMixin:
deleted_at = Column(DateTime, nullable=True)
def soft_delete(self):
self.deleted_at = datetime.datetime.utcnow()
This lets you “delete” records without actually removing them from the database.
import uuid
from sqlalchemy.dialects.postgresql import UUID
class UUIDMixin:
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
Use this when you want globally unique IDs instead of integers.
You can now create models like this:
class User(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = 'users'
name = Column(String, nullable=False)
email = Column(String, nullable=False, unique=True)
This model now:
Base
In many production apps, you need to track who created or updated a record. You can do this with another mixin:
class AuditMixin:
created_by = Column(String)
updated_by = Column(String)
Use it like this:
class Invoice(Base, TimestampMixin, AuditMixin):
__tablename__ = 'invoices'
amount = Column(Float, nullable=False)
description = Column(String)
This model now logs who made the last changes, great for admin dashboards and audit logs.
Sometimes you want a mixin or base class that doesn’t create a table. You can mark it as abstract:
from abc import ABC
class SoftDeleteBase(ABC):
deleted_at = Column(DateTime)
class BaseSoftDeleteModel(Base, SoftDeleteBase):
__abstract__ = True
SQLAlchemy skips abstract classes when creating tables.
Starting from SQLAlchemy 1.4, there's a newer and more modular way to define your models using a registry()
object instead of directly calling declarative_base()
. This approach provides greater flexibility, especially for larger codebases or plugin-style architectures where models are distributed across multiple files or packages.
from sqlalchemy.orm import registry
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy import Column, Integer, String
mapper_registry = registry()
@mapper_registry.as_declarative_base()
class Base:
@declared_attr
def __tablename__(cls):
return cls.__name__.lower()
id = Column(Integer, primary_key=True)
Now, you can define your models by inheriting from Base
:
class Product(Base):
name = Column(String, nullable=False)
price = Column(Integer)
This is functionally similar to the classic declarative_base()
, but gives you more control over how models are registered and mapped.
declarative_base()
?This approach is especially useful when:
registry().map_imperatively()
If needed, you can even mix in classic (imperative) mapping like this:
from sqlalchemy import Table, Column, Integer, MetaData
from sqlalchemy.orm import registry
metadata = MetaData()
mapper_registry = registry(metadata=metadata)
user_table = Table(
"users", metadata,
Column("id", Integer, primary_key=True),
Column("name", String),
)
class User:
pass
mapper_registry.map_imperatively(User, user_table)
This gives you full control over the mapping process and is handy in systems where models must be defined dynamically or loaded at runtime.
Using Declarative Base and mixins isn’t just about saving time. It’s about:
These patterns work really well in large codebases and teams. Once you get the hang of it, your model layer becomes powerful, testable, and future-proof.