Skip to main
Article

SQLAlchemy for Django Developers

SQLAlchemy is a Python library for interacting with relational databases. It is a popular alternative to Django’s ORM used by frameworks such as FastAPI. In this article we will help curious Django developers write their first queries with SQLAlchemy, and highlight key differences.

Update :

If you’ve heard about FastAPI, a modern and fast web framework for building APIs with Python, you might be wondering how it compares to Django, the most popular and mature web framework for Python. In this series, I will answer this question by comparing various aspects and features of Django and FastAPI, based on our recent experience converting an internal project from Django to FastAPI.

  1. FastAPI Path Operations for Django Developers
  2. SQLAlchemy for Django Developers (this article)
  3. Testing FastAPI Applications
  4. How To Use FastAPI Dependency Injection Everywhere (coming soon)

Both Django’s ORM (Object Relational Mapper) and SQLAlchemy are libraries for interacting with relational databases. Django’s ORM is tightly coupled with the Django framework, while SQLAlchemy is a standalone library that can be used with any Python application. I heard about it many years ago, but never had the need to use it because Django’s ORM was good enough for me (to the point that I know surprisingly little SQL). But when I started using FastAPI, SQLAlchemy was the recommended library for interacting with the database. Let’s follow SQLAlchemy’s Quick Start guide to create a simple database while learning about the differences between SQLAlchemy and Django’s ORM.

The first thing I noticed when I started using SQLAlchemy was how similar it is to Django’s ORM when it comes to defining models as classes that represent database tables. Here is an example of a User model in Django:

from django.db import models

class User(models.Model):
    name = models.CharField(max_length=100)
    fullname = models.TextField()

And here is the same model in SQLAlchemy:

from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase

class User(DeclarativeBase):
     __tablename__ = "user_account"

     id: Mapped[int] = mapped_column(primary_key=True)
     name: Mapped[str] = mapped_column(String(30))
     fullname: Mapped[Optional[str]]

Note: this declarative style for model definition is relatively new, superseding the old declarative_base function in SQLAlchemy 2.0. You might still encounter the old style in some codebases.

These two models are similar in that once they get information out of the database, table columns are accessible as attributes of the model instances such as user.name and user.fullname. In the same way, modifying the attributes of a model instance like user.name = "Jane" will modify the corresponding database record when the object is “saved” (which is done differently in SQLAlchemy as we will see later).

In contrast, these models differ in that SQLAlchemy relies more heavily on type hints to define the model. In my opinion this results in a more natural and robust developer experience. For example, the fullname field only requires the Mapped[Optional[str]] annotation to be mapped to a nullable text column. Fields that require further customization that can’t be expressed in a type hint, like setting a character limit or marking a column as primary key, can use the mapped_column function as seen in the id and name fields. In all cases, by using the Mapped annotation type checkers have everything they need to correctly type the model attributes.

SQLAlchemy also supports defining models with the imperative syntax, which is less similar to Django’s ORM. I personally prefer the declarative syntax and find it more readable and intuitive.

In Django, every model comes with a manager exposed as the objects attribute. This means that as long as you get a reference to the model class, you can access the manager and perform queries. For example, for the User model, you can get all users with User.objects.all().

In SQLAlchemy, you need to create an engine and session first before you can perform any queries. You can think of the engine as a factory that provides us with database connections, and sessions map roughly to these individual connections.

from sqlalchemy import create_engine
from sqlalchemy.orm import Session

engine = create_engine("sqlite:///db.sqlite3")
with Session(engine) as session:
    users = session.execute("SELECT * FROM users").all()

Notice we are using raw SQL here instead of the ORM. We will get to the ORM in the next section.

You don’t need to use a context manager to create a session, but it is recommended so that the session is automatically closed when you are done with it. This can get annoying when you are writing FastAPI path operations because the code for each operation will be inside a context manager. Luckily, you can create a FastAPI dependency that yields the session from the context manager and use it by declaring it as an argument in your path operation.

from fastapi import Depends, FastAPI
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker

engine = create_engine("sqlite:///db.sqlite3")
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

app = FastAPI()

def get_session() -> Session:
    with SessionLocal() as session:
        yield session

@app.get("/users", response_model=...)
def get_users(session: Session = Depends(get_session)):
    return session.execute("SELECT * FROM users").all()

In Django, you can query a model with keyword arguments. For example, if you want to get a single user with the name “John”, you can do User.objects.get(name="John"). In SQLAlchemy, you need to use class attributes instead:

from sqlalchemy import select

john = session.scalars(select(User).where(User.name == "John")).one()

The where method is chained to the select function and achieves the same as the filter and get methods in Django. However, instead of keyword arguments, we use an actual comparison of the name attribute with the string "John". This caught me off guard at first but I’ve come to appreciate it because I get help from my text editor when building queries instead of trying to guess the names of keyword arguments.

More complex queries are also different. Django encourages you to use a double underscore syntax for more specific lookups. For example, to get all users with IDs 1, 2, or 3, you use User.objects.filter(id__in=[1, 2, 3]). SQLAlchemy achieves this by exposing custom methods as part of the class attributes:

users = session.scalars(select(User).where(User.id.in_([1, 2, 3]))).all()

The trailing underscore in in_() is needed because in is a reserved word in Python, not because of anything specific to SQLAlchemy.

There’s a whole host of interesting methods you can use with model attributes as explained in the ColumnElement documentation.

The select function accepts entire model classes or individual columns as arguments. For example, to get only the name column, you can do select(User.name).

Finally, notice that we call one() or all() instead of using separate get() and filter() methods. This is because the scalars method returns a ScalarResult that allows us to chose how to get the results.

In Django, given a model instance user, you can save it to the database with user.save(). In SQLAlchemy, you need to commit the session instead:

