KAHIBARO
Discord Login Register

12.10. Queries

Querying with an ORM

Working with an ORM is all about expressing database queries in your programming language instead of writing raw SQL. In this chapter we focus on how to read data using queries, not how to define models or manage sessions, which are covered in their own chapters.

Examples below use Python and SQLAlchemy 2.x style (the most common choice with FastAPI), but the ideas are the same in other ORMs.


Basic query patterns

The ORM gives you an object oriented way to express the same things you would do with SQL:

SQL ideaTypical ORM pattern
SELECT * FROM usersselect(User) or session.query(User)
WHERE filter.where(...) or .filter(...)
ORDER BY.order_by(...)
LIMIT/OFFSET.limit(...).offset(...)
JOINjoin() / relationship-based loading
COUNT, SUMfunc.count, func.sum

Below is a minimal setup we will reuse in examples:

python
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import declarative_base, relationship
Base = declarative_base()
class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    email = Column(String, unique=True, nullable=False)
    full_name = Column(String)
    age = Column(Integer)
    posts = relationship("Post", back_populates="author")
class Post(Base):
    __tablename__ = "posts"
    id = Column(Integer, primary_key=True)
    title = Column(String, nullable=False)
    body = Column(String)
    author_id = Column(Integer, ForeignKey("users.id"))
    author = relationship("User", back_populates="posts")

We will assume you already have an active Session object, usually called session.


Selecting all records

Select all rows from a table

In SQL:

sql
SELECT * FROM users;

With SQLAlchemy 2.x style:

python
from sqlalchemy import select
stmt = select(User)
result = session.execute(stmt)
users = result.scalars().all()

Important details:

Typical usage:

python
def get_all_users(session):
    return session.execute(select(User)).scalars().all()

If you forget .scalars() you will get Row objects instead of User instances, which can be confusing.


Filtering with conditions

Filtering is the ORM equivalent of WHERE in SQL.

Simple equality filter

SQL:

sql
SELECT * FROM users WHERE email = 'john@example.com';

ORM:

python
stmt = select(User).where(User.email == "john@example.com")
user = session.execute(stmt).scalar_one_or_none()

scalar_one_or_none() returns:

Use this pattern often for queries by unique keys.

Multiple conditions

SQL:

sql
SELECT * FROM users WHERE age >= 18 AND age <= 30;

ORM:

python
from sqlalchemy import and_
stmt = select(User).where(
    and_(User.age >= 18, User.age <= 30)
)
users = session.execute(stmt).scalars().all()

You can also use Python operators chained, which is usually cleaner:

python
stmt = select(User).where(
    User.age >= 18,
    User.age <= 30,
)

SQLAlchemy treats multiple arguments to where() as an implicit AND.

OR conditions

SQL:

sql
SELECT * FROM users WHERE age < 18 OR age > 65;

ORM:

python
from sqlalchemy import or_
stmt = select(User).where(
    or_(User.age < 18, User.age > 65)
)
users = session.execute(stmt).scalars().all()

IN and NOT IN

SQL:

sql
SELECT * FROM users WHERE id IN (1, 2, 3);

ORM:

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

For NOT IN:

python
stmt = select(User).where(~User.id.in_(user_ids))

The ~ is “NOT” here.

LIKE and ILIKE (pattern matching)

SQL:

sql
SELECT * FROM users WHERE full_name LIKE 'John%';

ORM:

python
stmt = select(User).where(User.full_name.like("John%"))
users = session.execute(stmt).scalars().all()

Case insensitive pattern matching (PostgreSQL):

python
stmt = select(User).where(User.full_name.ilike("%smith%"))

Important rule: Never build filters by string concatenation with user input. Always use ORM expressions like User.email == user_email or parameters to avoid SQL injection.


Ordering and limiting results

ORDER BY

SQL:

sql
SELECT * FROM users ORDER BY age DESC, id ASC;

ORM:

python
from sqlalchemy import desc
stmt = select(User).order_by(desc(User.age), User.id)
users = session.execute(stmt).scalars().all()

You can also use User.age.desc():

python
stmt = select(User).order_by(User.age.desc(), User.id.asc())

LIMIT and OFFSET

SQL:

sql
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 20;

ORM:

python
stmt = (
    select(User)
    .order_by(User.id)
    .limit(10)
    .offset(20)
)
users = session.execute(stmt).scalars().all()

This combination of order_by, limit, and offset is the classic pagination pattern for APIs.


Getting single objects

