Chapter 17 · SQL Dialects, Tools, and Application Access

ORMs, Query Builders, and Raw SQL Tradeoffs

Every data-access abstraction trades visibility and control for composition and productivity. Mature systems do not choose one tool dogmatically: they define where object mapping helps, where composable expressions are safer, and where direct SQL is the clearest contract.

Intermediate160–195 minutesAbstraction design + SQLAlchemy laboratoryLast reviewed: August 2026

Learning outcomes

Learning outcomes

01

Compare raw SQL, query builders, SQL toolkits, active-record patterns, and data-mapper ORMs.

02

Use SQLAlchemy Core and ORM while preserving explicit transactions and parameter binding.

03

Detect N+1 queries, accidental Cartesian products, over-fetching, and hidden write behavior.

04

Define escape hatches for analytical SQL and vendor-specific capabilities.

05

Test generated SQL by semantics, query count, plan, and database integration—not only mocked objects.

The abstraction continuum

ApproachStrengthMain risk
Raw SQLMaximum clarity and access to database featuresManual composition, mapping, and portability work
Query builder / SQL toolkitComposable, parameterized expressions with schema awarenessGenerated SQL can become difficult to reason about
Micro-ORMLight mapping while SQL remains visibleLimited relationship and unit-of-work behavior
Data-mapper ORMRich domain mapping, identity map, relationships, unit of workHidden queries, object/relational mismatch, complex lifecycle
Active recordSimple model-centric CRUDPersistence concerns spread through domain objects and tests
Use case and data shape
Choose abstraction level
Inspect emitted SQL
Execute in transaction
Measure query count + plan
Keep explicit escape hatch

Choose per operation, not as a permanent ideology for the whole codebase.

SQLAlchemy Core: relational composition

python · Core query
from sqlalchemy import MetaData, Table, create_engine, func, selectengine = create_engine("sqlite+pysqlite:///academy.db")metadata = MetaData()customer = Table("customer", metadata, autoload_with=engine)order = Table("sales_order", metadata, autoload_with=engine)item = Table("order_item", metadata, autoload_with=engine)statement = (    select(        customer.c.region,        func.count(func.distinct(order.c.order_id)).label("orders"),        func.sum(item.c.quantity * item.c.unit_price_cents).label("revenue_cents"),    )    .join(order, order.c.customer_id == customer.c.customer_id)    .join(item, item.c.order_id == order.c.order_id)    .where(order.c.status == "paid")    .group_by(customer.c.region)    .order_by(customer.c.region))with engine.connect() as connection:    print(connection.execute(statement).all())

Core keeps tables, joins, expressions, and result columns visible while compiling parameters and dialect syntax.

ORM: object identity and unit of work

python · mapped entities and explicit session
from sqlalchemy import ForeignKey, String, selectfrom sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationshipclass Base(DeclarativeBase):    passclass Customer(Base):    __tablename__ = "customer"    customer_id: Mapped[int] = mapped_column(primary_key=True)    email: Mapped[str] = mapped_column(String(320), unique=True)    orders: Mapped[list["Order"]] = relationship(back_populates="customer")class Order(Base):    __tablename__ = "sales_order"    order_id: Mapped[int] = mapped_column(primary_key=True)    customer_id: Mapped[int] = mapped_column(ForeignKey("customer.customer_id"))    status: Mapped[str]    customer: Mapped[Customer] = relationship(back_populates="orders")with Session(engine) as session, session.begin():    customer = session.scalar(select(Customer).where(Customer.email == "ada@example.com"))    customer.orders.append(Order(status="submitted"))

The session is not a neutral collection. It tracks identity and changes, flushes SQL, and owns a transaction. Keep its lifetime aligned with one application unit of work.

The N+1 query failure

python · hidden lazy loads
customers = session.scalars(select(Customer)).all()for customer in customers:    # May emit one additional SELECT per customer.    print(customer.email, len(customer.orders))
python · intentional eager loading
from sqlalchemy.orm import selectinloadstatement = (    select(Customer)    .options(selectinload(Customer.orders))    .order_by(Customer.customer_id))customers = session.scalars(statement).all()
TestWhat to assert
Query-count testBound the number of statements for representative data sizes
SQL captureInspect joins, predicates, parameters, and selected columns
Plan testVerify important queries use expected access paths
Cardinality testUse multiple parents/children to expose fan-out and duplicate assumptions
Transaction testVerify rollback and retry behavior around flush/commit
Concurrency testExercise uniqueness, optimistic locking, and stale data

Raw SQL remains a first-class tool

python · textual SQL with explicit result contract
from dataclasses import dataclassfrom sqlalchemy import text@dataclass(frozen=True)class RegionRevenue:    region: str    revenue_cents: intstatement = text("""    SELECT c.region,           SUM(i.quantity * i.unit_price_cents) AS revenue_cents    FROM customer AS c    JOIN sales_order AS o ON o.customer_id = c.customer_id    JOIN order_item AS i ON i.order_id = o.order_id    WHERE o.status = :status    GROUP BY c.region    ORDER BY c.region""")with engine.connect() as connection:    report = [RegionRevenue(**row) for row in connection.execute(statement,{"status":"paid"}).mappings()]

Raw SQL is often clearest for reports, CTE-heavy transformations, window functions, bulk operations, optimizer hints, or database-specific features. Keep parameters and result contracts explicit.

Decision framework

QuestionPrefer higher abstraction when…Prefer lower abstraction when…
ShapeRows map naturally to domain entitiesResult is aggregate, hierarchical, or report-shaped
LifecycleIdentity and relationship changes are centralOperation is stateless or set-based
PortabilitySeveral engines must be supportedOne engine feature is a strategic requirement
PerformanceAccess patterns are simple and boundedPlan, batching, locking, or exact SQL matters
Team skillDomain modeling is strongerSQL and database reasoning are stronger
Change frequencyCRUD behavior changes frequentlyStable query contract deserves direct optimization

Rules for a hybrid data-access layer

TX

One transaction owner

Do not mix independent ORM sessions and raw connections inside one logical operation without sharing the transaction.

OBS

Observe SQL

Log normalized statement identity, duration, rows, errors, and query count without logging secrets.

ESC

Document escape hatches

State when raw SQL or dialect APIs are allowed and how results are tested.

SEP

Separate models

Persistence models, API schemas, and domain objects may have different responsibilities.

Abstraction review

  1. Why can an ORM produce correct results but still create a production problem?
  2. When is raw SQL safer than an ORM expression?
  3. Why should the session lifetime be short and explicit?
  4. What should integration tests inspect beyond returned values?
Review the answers

Hidden N+1 queries, excessive columns, long transactions, and bad plans can preserve correctness while destroying performance. Raw SQL can be safer when the exact set-based operation and lock semantics are easier to review directly. A short session bounds identity state and transaction lifetime. Tests should inspect query count, emitted SQL, parameters, plans, transaction behavior, and real constraint failures.

Lesson summary

  • Choose the least powerful abstraction that keeps the operation clear and correct.
  • Keep transaction ownership explicit regardless of API style.
  • Detect hidden I/O with query-count and integration tests.
  • Maintain a governed path to raw SQL and vendor-specific capabilities.

References

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.