session.add(user)
session.commit()

This actually makes it easier to bundle together multiple “saves”, even if they are for different models, into a single transaction. You can do this by adding multiple objects to the session and then committing the session.

user1 = User(...)
user2 = User(...)
address1 = Address(...)
session.add_all([user1, user2, address1])
session.commit()

The notion of saving individual model instances by calling one of their methods is not present because transactions are always enabled, as we’ll see in the next section.

In Django, you need to wrap your database operations in a transaction to ensure that they are atomic. This means that if an exception is raised during the operation, the changes are rolled back. This is done by using the atomic decorator or the transaction.atomic context manager:

from django.db import transaction

with transaction.atomic():
    User.objects.create(name="John")

In SQLAlchemy, transactions are on by default. This means that every operation you perform is wrapped in a transaction. You can commit the transaction explicitly with session.commit() or rollback with session.rollback(). If you don’t commit the transaction, the changes will be rolled back when the session is closed.

from sqlalchemy.orm import Session

with Session(engine) as session:
    session.execute("INSERT INTO users (name) VALUES ('John')")
    session.commit()

If you prefer the Django way of doing things, it’s actually possible to create an “autocommit” engine in SQLAlchemy. Refer to our article on SQLAlchemy Autocommit for more information.

Let’s add an Address and let each user have multiple addresses. In Django, you can do this with a ForeignKey:

# No changes required to the User model

class Address(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    email_address = models.TextField()

In SQLAlchemy, you need to define the relationship explicitly:

from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import relationship, Mapped, mapped_column, DeclarativeBase

class User(DeclarativeBase):
    # Previous attributes omitted for brevity

    addresses: Mapped[list["Address"]] = relationship(
        back_populates="user", cascade="all, delete-orphan"
    )

class Address(DeclarativeBase):
    __tablename__ = "address"

    id: Mapped[int] = mapped_column(primary_key=True)
    email_address: Mapped[str]
    user_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))

    user: Mapped["User"] = relationship(back_populates="addresses")

Notice you need to define both the user_id column and the user relationship in the Address model, something that Django does automatically. Additionally, you also need to define the “other side” of the relationship as User.addresses.

Once your models are in place you can query the Address model and join it with the User model:

from sqlalchemy import select

stmt = (
    select(Address)
    .join(Address.user)
    .where(User.name == "sandy")
)
sandy_addresses = session.scalars(stmt).all()

There is no double underscore syntax like in Django, and no “related managers” that allow you to get related objects from a given model instance. Instead we need to use a join to bring in the User model and then filter on the name attribute.

One clear advantage of SQLAlchemy is that you can instantiate models and relate them before they are saved, and then commit them all at once:

spongebob = User(
    name="spongebob",
    fullname="SpongeBob SquarePants",
    addresses=[Address(email_address="spongebob@sqlalchemy.org")],
)
sandy = User(
    name="sandy",
    fullname="Sandy Cheeks",
    addresses=[
        Address(email_address="sandy@sqlalchemy.org"),
        Address(email_address="sandy@squirrelpower.org"),
    ],
)
patrick = User(name="patrick", fullname="Patrick Star")
session.add_all([spongebob, sandy, patrick])
session.commit()

In Django you would need to save all the users first, then save the addresses with the user attribute set to the corresponding user. Here we can nest them naturally and SQLAlchemy will take care of the rest.

Django comes with a built-in migration system that allows you to create and apply migrations with a single command. This is definitely one of the most important features of Django and I was surprised to find out that SQLAlchemy does not have a built-in alternative. Instead, they maintain a separate package called Alembic to manage migrations (which have their name changed to “revisions”).

We won’t go into details here, but the basic substitutions are:

SQLAlchemy and Alembic don’t have the concept of “apps” as standalone elements with their own models and migrations. Instead, they use a single alembic/versions directory that contains all the revisions for all tables, and autogenerating revisions will scan the entire database for changes.

To date I don’t know how to manage migrations in databases that have models defined by third-party packages. This is in contrast with Django where third-parties usually ship their own migration history to manage their tables independently from user-defined models.

Alembic is also less helpful when it comes to automatically generating revisions. Some column attributes, like server_default, seem to be completely ignored when it comes to detecting changes in the model. When adding new columns to existing tables, Alembic will not warn you if the new column is not nullable. This can lead to data integrity issues if you are not careful, and the decision of adding a default or somehow populating your old rows is left to you.

SQLAlchemy is a powerful library. We have only scratched the surface of what it can do. I think it’s as powerful as Django’s ORM, but it’s definitely not as streamlined and putting the pieces together requires time. There also seems to be a lot of old, pre-2.0 information out there that can be confusing. All in all, I think it’s a library worth learning and keeping in your tool belt, especially if you are using FastAPI.

Recent Articles

  1. A symmetrical knot with thick rope lying on weathered wood.
    Article post type

    Anchor Positioning Is Disruptive

    New layouts will be possible

    The more I play with it, the more convinced I am that anchor positioning is going to unlock some surprising new layouts.

    see all Article posts
  2. A pay-to-use pair of binoculars at a scenic overlook.
    Article post type

    Updates to the Most Desired CSS Feature You Can’t Use Yet

    Performance, scope, and fallbacks for the anchor positioning polyfill

    Our sponsors are supporting the continued development of the CSS Anchor Positioning Polyfill. Here’s a summary of the latest updates.

    see all Article posts
  3. A green on dark-gray horizontal bar chart, with the labels cut off
    Article post type

    What do survey demographics tell us?

    Are we measuring what we meant to measure?

    There’s been a lot of interest in the results of the annual State of CSS survey, but are we asking all the right questions?

    see all Article posts