<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: maciej zielinski</title>
    <description>The latest articles on DEV Community by maciej zielinski (@maciejziel).</description>
    <link>https://dev.to/maciejziel</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4176146%2F96531993-43f0-4d21-8c25-83562eb7283d.png</url>
      <title>DEV Community: maciej zielinski</title>
      <link>https://dev.to/maciejziel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/maciejziel"/>
    <language>en</language>
    <item>
      <title>Why your tests don't catch N+1 queries (and how to make them)</title>
      <dc:creator>maciej zielinski</dc:creator>
      <pubDate>Sun, 11 Oct 2026 15:27:57 +0000</pubDate>
      <link>https://dev.to/maciejziel/why-your-tests-dont-catch-n1-queries-and-how-to-make-them-gdg</link>
      <guid>https://dev.to/maciejziel/why-your-tests-dont-catch-n1-queries-and-how-to-make-them-gdg</guid>
      <description>&lt;p&gt;N+1 queries are easy to merge with a green CI run. The tests covered the endpoint and checked the response. They did not check how many queries it took to produce that response, so nothing failed. This post covers why the usual test setup hides N+1 queries, how to make a test fail on them, and a related problem the same setup hides: queries that have no index to use.&lt;/p&gt;

&lt;p&gt;The examples use SQLAlchemy 2 and pytest. The tool at the end is pytest-queryguard, a plugin I wrote; the techniques before it work without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;Two models, a blog post and its author:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;post&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;author_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author.id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a function that builds a feed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;select(Post)&lt;/code&gt; loads the posts. &lt;code&gt;p.author&lt;/code&gt; is a lazy relationship, so the first time the comprehension touches it for a given author, SQLAlchemy sends &lt;code&gt;SELECT ... FROM author WHERE author.id = ?&lt;/code&gt;. One query for the list, then one more for each author it has not seen yet. That is the N+1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the test passes
&lt;/h2&gt;

&lt;p&gt;The fixture creates 3 authors and 6 posts, and the test checks the length:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It passes. It would pass in production too: the function returns the right data. The difference is cost. With 3 authors the function sends 4 queries and finishes in microseconds against SQLite. With 500 posts by 400 authors on a real database over a network, it sends 401 round trips per request.&lt;/p&gt;

&lt;p&gt;Three things in a normal test setup hide this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The data is tiny.&lt;/strong&gt; A test database holds a handful of rows on purpose, so the extra queries cost nothing measurable. No test times out and nobody notices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assertions check results, not work.&lt;/strong&gt; &lt;code&gt;len(...) == 6&lt;/code&gt; is true whether it took 1 query or 400.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The identity map can hide the queries completely.&lt;/strong&gt; This one surprised me while preparing the example. If the test reuses the session that inserted the rows, the authors are already in that session's identity map, so &lt;code&gt;p.author&lt;/code&gt; is answered from memory and no lazy-load SQL is sent at all. I ran the same lazy &lt;code&gt;feed&lt;/code&gt; code in a test that inserts and reads in one session, with the plugin installed and &lt;code&gt;--queryguard-report&lt;/code&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   variant_identity_map/test_identity_map.py .                              [100%]

   ============================== queryguard report ===============================
      17  variant_identity_map/test_identity_map.py::test_feed_same_session
   ============================== 1 passed in 0.02s ===============================
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 17 statements are the table setup, the inserts and a single &lt;code&gt;SELECT&lt;/code&gt; of the posts. The bug is in the code, but the database never sees it, so nothing watching the SQL can see it either. The fix is in the fixture: insert with one session, close it, and give the code under test a fresh one. The rest of this post uses a fixture that does that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the test fail
&lt;/h2&gt;

&lt;p&gt;The usual answer, which you will find on Stack Overflow going back to 2013, is to listen to SQLAlchemy's &lt;code&gt;before_cursor_execute&lt;/code&gt; event and count statements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.engine&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Engine&lt;/span&gt;

&lt;span class="n"&gt;statements&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;before_cursor_execute&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;statements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then assert the count. That works, and if a counter is all you need, those few lines are enough. When I tried this in my own projects, I kept wanting three more things: a check on every test without writing a number for each one; a message that says which relationship and which line caused the repeats; and something that understands async sessions and FastAPI's &lt;code&gt;TestClient&lt;/code&gt;, which runs your app in another thread.&lt;/p&gt;

&lt;p&gt;pytest-queryguard does that. Install it and run the same, unchanged test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;pytest-queryguard
pytest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tests/test_feed.py F                                                     [100%]

