4Geeks chosen to deliver AI education in the Bahamas alongside Harvard, Oxford, and Columbia.See more

SQLAlchemy JOIN: combine tables in Python ORM

By 4Geeks

Combine tables with join() and outerjoin(), find rows with no match, count across tables and fix the join errors SQLAlchemy shows.

8 min readLast updated
SQLAlchemy JOIN icon

How do you join tables in SQLAlchemy?

Short answer: add .join() to a select(). select(Client.name, Order.id).join(Client.orders) runs an INNER JOIN and returns only the clients that have orders. Use .outerjoin() for a LEFT OUTER JOIN that also keeps the clients without orders.

python
from sqlalchemy import select
 
stmt = select(Client.name, Order.id).join(Client.orders)
print(session.execute(stmt).all())
# [('Ana', 101), ('Ana', 102), ('Eva', 103)]
 
stmt = select(Client.name, Order.id).outerjoin(Client.orders)
print(session.execute(stmt).all())
# [('Ana', 101), ('Ana', 102), ('Luis', None), ('Eva', 103)]

The examples use SQLAlchemy 2.1 and work the same in 2.0. With Flask-SQLAlchemy, write db.select() and db.session instead.

join() keeps only the rows that match, outerjoin() keeps every row of the left table and print() shows the SQL that SQLAlchemy sends.

Every type of JOIN in SQLAlchemy

What you wantSQLAlchemySQL
Only the rows that match.join(Client.orders)INNER JOIN
Every row on the left, matches on the right.outerjoin(Client.orders) or .join(Client.orders, isouter=True)LEFT OUTER JOIN
Every row on both sides.join(Order, full=True)FULL OUTER JOIN
Every row on the rightSwap the tables: select_from(Order).outerjoin(Client)RIGHT JOIN (no method of its own)
Join with your own condition.join(Order, Client.id == Order.client_id)JOIN ... ON ...
Rows with no match.outerjoin(Client.orders).where(Order.id.is_(None))LEFT JOIN ... WHERE ... IS NULL
The same table twicealiased(User) and one .join() for each aliasJOIN users AS users_1

The models used in the examples

Two tables: each client can have many orders. The ForeignKey connects them, and relationship() lets you write Client.orders in your joins.

python
from sqlalchemy import ForeignKey, String, create_engine, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship
 
class Base(DeclarativeBase):
    pass
 
class Client(Base):
    __tablename__ = "clients"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50))
    orders: Mapped[list["Order"]] = relationship(back_populates="client")
 
class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    client_id: Mapped[int] = mapped_column(ForeignKey("clients.id"))
    total: Mapped[int]
    client: Mapped["Client"] = relationship(back_populates="orders")
 
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
session = Session(engine)
session.add_all([
    Client(id=1, name="Ana", orders=[Order(id=101, total=50), Order(id=102, total=20)]),
    Client(id=2, name="Luis"),
    Client(id=3, name="Eva", orders=[Order(id=103, total=80)]),
])
session.commit()

Ana has two orders, Eva has one and Luis has none. Watch what happens to Luis in each join.

Career · AI Engineering for Developers

You already join tables in your apps, now build AI features on top

Use the coding skills you already have to build AI agents, RAG systems and LLM-powered apps, with mentors and real projects.

INNER JOIN with join()

join() returns only the rows that have a match in both tables:

python
stmt = select(Client.name, Order.id).join(Client.orders).order_by(Order.id)
 
for row in session.execute(stmt):
    print(row.name, row.id)
# Ana 101
# Ana 102
# Eva 103

Luis doesn't appear because he has no orders. Ana appears twice because she has two.

You can pass the relationship (Client.orders) or the model (Order). With the model, SQLAlchemy finds the condition from the ForeignKey. If it can't, give it yourself:

python
stmt = (
    select(Client.name, Order.total)
    .join(Order, Client.id == Order.client_id)
    .where(Order.total > 30)
)
print(session.execute(stmt).all())   # [('Ana', 50), ('Eva', 80)]

LEFT OUTER JOIN with outerjoin()

outerjoin() keeps every row of the left table. When there is no match, the columns of the right table come back as None:

python
stmt = select(Client.name, Order.id).outerjoin(Client.orders).order_by(Client.id, Order.id)
print(session.execute(stmt).all())
# [('Ana', 101), ('Ana', 102), ('Luis', None), ('Eva', 103)]

.join(Client.orders, isouter=True) does the same. To find the clients with no orders, filter the empty side:

python
stmt = select(Client.name).outerjoin(Client.orders).where(Order.id.is_(None))
print(session.scalars(stmt).all())   # ['Luis']

RIGHT and FULL OUTER JOIN

SQLAlchemy has no rightjoin(). A RIGHT JOIN is a LEFT JOIN with the tables swapped, so start from the other table with select_from():

python
stmt = select(Order.id, Client.name).select_from(Order).outerjoin(Client)

For a FULL OUTER JOIN, pass full=True. It needs a database that supports it, such as PostgreSQL, or SQLite 3.39 or newer. MySQL doesn't support it.