There are several helpers for fetching one row.

`first()`, `one()`, `one_or_none()`, `scalar_one_or_none()`

Common patterns:

python
# First row or None
user = session.execute(
    select(User).where(User.email == email)
).scalars().first()
# Exactly one row, or raise if 0 or > 1
user = session.execute(
    select(User).where(User.id == user_id)
).scalar_one()
# One row or None, raise if > 1
user = session.execute(
    select(User).where(User.id == user_id)
).scalar_one_or_none()

Typical use in an API:

python
def get_user_or_404(session, user_id: int):
    user = session.execute(
        select(User).where(User.id == user_id)
    ).scalar_one_or_none()
    if user is None:
        # In a FastAPI app, you might raise HTTPException(status_code=404)
        raise ValueError("User not found")
    return user

Projections: selecting specific columns

You do not always need full model instances. You can select specific columns.

SQL:

sql
SELECT id, email FROM users;

ORM:

python
stmt = select(User.id, User.email)
rows = session.execute(stmt).all()
for row in rows:
    print(row.id, row.email)

You get Row objects with those fields.

You can also select expressions:

python
from sqlalchemy import func
stmt = select(
    User.id,
    User.email,
    func.length(User.full_name).label("name_length"),
)
rows = session.execute(stmt).all()
for row in rows:
    print(row.id, row.email, row.name_length)

Labeling expressions with .label("...") gives them a column name.


Aggregations and counting

Counting rows

SQL:

sql
SELECT COUNT(*) FROM users;

ORM:

python
from sqlalchemy import func
stmt = select(func.count(User.id))
total_users = session.execute(stmt).scalar_one()

Filter and count:

python
stmt = select(func.count(User.id)).where(User.age >= 18)
adult_count = session.execute(stmt).scalar_one()

Grouping and aggregates

SQL:

sql
SELECT age, COUNT(*) FROM users GROUP BY age;

ORM:

python
stmt = (
    select(User.age, func.count(User.id).label("count"))
    .group_by(User.age)
)
rows = session.execute(stmt).all()
for age, count in rows:
    print(age, count)

Multiple aggregates:

python
stmt = select(
    func.min(User.age),
    func.max(User.age),
    func.avg(User.age),
)
min_age, max_age, avg_age = session.execute(stmt).one()

Important pattern: Use func for database functions like COUNT, MAX, LOWER, etc. This keeps your query portable and parameterized.


Joining related tables

Joins let you query across relationships in one go.

Simple join

SQL:

sql
SELECT * FROM posts JOIN users ON posts.author_id = users.id;

ORM:

python
from sqlalchemy import select, join
stmt = (
    select(Post, User)
    .join(User, Post.author_id == User.id)
)
rows = session.execute(stmt).all()
for post, author in rows:
    print(post.title, "by", author.email)

Note that each row from execute contains both models.

Join using relationships

If you have relationships defined, you can join via the relationship.

python
stmt = select(Post).join(Post.author).where(User.email == "john@example.com")
posts = session.execute(stmt).scalars().all()

Here, Post.author refers to the relationship. SQLAlchemy builds the join condition automatically.

Filtering across relationships

Find all users who have at least one post:

python
stmt = (
    select(User)
    .join(User.posts)
    .group_by(User.id)
)
users_with_posts = session.execute(stmt).scalars().all()

Filter posts by author age:

python
stmt = (
    select(Post)
    .join(Post.author)
    .where(User.age >= 18)
)
posts = session.execute(stmt).scalars().all()

Eager loading vs lazy loading

When you access user.posts, by default the ORM will often do a separate query the moment you access the attribute. This is called lazy loading.

Lazy loading example:

python
users = session.execute(select(User)).scalars().all()
for user in users:
    # This may run another SQL query each time
    print(user.email, len(user.posts))

This can cause many small queries, sometimes called the N+1 query problem.

Avoiding N+1 with eager loading

You can ask the ORM to load related data in the same query.

python
from sqlalchemy.orm import selectinload
stmt = select(User).options(selectinload(User.posts))
users = session.execute(stmt).scalars().all()
for user in users:
    # Now user.posts is already loaded
    print(user.email, len(user.posts))

There are several eager loading strategies:

StrategyPurpose
selectinloadDoes a separate IN query for related rows, efficient for many parents
joinedloadUses JOIN to load relationships in one big query

Example with joinedload:

python
from sqlalchemy.orm import joinedload
stmt = select(Post).options(joinedload(Post.author))
posts = session.execute(stmt).scalars().all()
for post in posts:
    # post.author already loaded
    print(post.title, post.author.email)

Important rule: Be explicit about loading strategies when you need related data for many rows. This avoids hidden extra queries and performance issues.


Querying JSON fields (PostgreSQL example)

If your model has a JSON column:

python
from sqlalchemy import JSON
class Event(Base):
    __tablename__ = "events"
    id = Column(Integer, primary_key=True)
    type = Column(String, nullable=False)
    payload = Column(JSON, nullable=False)

You can filter on JSON keys:

python
from sqlalchemy import cast, String
# payload: {"user_id": 123, "source": "mobile"}
stmt = select(Event).where(Event.payload["user_id"].as_integer() == 123)
events = session.execute(stmt).scalars().all()

Or check nested structures, depending on your database capabilities. The exact syntax can depend on backend, but the idea is the same: access JSON keys through the column object.


Combining queries into reusable functions

You rarely put query logic directly in your route handlers. You usually wrap it in functions or repository methods.

Example "repository" style:

python
from typing import Sequence, Optional
from sqlalchemy import select
class UserRepository:
    def __init__(self, session):
        self.session = session
    def list_users(
        self,
        limit: int = 100,
        offset: int = 0,
    ) -> Sequence[User]:
        stmt = (
            select(User)
            .order_by(User.id)
            .limit(limit)
            .offset(offset)
        )
        return self.session.execute(stmt).scalars().all()
    def get_by_email(self, email: str) -> Optional[User]:
        stmt = select(User).where(User.email == email)
        return self.session.execute(stmt).scalar_one_or_none()
    def list_by_min_age(self, min_age: int) -> Sequence[User]:
        stmt = select(User).where(User.age >= min_age)
        return self.session.execute(stmt).scalars().all()

Then your API code becomes simple:

python
def list_users_endpoint(session=Depends(get_session)):
    repo = UserRepository(session)
    return repo.list_users(limit=50, offset=0)

This keeps your queries testable and easier to change.


Pagination, filtering, and searching in APIs

Backend APIs usually combine these query patterns together.

Imagine an endpoint:

text
GET /users?search=john&min_age=18&max_age=40&limit=20&offset=0

You can build the query step by step.

python
from typing import Optional
from sqlalchemy import select, and_, or_
def search_users(
    session,
    search: Optional[str] = None,
    min_age: Optional[int] = None,
    max_age: Optional[int] = None,
    limit: int = 20,
    offset: int = 0,
):
    stmt = select(User)
    # Dynamic filters
    conditions = []
    if search:
        like_pattern = f"%{search}%"
        conditions.append(
            or_(
                User.email.ilike(like_pattern),
                User.full_name.ilike(like_pattern),
            )
        )
    if min_age is not None:
        conditions.append(User.age >= min_age)
    if max_age is not None:
        conditions.append(User.age <= max_age)
    if conditions:
        stmt = stmt.where(and_(*conditions))
    stmt = stmt.order_by(User.id).limit(limit).offset(offset)
    users = session.execute(stmt).scalars().all()
    return users

This pattern lets you assemble flexible queries based on request parameters without ever touching raw SQL.


Common pitfalls and how to avoid them

  1. Loading too much data

Example of a problem:

python
   users = session.execute(select(User)).scalars().all()  # thousands of users

Better: always limit or paginate.

python
   users = session.execute(
       select(User).order_by(User.id).limit(100)
   ).scalars().all()
  1. N+1 problem

Looping and accessing related objects inside the loop without eager loading:

python
   users = session.execute(select(User)).scalars().all()
   for user in users:
       # Each `user.posts` might trigger its own query
       print(user.email, len(user.posts))

Fix with selectinload:

python
   stmt = select(User).options(selectinload(User.posts))
   users = session.execute(stmt).scalars().all()
  1. Session closed or expired

If you access user.posts after the session is closed, lazy loading will fail. If you must use objects after the session is closed, load relationships eagerly.

  1. Mixing raw SQL and ORM expressions incorrectly

If you need raw SQL, use text() or session.execute(text(...)). Do not concatenate raw strings with ORM expressions. Keep one approach per query.


Summary

In this chapter you learned how to:

These querying techniques are the core of reading data from your database through an ORM. In the next chapters you will see how to combine them with transactions, connection pooling, and the repository pattern to build robust backend applications.

Views: 7

Comments

Please login to add a comment.

Don't have an account? Register now!