Recipe: budgets for hot endpoints#

N+1 detection finds a pattern. A budget protects a number: this endpoint sends at most N statements. Put budgets on the few endpoints where a regression costs most, such as the page every user loads, and let the detection cover the rest. See Budgets and timing for the full reference.

The endpoint below (/orders/{order_id} in examples/fastapi-dependency-override) has no N+1, so detection stays quiet, but it sends four statements, two of them counts:

@app.get("/orders/{order_id}")
def order_detail(order_id: int, session: SessionDep) -> dict[str, object]:
    order = session.get_one(Order, order_id)
    customer = session.get_one(Customer, order.customer_id)
    siblings = session.scalar(
        select(func.count()).select_from(Order).where(Order.customer_id == customer.id)
    )
    audit = session.scalar(select(func.count()).select_from(Order))
    return {"customer": customer.name, "orders": siblings, "total": audit}

A budget of two statements, set with the marker on the test that calls it:

import pytest


@pytest.mark.max_queries(2)
def test_order_detail_budget(client):
    response = client.get("/orders/1")
    assert response.status_code == 200
F                                                                        [100%]
=================================== FAILURES ===================================
___________________________ test_order_detail_budget ___________________________
queryguard: expected at most 2 queries, got 4

  1. SELECT orders.id, orders.customer_id FROM orders WHERE orders.id = ?  (app/main.py:63 in order_detail)
  2. SELECT customer.id, customer.name FROM customer WHERE customer.id = ?  (app/main.py:64 in order_detail)
  3. SELECT count(*) AS count_1 FROM orders WHERE orders.customer_id = ?  (app/main.py:65 in order_detail)
  4. SELECT count(*) AS count_1 FROM orders  (app/main.py:68 in order_detail)

  database time: 77 us
  slowest:
        24 us  SELECT count(*) AS count_1 FROM orders WHERE orders.customer_id = ?  (app/main.py:65 in order_detail)
        24 us  SELECT orders.id, orders.customer_id FROM orders WHERE orders.id = ?  (app/main.py:63 in order_detail)
        15 us  SELECT customer.id, customer.name FROM customer WHERE customer.id = ?  (app/main.py:64 in order_detail)
=========================== short test summary info ============================
FAILED tests/test_budget.py::test_order_detail_budget - Failed: queryguard: e...
1 failed in 0.06s

The report lists every statement with the line that sent it, so the cost is visible: the two counts are what to remove or merge. The timings vary from run to run; the statements and their lines do not.

Choosing the number#

Time budgets#

@pytest.mark.max_db_time(seconds) limits the time spent in the database and driver for the test. Timing on a shared CI runner is noisy, and an in-memory SQLite test measures microseconds. Use a time budget only against a real server, with a limit several times what you see locally, and prefer a statement budget, which does not flake.

Edit this page on GitHub