<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Simon Islam — testing</title><description>Posts tagged with &quot;testing&quot;.</description><link>https://simonislam.com/</link><item><title>Catching N+1 Queries with SQLAlchemy Events</title><link>https://simonislam.com/writing/catching-n1-queries-with-sqlalchemy-events/</link><guid isPermaLink="true">https://simonislam.com/writing/catching-n1-queries-with-sqlalchemy-events/</guid><description>A quiet exploration of ORM observability with zero external dependencies.</description><pubDate>Sat, 06 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;Imagine this, the devs ships a feature. Tests are passing wihtout a hitch. The reviewer approves it. And then the everything gets slow.&lt;/p&gt;
&lt;p&gt;The logs show nothing unusual. No errors or exceptions. No timeouts. Just a database CPU that keeps climbing. And eventually, the cause surfaces: a single api load is generating hundreds of SQL queries.&lt;/p&gt;
&lt;p&gt;The N+1 query problem is the most common ORM performance issue. It&amp;#39;s also the hardest to catch before production. The code looks correct. The logic is sound. The bug only appears when you count the queries.&lt;/p&gt;
&lt;p&gt;The real question is how to catch this before it ships. Fixes get merged, but the pattern resurfaces again, lost somewhere between schemas and serializers. That&amp;#39;s what led me to SQLAlchemy&amp;#39;s event system. It fires on every query. And it turns out, nothing extra is needed to see what&amp;#39;s happening.&lt;/p&gt;
&lt;p&gt;This is what I built from that: a lightweight observability layer that detects N+1 queries, prevents performance regressions in tests, monitors slow queries, and groups similar queries to find hot patterns.&lt;/p&gt;
&lt;p&gt;No external dependencies. Just the ORM.&lt;/p&gt;
&lt;h2&gt;The Hidden Cost of ORMs&lt;/h2&gt;
&lt;p&gt;ORMs make database code readable. Instead of SQL, one write:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;for user in users:
    print(user.posts)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&amp;#39;s clean. But it hides something.&lt;/p&gt;
&lt;p&gt;When &lt;code&gt;user.posts&lt;/code&gt; is accessed, SQLAlchemy fires a query to load that user&amp;#39;s posts. Do this in a loop, and the cost compounds:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;1 query&lt;/strong&gt; to load all users&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;N queries&lt;/strong&gt; to load posts for each user&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Total: &lt;strong&gt;N + 1 queries&lt;/strong&gt;. With 100 users, that&amp;#39;s 101. With 10,000, that&amp;#39;s 10,001.&lt;/p&gt;
&lt;p&gt;The code doesn&amp;#39;t look wrong. The logic is correct. But the database cost grows linearly with data size.&lt;/p&gt;
&lt;h2&gt;Reproducing the Problem&lt;/h2&gt;
&lt;p&gt;A minimal example helps make it concrete. Two models:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sqlalchemy.orm import DeclarativeBase, relationship
from sqlalchemy import Column, Integer, String, ForeignKey

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = &amp;quot;users&amp;quot;
    id = Column(Integer, primary_key=True)
    name = Column(String(100), nullable=False)
    posts = relationship(&amp;quot;Post&amp;quot;, back_populates=&amp;quot;user&amp;quot;, lazy=&amp;quot;select&amp;quot;)