=================================== FAILURES ===================================
__________________________________ test_feed ___________________________________
queryguard: N+1 detected

  Post.author was lazy-loaded 3 times
  first at app/feed.py:9 in feed
  sql: SELECT author.id, author.name FROM author WHERE author.id = ?
  fix: load it with the parent query, e.g.
       select(Post).options(selectinload(Post.author))
=========================== short test summary info ============================
FAILED tests/test_feed.py::test_feed - Failed: queryguard: N+1 detected
============================== 1 failed in 0.02s ===============================
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no configuration and no marker. The plugin listens to two SQLAlchemy events for the duration of each test function (fixture setup is not counted): &lt;code&gt;do_orm_execute&lt;/code&gt; on &lt;code&gt;Session&lt;/code&gt;, which reports lazy loads of a relationship, and &lt;code&gt;before_cursor_execute&lt;/code&gt; on &lt;code&gt;Engine&lt;/code&gt;, which sees every statement. A test fails when one relationship is lazy-loaded for 2 or more parent objects, or when one &lt;code&gt;SELECT&lt;/code&gt;, with its parameters removed, runs 5 or more times. Both thresholds are settings.&lt;/p&gt;

&lt;p&gt;The relationship rule is the useful one here. It fires with 3 authors, on test data, because it does not need the repetition to be large; it only needs the pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The message already suggests it. Load the authors together with the posts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;selectinload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;selectinload&lt;/code&gt; sends a second query, &lt;code&gt;SELECT ... FROM author WHERE author.id IN (...)&lt;/code&gt;, for all the authors at once. Two queries, whether there are 6 posts or 6000. &lt;code&gt;joinedload&lt;/code&gt; is the other common choice and works well for many-to-one relationships like this one; &lt;code&gt;selectinload&lt;/code&gt; is usually the safer default for collections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Query budgets
&lt;/h2&gt;

&lt;p&gt;The N+1 rules catch loops. They do not catch a page that slowly grows from 2 queries to 7 because someone added a count, a permission check and another relationship. For pages where you care about the number, write it down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest.mark.max_queries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_post_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;post_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author_posts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Against this version of &lt;code&gt;post_page&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;post_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;object&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;post_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;
    &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;select_from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author_posts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pytest --queryguard-report&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tests/test_feed.py .F                                                    [100%]

=================================== FAILURES ===================================
________________________________ test_post_page ________________________________
queryguard: expected at most 2 queries, got 3

  1. SELECT post.id, post.title, post.author_id FROM post WHERE post.id = ?  (app/feed.py:14 in post_page)
  2. SELECT author.id, author.name FROM author WHERE author.id = ?  (app/feed.py:15 in post_page)
  3. SELECT count(*) AS count_1 FROM post WHERE post.author_id = ?  (app/feed.py:16 in post_page)
============================== queryguard report ===============================
    3  tests/test_feed.py::test_post_page
    2  tests/test_feed.py::test_feed
=========================== short test summary info ============================
FAILED tests/test_feed.py::test_post_page - Failed: queryguard: expected at m...
========================= 1 failed, 1 passed in 0.03s ==========================
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failure lists every statement with the line that sent it, and the report at the bottom ranks tests by query count, which is a quick way to find the heaviest tests in a suite you did not write. Loading the author with the post, &lt;code&gt;session.get(Post, post_id, options=[joinedload(Post.author)])&lt;/code&gt;, brings it to 2 and the test passes. There is also a &lt;code&gt;max_queries()&lt;/code&gt; context manager for budgeting one block of code instead of a whole test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other thing tiny databases hide: missing indexes
&lt;/h2&gt;

&lt;p&gt;That count query has a second problem that no amount of query counting will find. There is no index on &lt;code&gt;post.author_id&lt;/code&gt;. With 6 rows, PostgreSQL reads the whole table and nobody can tell; with 10 million rows, the same query reads 10 million rows.&lt;/p&gt;

&lt;p&gt;You cannot catch this by looking at &lt;code&gt;EXPLAIN&lt;/code&gt; in a test, because on a tiny table a sequential scan is the planner's correct choice even when an index exists. The trick is to ask a different question: plan the query again with &lt;code&gt;enable_seqscan&lt;/code&gt; switched off. If PostgreSQL still reads the whole table to apply the filter, then no index can serve that filter, however small the table.&lt;/p&gt;

&lt;p&gt;pytest-queryguard 0.2 does this when you ask for it (PostgreSQL 16 or newer, a sync driver; this run used pg8000 against PostgreSQL 17 in Docker):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pytest &lt;span class="nt"&gt;--queryguard-index-check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tests/test_feed.py .F                                                    [100%]

