SQLAlchemy `order_by` gotcha

Today I encountered a surprising SQLAlchemy gotcha. In short, if you have a Query object and call its order_by method on the same column more than once, the first ORDER BY is not superseded. Here's an illustrative example. I'll include the boilerplate code (schema, etc.) at the end of the article.

In [1]: session.add(Person(age=19))
In [2]: session.add(Person(age=21))
In [3]: session.add(Person(age=25))

In [4]: session.query(Person).all()
2020-09-08 17:31:58,237 INFO sqlalchemy.engine.base.Engine BEGIN (implicit)
2020-09-08 17:31:58,237 INFO sqlalchemy.engine.base.Engine SELECT person.id AS person_id, person.age AS person_age
FROM person
2020-09-08 17:31:58,237 INFO sqlalchemy.engine.base.Engine ()
Out[3]: [Person<age=19>, Person<age=21>, Person<age=25>]

In [5]: session.query(Person).order_by(asc('age')).all()
2020-09-08 17:32:11,742 INFO sqlalchemy.engine.base.Engine SELECT person.id AS person_id, person.age AS person_age
FROM person ORDER BY person.age ASC
2020-09-08 17:32:11,742 INFO sqlalchemy.engine.base.Engine ()
Out[4]: [Person<age=19>, Person<age=21>, Person<age=25>]

In [6]: session.query(Person).order_by(asc('age')).order_by(desc('age')).all()
2020-09-08 17:32:37,082 INFO sqlalchemy.engine.base.Engine SELECT person.id AS person_id, person.age AS person_age
FROM person ORDER BY person.age ASC, person.age DESC
2020-09-08 17:32:37,082 INFO sqlalchemy.engine.base.Engine ()
Out[5]: [Person<age=19>, Person<age=21>, Person<age=25>]

Can you see why this would be confusing? Even reading the generated SQL, I might have thought the result would be sorted twice, or that the first ORDER BY would be ignored.

So what's the solution? Turns out, it's easy:

In [7]: session.query(Person).order_by(asc('age')).order_by(None).order_by(desc('age')).all()
2020-09-08 17:35:39,602 INFO sqlalchemy.engine.base.Engine SELECT person.id AS person_id, person.age AS person_age
FROM person ORDER BY person.age DESC
2020-09-08 17:35:39,602 INFO sqlalchemy.engine.base.Engine ()
Out[7]: [Person<age=25>, Person<age=21>, Person<age=19>]

This is something to watch out for if you're mutating a Query object as I was!

Here's the supporting code:

from sqlalchemy import create_engine, Column, Integer, desc, asc
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker


engine = create_engine('sqlite:///:memory:', echo=True)

Base = declarative_base()


class Person(Base):
    __tablename__ = 'person'

    id = Column(Integer, primary_key=True)
    age = Column(Integer)

    def __repr__(self):
        return f'Person<age={self.age}>'


Base.metadata.create_all(engine)

Session = sessionmaker(bind=engine)
session = Session()