class Post(Base):
    __tablename__ = &amp;quot;posts&amp;quot;
    id = Column(Integer, primary_key=True)
    title = Column(String(200), nullable=False)
    user_id = Column(Integer, ForeignKey(&amp;quot;users.id&amp;quot;), nullable=False)
    user = relationship(&amp;quot;User&amp;quot;, back_populates=&amp;quot;posts&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key detail: &lt;code&gt;lazy=&amp;quot;select&amp;quot;&lt;/code&gt;. SQLAlchemy&amp;#39;s default. It means &amp;quot;load posts only when accessed.&amp;quot; That&amp;#39;s convenient. It&amp;#39;s also what causes N+1.&lt;/p&gt;
&lt;p&gt;Here&amp;#39;s the service function that triggers it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sqlalchemy import select

def get_all_users_with_posts_n_plus_one(db):
    stmt = select(User)
    users = db.scalars(stmt).all()

    result = []
    for user in users:
        result.append({
            &amp;quot;id&amp;quot;: user.id,
            &amp;quot;name&amp;quot;: user.name,
            &amp;quot;posts&amp;quot;: [{&amp;quot;id&amp;quot;: p.id, &amp;quot;title&amp;quot;: p.title} for p in user.posts],
        })
    return result
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When &lt;code&gt;user.posts&lt;/code&gt; is accessed inside the loop, SQLAlchemy fires a new query:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT posts.id, posts.title, posts.user_id
FROM posts
WHERE posts.user_id = ?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This happens once per user. With 100 users and 5 posts each, that&amp;#39;s 101 queries instead of 2.&lt;/p&gt;
&lt;p&gt;The query flow:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;sequenceDiagram
    participant Client
    participant Endpoint
    participant ORM
    participant Database

    Client-&amp;gt;&amp;gt;Endpoint: GET /users
    Endpoint-&amp;gt;&amp;gt;ORM: select(User)
    ORM-&amp;gt;&amp;gt;Database: SELECT * FROM users
    Database--&amp;gt;&amp;gt;ORM: 100 users
    ORM--&amp;gt;&amp;gt;Endpoint: User objects

    loop For each user (100 times)
        Endpoint-&amp;gt;&amp;gt;ORM: user.posts
        ORM-&amp;gt;&amp;gt;Database: SELECT * FROM posts WHERE user_id = ?
        Database--&amp;gt;&amp;gt;ORM: Posts for this user
        ORM--&amp;gt;&amp;gt;Endpoint: Post objects
    end

    Endpoint--&amp;gt;&amp;gt;Client: Response with all data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same query pattern repeats 100 times. Each one is fast individually. Together, they&amp;#39;re a performance problem.&lt;/p&gt;
&lt;h2&gt;Building a Query Counter&lt;/h2&gt;
&lt;p&gt;To see what&amp;#39;s happening, SQLAlchemy&amp;#39;s event system provides two hooks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;before_cursor_execute&lt;/code&gt; - fires right before a query runs&lt;/li&gt;
&lt;li&gt;&lt;code&gt;after_cursor_execute&lt;/code&gt; - fires right after a query completes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Attaching listeners to these events makes it possible to count queries, measure duration, and collect the SQL.&lt;/p&gt;
&lt;p&gt;The implementation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import time
import threading
from contextlib import contextmanager
from dataclasses import dataclass, field
from sqlalchemy import event, Engine
from sqlalchemy.engine import Connection

@dataclass
class QueryRecord:
    sql: str
    params: Optional[dict]
    duration: float
    timestamp: float

@dataclass
class QueryCounter:
    engine: Engine
    queries: list[QueryRecord] = field(default_factory=list)
    _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
    _listener_registered: bool = field(default=False, repr=False)

    @property
    def query_count(self) -&amp;gt; int:
        return len(self.queries)

    @property
    def total_duration(self) -&amp;gt; float:
        return sum(q.duration for q in self.queries)

    def _on_before_execute(self, conn, cursor, statement, parameters, context, executemany):
        conn.info[&amp;quot;_query_start_time&amp;quot;] = time.perf_counter()
        conn.info[&amp;quot;_query_statement&amp;quot;] = statement
        conn.info[&amp;quot;_query_parameters&amp;quot;] = parameters

    def _on_after_execute(self, conn, cursor, statement, parameters, context, executemany):
        start_time = conn.info.pop(&amp;quot;_query_start_time&amp;quot;, None)
        if start_time is not None:
            duration = time.perf_counter() - start_time
            record = QueryRecord(
                sql=statement,
                params=parameters,
                duration=duration,
                timestamp=time.time(),
            )
            with self._lock:
                self.queries.append(record)

    def __enter__(self):
        event.listen(self.engine, &amp;quot;before_cursor_execute&amp;quot;, self._on_before_execute)
        event.listen(self.engine, &amp;quot;after_cursor_execute&amp;quot;, self._on_after_execute)
        self._listener_registered = True
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self._listener_registered:
            event.remove(self.engine, &amp;quot;before_cursor_execute&amp;quot;, self._on_before_execute)
            event.remove(self.engine, &amp;quot;after_cursor_execute&amp;quot;, self._on_after_execute)
            self._listener_registered = False
        return False
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;How It Works&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;__enter__&lt;/code&gt; registers event listeners on the engine. Every query through that engine triggers the callbacks.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;_on_before_execute&lt;/code&gt; stores the start time and SQL in &lt;code&gt;conn.info&lt;/code&gt; -- a per-connection dictionary that persists across the event pair.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;_on_after_execute&lt;/code&gt; calculates the duration and appends a &lt;code&gt;QueryRecord&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;__exit__&lt;/code&gt; removes the listeners. This matters -- without cleanup, the listeners persist and affect other code.&lt;/p&gt;
&lt;p&gt;The context manager pattern keeps the API clean:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with QueryCounter(engine) as qc:
    result = get_all_users_with_posts_n_plus_one(db)

print(f&amp;quot;Executed {qc.query_count} queries&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;How SQLAlchemy Events Work Internally&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;event.listen(engine, &amp;quot;before_cursor_execute&amp;quot;, callback)&lt;/code&gt; adds the callback to an internal list. Every time the engine executes a cursor, it iterates through that list.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;conn.info&lt;/code&gt; dictionary is per-connection storage. It&amp;#39;s how the start time passes from &lt;code&gt;_on_before_execute&lt;/code&gt; to &lt;code&gt;_on_after_execute&lt;/code&gt; without global state.&lt;/p&gt;
&lt;p&gt;For more details, see the &lt;a href=&quot;https://docs.sqlalchemy.org/en/20/core/events.html&quot;&gt;SQLAlchemy event documentation&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Detecting N+1 in Tests&lt;/h2&gt;
&lt;p&gt;With a way to count queries, the next step was putting it in tests.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from app.observability import QueryCounter

def test_n_plus_one_triggers_many_queries(seeded_engine):
    &amp;quot;&amp;quot;&amp;quot;The naive implementation should trigger 101+ queries.&amp;quot;&amp;quot;&amp;quot;
    Session = sessionmaker(bind=seeded_engine)
    with QueryCounter(seeded_engine) as qc:
        db = Session()
        result = get_all_users_with_posts_n_plus_one(db)
        db.close()

    assert len(result) == 100
    assert qc.query_count &amp;gt;= 101
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This test passes when the N+1 bug is present. It documents the problem and quantifies its cost.&lt;/p&gt;
&lt;p&gt;The regression guard is where it becomes useful:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_selectinload_regression_guard(seeded_engine):
    &amp;quot;&amp;quot;&amp;quot;Regression test: selectinload must not exceed 2 queries.&amp;quot;&amp;quot;&amp;quot;
    Session = sessionmaker(bind=seeded_engine)
    with QueryCounter(seeded_engine) as qc:
        db = Session()
        result = get_all_users_with_posts_selectinload(db)
        db.close()

    assert qc.query_count &amp;lt;= 2, (
        f&amp;quot;Query count regression: expected &amp;lt;= 2, got {qc.query_count}&amp;quot;
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This fails if a new query gets introduced. It&amp;#39;s a performance contract enforced by the test suite.&lt;/p&gt;
&lt;h3&gt;Why This Belongs in CI&lt;/h3&gt;
&lt;p&gt;Performance regressions are silent. They don&amp;#39;t break functionality. They don&amp;#39;t raise exceptions. They just make things slower, one query at a time.&lt;/p&gt;
&lt;p&gt;Query count assertions in tests provide:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Automatic detection&lt;/strong&gt; of new queries introduced by code changes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Quantified baselines&lt;/strong&gt; so &amp;quot;normal&amp;quot; has a number attached to it&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fail-fast behavior&lt;/strong&gt; for the CI pipeline  to reject the PR before it merges&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Fixing It&lt;/h2&gt;
&lt;p&gt;SQLAlchemy provides two eager loading strategies.&lt;/p&gt;
&lt;h3&gt;selectinload()&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sqlalchemy.orm import selectinload

def get_all_users_with_posts_selectinload(db):
    stmt = select(User).options(selectinload(User.posts))
    users = db.scalars(stmt).all()
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This generates &lt;strong&gt;2 queries&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT users.id, users.name FROM users;
SELECT posts.id, posts.title, posts.user_id FROM posts WHERE posts.user_id IN (?, ?, ?, ...);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second query uses an &lt;code&gt;IN&lt;/code&gt; clause with all user IDs. The database can use an index on &lt;code&gt;user_id&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;joinedload()&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sqlalchemy.orm import joinedload

def get_all_users_with_posts_joinedload(db):
    stmt = select(User).options(joinedload(User.posts))
    users = db.scalars(stmt).unique().all()
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This generates &lt;strong&gt;1 query&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT users.id, users.name, posts.id AS id_1, posts.title, posts.user_id
FROM users
LEFT OUTER JOIN posts ON users.id = posts.user_id;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;.unique()&lt;/code&gt; call matters -- without it, SQLAlchemy returns duplicate User objects, one per joined post row.&lt;/p&gt;
&lt;h3&gt;Tradeoffs&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Query Count&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;th&gt;Caveat&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Lazy loading&lt;/td&gt;
&lt;td&gt;N + 1&lt;/td&gt;
&lt;td&gt;Small datasets&lt;/td&gt;
&lt;td&gt;Performance disaster at scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;selectinload()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Medium datasets, large collections&lt;/td&gt;
&lt;td&gt;Two round trips to DB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;joinedload()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Small collections, single related object&lt;/td&gt;
&lt;td&gt;Result set can be large with many rows&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;selectinload()&lt;/code&gt; works well for collections (one-to-many). &lt;code&gt;joinedload()&lt;/code&gt; works better for single related objects (many-to-one).&lt;/p&gt;
&lt;p&gt;For more details, see the &lt;a href=&quot;https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html&quot;&gt;SQLAlchemy Relationship Loading Techniques&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Before and After&lt;/h2&gt;
&lt;p&gt;The comparison from the demo (10 users, 3 posts each):&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Query Count&lt;/th&gt;
&lt;th&gt;Reduction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Lazy Loading (N+1)&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;selectinload()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;82% fewer queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;joinedload()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;91% fewer queries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;With 100 users and 5 posts each:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Query Count&lt;/th&gt;
&lt;th&gt;Reduction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Lazy Loading (N+1)&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;selectinload()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;98% fewer queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;joinedload()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;99% fewer queries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The improvement scales with data size.&lt;/p&gt;
&lt;h2&gt;SQLAlchemy Events Are More Powerful Than You Think&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;QueryCounter&lt;/code&gt; is just the start. The same event mechanism can also:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Track query duration to find slow queries&lt;/li&gt;
&lt;li&gt;Normalize SQL to group similar queries&lt;/li&gt;
&lt;li&gt;Collect per-request metrics for endpoint analysis&lt;/li&gt;
&lt;li&gt;Detect duplicate query patterns&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Building a Lightweight Database Observability Layer&lt;/h2&gt;
&lt;h3&gt;Slow Query Monitoring&lt;/h3&gt;
&lt;p&gt;Sometimes the problem isn&amp;#39;t how many queries, but how long they take. A single slow query can be worse than 100 fast ones.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@dataclass
class SlowQueryTracker:
    engine: Engine
    threshold: float = 0.1  # 100ms
    slow_queries: list[SlowQueryRecord] = field(default_factory=list)

    def _on_after_execute(self, conn, cursor, statement, parameters, context, executemany):
        start = conn.info.pop(&amp;quot;_slow_query_start&amp;quot;, None)
        if start is not None:
            duration = time.perf_counter() - start
            if duration &amp;gt;= self.threshold:
                self.slow_queries.append(SlowQueryRecord(
                    sql=statement,
                    duration=duration,
                    threshold=self.threshold,
                    timestamp=time.time(),
                ))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Usage:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with SlowQueryTracker(engine, threshold=0.25) as tracker:
    run_expensive_report()

if tracker.slow_count &amp;gt; 0:
    print(f&amp;quot;Warning: {tracker.slow_count} queries exceeded 250ms&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Query Fingerprinting&lt;/h3&gt;
&lt;p&gt;Some query patterns appear constantly. Normalizing SQL by replacing literal values with placeholders makes them visible:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import re

def fingerprint_sql(sql: str) -&amp;gt; str:
    normalized = sql.strip()
    normalized = re.sub(r&amp;quot;&amp;#39;[^&amp;#39;]*&amp;#39;&amp;quot;, &amp;quot;?&amp;quot;, normalized)
    normalized = re.sub(r&amp;quot;\b\d+\b&amp;quot;, &amp;quot;?&amp;quot;, normalized)
    normalized = &amp;quot; &amp;quot;.join(normalized.split())
    return normalized
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This turns:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT * FROM users WHERE id = 1
SELECT * FROM users WHERE id = 42
SELECT * FROM users WHERE id = 99
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Into a single fingerprint:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT * FROM users WHERE id = ?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;FingerprintCollector&lt;/code&gt; groups queries by their normalized pattern:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with FingerprintCollector(engine) as collector:
    run_application_workload()

for fp in collector.get_hottest_query(n=5):
    print(f&amp;quot;Executed {fp.count}x ({fp.total_duration:.4f}s total)&amp;quot;)
    print(f&amp;quot;  {fp.pattern}&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sample output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;Query Fingerprint Report
============================================================
Total unique patterns: 3

  [1] Executed 100x (0.0234s total)
      SELECT posts.id, posts.title, posts.user_id FROM posts WHERE posts.user_id = ?

  [2] Executed 1x (0.0012s total)
      SELECT users.id, users.name FROM users

  [3] Executed 1x (0.0008s total)
      SELECT 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The hottest pattern stands out immediately: the N+1 query, executed 100 times.&lt;/p&gt;
&lt;h3&gt;Request-Level Metrics&lt;/h3&gt;
&lt;p&gt;Combining these tools makes per-request dashboards possible:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from app.observability import QueryCounter

@app.middleware(&amp;quot;http&amp;quot;)
async def track_db_metrics(request, call_next):
    with QueryCounter(engine) as qc:
        response = await call_next(request)

    response.headers[&amp;quot;X-Query-Count&amp;quot;] = str(qc.query_count)
    response.headers[&amp;quot;X-DB-Time&amp;quot;] = f&amp;quot;{qc.total_duration:.4f}&amp;quot;
    return response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every response carries its database cost. The metrics can be logged, sent to a monitoring system, or used in load testing.&lt;/p&gt;
&lt;h3&gt;Endpoint Cost Visibility&lt;/h3&gt;
&lt;p&gt;With query counting in place, cost reports emerge naturally:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;Endpoint                    Queries    DB Time (ms)
GET /users/n-plus-one       101        23.4
GET /users/selectinload     2          1.2
GET /users/joinedload       1          0.8
GET /health                 0          0.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The expensive endpoint is obvious. No profiling tool needed.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;CI/CD Integration&lt;/h2&gt;
&lt;p&gt;This is how query count regression testing fits into a pipeline.&lt;/p&gt;
&lt;h3&gt;1. Add Query Assertions to Tests&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_endpoint_query_count(client):
    with QueryCounter(test_engine) as qc:
        response = client.get(&amp;quot;/users/selectinload&amp;quot;)
    assert response.status_code == 200
    assert qc.query_count &amp;lt;= 2
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. Set Baselines for Each Endpoint&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;EXPECTED_QUERY_COUNTS = {
    &amp;quot;/users/selectinload&amp;quot;: 2,
    &amp;quot;/users/joinedload&amp;quot;: 1,
    &amp;quot;/posts/list&amp;quot;: 3,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. Run Regression Tests in CI&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/test.yml
- name: Run tests
  run: pytest tests/ -v --cov=app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If a PR increases query counts beyond the baseline, the test fails. Feedback is immediate.&lt;/p&gt;
&lt;h3&gt;4. Monitor in Staging&lt;/h3&gt;
&lt;p&gt;Deploy with query counting enabled in staging. Collect metrics over a few days. Compare against production baselines.&lt;/p&gt;
&lt;h2&gt;Lessons Learned&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;N+1 bugs are invisible in code review.&lt;/strong&gt; The code looks correct. The logic is sound. The only way to catch them is to count queries.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SQLAlchemy events are underutilized.&lt;/strong&gt; Most developers know about &lt;code&gt;before_cursor_execute&lt;/code&gt; but don&amp;#39;t realize how useful it is for observability. An APM tool isn&amp;#39;t required to see what the ORM is doing.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tests are the best place for regression guards.&lt;/strong&gt; A failing test is harder to ignore than a slow dashboard. Query count assertions belong in the test suite.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Eager loading is not always the answer.&lt;/strong&gt; &lt;code&gt;joinedload()&lt;/code&gt; can produce massive result sets. &lt;code&gt;selectinload()&lt;/code&gt; adds a round trip. The choice depends on data shape.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Start simple.&lt;/strong&gt; OpenTelemetry, distributed tracing, and metrics platforms all have their place. But a 50-line context manager catches N+1 just fine.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Resources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.sqlalchemy.org/en/20/core/events.html&quot;&gt;SQLAlchemy Event System&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html&quot;&gt;Relationships Loading Techniques&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html#select-in-loading&quot;&gt;selectinload() Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html#joined-eager-loading&quot;&gt;joinedload() Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item></channel></rss>