python
stmt = select(Client.name, Order.id).join(Order, full=True)

See the SQL that SQLAlchemy generates

Print any statement to check the join before you run it:

python
print(select(Client.name, Order.id).outerjoin(Client.orders))
# SELECT clients.name, orders.id
# FROM clients LEFT OUTER JOIN orders ON clients.id = orders.client_id

To see every query while your app runs, create the engine with create_engine(url, echo=True).

Count, filter and load with a JOIN

Count the orders of each client, including the ones with zero:

python
from sqlalchemy import func
 
stmt = (
    select(Client.name, func.count(Order.id))
    .outerjoin(Client.orders)
    .group_by(Client.id)
)
print(session.execute(stmt).all())   # [('Ana', 2), ('Luis', 0), ('Eva', 1)]

When you select whole objects through a join, a client with two orders comes back twice. Add .distinct() to get each one once:

python
stmt = select(Client).join(Client.orders).distinct()
print([c.name for c in session.scalars(stmt)])   # ['Ana', 'Eva']

If you only want to load each client with its orders, you don't need a join. Use selectinload(), which avoids running one query per client:

python
from sqlalchemy.orm import selectinload
 
clients = session.scalars(select(Client).options(selectinload(Client.orders))).all()
print([(c.name, len(c.orders)) for c in clients])   # [('Ana', 2), ('Luis', 0), ('Eva', 1)]

Join the same table twice with aliased()

When a table has two foreign keys to the same table, like a message with a sender and a receiver, give each side its own alias:

python
from sqlalchemy.orm import aliased
 
Sender = aliased(User)
Receiver = aliased(User)
 
stmt = (
    select(Message.id, Sender.name, Receiver.name)
    .join(Sender, Message.sender_id == Sender.id)
    .join(Receiver, Message.receiver_id == Receiver.id)
)

JOIN in Flask-SQLAlchemy and the older query() style

In Flask-SQLAlchemy 3, the same code uses db.select() and db.session:

python
rows = db.session.execute(
    db.select(Client.name, Order.total).join(Client.orders)
).all()

Older projects use session.query(). It still works, and the joins are the same:

python
session.query(Client.name, Order.id).join(Client.orders).all()
session.query(Client.name, Order.id).outerjoin(Client.orders).all()

For new code, use select(). It is the style of SQLAlchemy 2.

Use AI to write and check your joins

An assistant like ChatGPT, Claude or GitHub Copilot can write a join from your models and explain an error. These three prompts are ready to copy.

1. Write a join

These are my SQLAlchemy models: [paste your models].
Write a SQLAlchemy 2 select() that returns [what you need],
including [the rows with no match / only the rows that match]. Explain the join you chose.

2. Translate SQL

Translate this SQL query to SQLAlchemy 2 with select() and my models:
[paste the SQL]
[paste your models]

3. Explain an error

This SQLAlchemy query: [paste the query]
gives this error: [paste the error].
These are my models: [paste them]. Explain why and how to fix it.

Print the statement the assistant gives you and check the SQL before you run it on real data.

Learn with a mentor

From ORM queries to shipping AI features

In the 4Geeks AI Engineering for Developers program you add AI agents, RAG and production LLM apps to your portfolio.

Common problems with SQLAlchemy JOIN

What you seeWhy it happensFix
InvalidRequestError: Don't know how to join to ...There is no ForeignKey or relationship between the tablesGive the condition: .join(Order, Client.id == Order.client_id)
AmbiguousForeignKeysError: Can't determine join between ...The tables are connected by more than one foreign keyWrite the ON condition, and use aliased() for the same table twice
Rows with no match disappearjoin() is an INNER JOINUse outerjoin()
The same object comes back several timesOne row for each match on the other sideAdd .distinct(), or use selectinload() instead of a join
None in the right-hand columnsAn outer join found no match for that rowIt is expected; filter with .where(Order.id.is_not(None)) if you don't want them
A filter turns your LEFT JOIN into an INNER JOIN.where(Order.total > 30) removes the rows where Order is NonePut the condition in the join: .outerjoin(Order, and_(Client.id == Order.client_id, Order.total > 30))
FULL OUTER JOIN fails in MySQLMySQL doesn't support itCombine a left and a right join with union()
Free download

SQLAlchemy JOIN in a 2-page cheat sheet

INNER, LEFT, RIGHT and FULL joins with select(), ON conditions, rows with no match, counting, aliased(), Flask-SQLAlchemy, common errors and prompts for ChatGPT, Claude or Copilot. Free, in PDF.

SQLAlchemy JOIN cheat sheet

2 pages · PDF · 4Geeks

  • Every type of join with the SQL it generates
  • How to find rows with no match and count across tables
  • Common errors and prompts to solve them with AI

Get it free

Your PDF right away

Frequently asked questions about SQLAlchemy JOIN

Want to go from database queries to building AI apps, with real projects and mentors?

See the AI Engineering for Developers program