=================================== FAILURES ===================================
________________________________ test_post_page ________________________________
queryguard: missing index

  post is read in full: Seq Scan with filter (post.author_id = $1)
  first at app/feed.py:16 in post_page
  sql: SELECT count(*) AS count_1 FROM post WHERE post.author_id = %s::INTEGER
  fix: CREATE INDEX ON post (author_id)

  a sequential scan is fine on a small lookup table:
  list it in queryguard_index_ignore_tables
=========================== short test summary info ============================
FAILED tests/test_feed.py::test_post_page - Failed: queryguard: missing index
========================= 1 failed, 1 passed in 0.15s ==========================
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that &lt;code&gt;test_feed&lt;/code&gt; passed: its &lt;code&gt;IN&lt;/code&gt; query filters on the author's primary key. After adding &lt;code&gt;index=True&lt;/code&gt; to &lt;code&gt;Post.author_id&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tests/test_feed.py ..                                                    [100%]

============================== 2 passed in 0.17s ===============================
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each distinct &lt;code&gt;SELECT&lt;/code&gt; is explained once per test, inside a savepoint on the connection that ran it, so it sees the test's uncommitted rows and leaves the transaction as it was. On SQLite the check does not fail anything; it prints one line at the end of the session saying the statements were not checked.&lt;/p&gt;

&lt;p&gt;PostgreSQL 16 matters because of &lt;code&gt;EXPLAIN (GENERIC_PLAN)&lt;/code&gt;, which plans a statement with &lt;code&gt;$1&lt;/code&gt; placeholders without values. Before 16 there is no clean way to do this from the SQL your ORM actually sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;Worth knowing before you adopt any of this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SQLAlchemy only.&lt;/strong&gt; Raw DB-API calls and other ORMs are invisible to the plugin. For Django, use &lt;code&gt;assertNumQueries&lt;/code&gt; or a Django-specific tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It only sees SQL that is sent.&lt;/strong&gt; The identity-map case above passes. So does code that is never executed by a test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writes are never reported as N+1.&lt;/strong&gt; At cursor level a unit-of-work flush looks the same as an application write loop, so &lt;code&gt;INSERT&lt;/code&gt;/&lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt; are left to budgets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No unused-eager-load detection.&lt;/strong&gt; nplusone does this; pytest-queryguard does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The index check needs PostgreSQL 16+ and a sync engine.&lt;/strong&gt; Async drivers prepare every statement, and PostgreSQL rejects &lt;code&gt;EXPLAIN (GENERIC_PLAN)&lt;/code&gt; sent that way, so async statements are skipped with a notice. It judges plans, not data: it tells you no index can serve a filter, not that an existing index is good. Joins without a &lt;code&gt;WHERE&lt;/code&gt; on the unindexed side are not reported, because that plan depends on statistics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs time.&lt;/strong&gt; In the project's own measurement, the index check added about 2–5 ms per distinct statement against a local Docker PostgreSQL. N+1 detection alone is cheap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small lookup tables&lt;/strong&gt; are fine to scan in production too. List them in &lt;code&gt;queryguard_index_ignore_tables&lt;/code&gt; or mark a test with &lt;code&gt;@pytest.mark.allow_missing_index&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What about nplusone?
&lt;/h2&gt;

&lt;p&gt;nplusone did this job for years and still gets a lot of downloads. Its last release was 1.0.0 in 2018. It patches &lt;code&gt;Query&lt;/code&gt; internals that SQLAlchemy 1.4 rewrote, so on 1.4 and 2.0 it does not detect lazy loads and does not tell you it stopped working (see nplusone issue #37). If you have it in a SQLAlchemy 2 project, check that it still fails on a known N+1 before relying on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use a fresh session for the code under test, or the identity map hides lazy loads.&lt;/li&gt;
&lt;li&gt;Make tests fail on the N+1 pattern, not on slowness; test data is too small to be slow.&lt;/li&gt;
&lt;li&gt;Put a query budget on the endpoints you care about.&lt;/li&gt;
&lt;li&gt;On PostgreSQL, plan your test queries with sequential scans disabled to find missing indexes while the tables are still small.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;pytest-queryguard is MIT-licensed and on PyPI. Source, full documentation and the issue tracker: &lt;a href="https://github.com/MaciejZiel/pytest-queryguard" rel="noopener noreferrer"&gt;https://github.com/MaciejZiel/pytest-queryguard&lt;/a&gt;. Bug reports and "it missed this N+1" examples are the most useful thing you can send.&lt;/p&gt;

</description>
      <category>database</category>
      <category>performance</category>
      <category>python</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
