Metadata-Version: 2.4
Name: django-queryguard-n1
Version: 0.1.0
Summary: Detect N+1, duplicate and slow database queries in Django API projects during development and in test suites.
Author-email: Aryan Gupta <aryan014kumar@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/N16H7H4WK-3R/Django-Queryguard
Project-URL: Changelog, https://github.com/N16H7H4WK-3R/Django-Queryguard/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/N16H7H4WK-3R/Django-Queryguard/issues
Keywords: django,n+1,orm,performance,queries,sql,testing
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Framework :: Django :: 6.1
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Database
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: Software Development :: Testing
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django>=5.2
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-django>=4.9; extra == "dev"
Requires-Dist: pytest-cov>=5; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Dynamic: license-file

# django-queryguard

[![PyPI](https://img.shields.io/pypi/v/django-queryguard-n1.svg)](https://pypi.org/project/django-queryguard-n1/) [![Python versions](https://img.shields.io/pypi/pyversions/django-queryguard-n1.svg)](https://pypi.org/project/django-queryguard-n1/) [![Django versions](https://img.shields.io/pypi/frameworkversions/django/django-queryguard-n1.svg)](https://pypi.org/project/django-queryguard-n1/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

API-first N+1 query detection for Django. queryguard records every SQL query your views run, spots N+1 queries, duplicate queries and slow queries, and logs a short plain-text report with the line of your code that caused it and a `select_related()` / `prefetch_related()` hint. It works with any Django view, including Django REST Framework and plain JSON views where HTML-based tools like django-debug-toolbar are not practical. Its test helpers let you fail your test suite in CI when an N+1 sneaks in.

## The problem

This serializer looks harmless:

```python
class OrderSerializer(serializers.ModelSerializer):
    factory_name = serializers.SerializerMethodField()

    def get_factory_name(self, order):
        return order.factory.name  # one extra query per order
```

For a list of 200 orders it runs 201 queries. With queryguard the console tells you:

```
[queryguard] GET /api/orders/ -> 201 queries, 1843.2 ms DB time
  N+1 (200x): SELECT "shop_factory"."id", "shop_factory"."name" FROM "shop_factory" WHERE "shop_factory"."id" = %s LIMIT 21
    at shop/serializers.py:14 in get_factory_name
    suggestion: forward relation to Factory -> use select_related() on: Order.factory
```

After the fix, `Order.objects.select_related("factory")`, the same endpoint runs 1 query and queryguard logs nothing above `DEBUG`.

## Installation

```bash
pip install django-queryguard-n1
```

Requires Python 3.10+ and Django 5.2, 6.0 or 6.1. Django is the only dependency.

## Middleware setup

Add the middleware near the top of `MIDDLEWARE`, so it also sees queries made by the middleware below it:

```python
MIDDLEWARE = [
    "queryguard.middleware.QueryGuardMiddleware",
    "django.middleware.security.SecurityMiddleware",
    # ...
]
```

Reports are written to the `queryguard` logger: issues at `WARNING`, and a one-line summary of clean requests at `DEBUG`. With no `LOGGING` config, warnings still print to stderr through Python's last-resort handler. To also see the summaries, or to route the output with your own `LOGGING` config, add:

```python
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "loggers": {
        "queryguard": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
    },
}
```

The middleware is only active when `ENABLED` is true, which by default follows `DEBUG`. When it is disabled, Django removes it from the middleware chain at startup, so it costs nothing.

## Settings

All settings are optional. Put the ones you want to change in a `QUERYGUARD` dict:

```python
QUERYGUARD = {
    "ENABLED": DEBUG,  # turn the middleware on or off
    "N_PLUS_ONE_THRESHOLD": 5,  # same query shape from the same call site >= N times
    "DUPLICATE_THRESHOLD": 2,  # identical SQL and params >= N times
    "SLOW_QUERY_MS": 100,  # a single query at least this slow
    "IGNORE_PATHS": ["/admin/", "/static/", "/media/"],  # path prefixes to skip
    "MAX_SQL_LENGTH": 200,  # SQL longer than this is cut in reports
}
```

Setting

Default

Meaning

`ENABLED`

`settings.DEBUG`

Turns the middleware on or off. The test helpers work either way.

`N_PLUS_ONE_THRESHOLD`

`5`

Minimum repetitions of one query shape from one call site to count as an N+1.

`DUPLICATE_THRESHOLD`

`2`

Minimum repetitions of identical SQL and params to count as a duplicate.

`SLOW_QUERY_MS`

`100`

A single query taking at least this many milliseconds is reported as slow.

`IGNORE_PATHS`

`["/admin/", "/static/", "/media/"]`

Requests whose path starts with one of these are not checked.

`MAX_SQL_LENGTH`

`200`

SQL in reports is cut to this many characters.

Unknown keys raise `ImproperlyConfigured`, so typos do not go unnoticed.

## Test helpers

`assert_no_n_plus_one()` fails the test with `NPlusOneError` when the block runs an N+1. It works without the middleware and whatever `ENABLED` is, which matters because test runners set `DEBUG = False`.

With pytest and pytest-django:

```python
from queryguard.testing import assert_no_n_plus_one


def test_order_list(client, orders):
    with assert_no_n_plus_one():
        client.get("/api/orders/")
```

With Django's `TestCase` (unittest):

```python
from django.test import TestCase

from queryguard.testing import assert_no_n_plus_one


class OrderListTests(TestCase):
    def test_order_list(self):
        with assert_no_n_plus_one(threshold=3):  # stricter than the setting for this block
            self.client.get("/api/orders/")
```

`NPlusOneError` subclasses `AssertionError`, so both runners report it as a normal test failure. The message contains each N+1 with its SQL, call site and suggestion.

For custom assertions, `capture_queries()` records every query in the block:

```python
from queryguard.testing import capture_queries


def test_order_detail_query_budget(client, order):
    with capture_queries() as queries:
        client.get(f"/api/orders/{order.pk}/")

    assert queries.count <= 3
    assert queries.total_ms < 50
    assert all(record.alias == "default" for record in queries.records)
```

## Sample output

From the [example project](example/), a DRF endpoint with nested serializers:

```
WARNING [queryguard] GET /api/orders/slow/ -> 301 queries, 2.9 ms DB time
  N+1 (200x): SELECT "shop_product"."id", "shop_product"."name", "shop_product"."factory_id" FROM "shop_product" WHERE "shop_product"."id" = %s LIMIT 21
    at shop/views.py:14 in get
    suggestion: forward relation to Product -> use select_related() on: OrderItem.product
  N+1 (50x): SELECT "shop_orderitem"."id", "shop_orderitem"."order_id", "shop_orderitem"."product_id", "shop_orderitem"."quantity" FROM "shop_orderitem" WHERE "shop_orderitem"."order_id" = %s
    at shop/views.py:14 in get
    suggestion: reverse relation to OrderItem -> use prefetch_related() on: Order.items
  N+1 (50x): SELECT "shop_factory"."id", "shop_factory"."name" FROM "shop_factory" WHERE "shop_factory"."id" = %s LIMIT 21
    at shop/serializers.py:23 in get_factory_name
    suggestion: forward relation to Factory -> use select_related() on: Product.factory, Order.factory
```

Duplicates and slow queries are reported as `Duplicate (3x): ...` and `Slow (152.3 ms): ...`. Issues are ordered N+1 first, then slow queries, then duplicates.

## How it works

**Recording.** For each request (or test block), queryguard installs a wrapper on every configured database with Django's `connection.execute_wrapper()`. The wrapper times the real query with `time.perf_counter()`, then stores the SQL (with `%s` placeholders), the params, the duration, the database alias and the call site. It always runs the real query, and it records the query even when it fails. The call site is the innermost stack frame in your project: frames from queryguard, Django, the standard library and installed packages are skipped, and frames under `BASE_DIR` are preferred and shown relative to it. The stack is walked lazily, without building a full traceback.

**Grouping rules.** Only `SELECT` statements are grouped, and transaction statements (`SAVEPOINT`, `BEGIN`, `COMMIT`, ...) are ignored. An **N+1** is the same SQL from the same call site run at least `N_PLUS_ONE_THRESHOLD` times with at least two different sets of params. A **duplicate** is identical SQL with identical params run at least `DUPLICATE_THRESHOLD` times. Queries already reported as part of an N+1 are not reported again as duplicates. A **slow** query is any single query taking at least `SLOW_QUERY_MS`.

**Suggestions.** queryguard reads the main table and the filtered column from the repeated SQL and maps the table to a model. If the filter is on the model's primary key, a foreign key is being followed once per row, so it suggests `select_related()` on the fields that point to that model. If the filter is on a foreign key column of the model, a reverse relation is being read once per row, so it suggests `prefetch_related()` on the reverse accessor, such as `Order.items`.

**Fail open.** queryguard never breaks your app. If recording or analysis fails, it logs one warning on the `queryguard` logger and the request carries on with its original response.

## Limitations

-   The middleware is sync only. Django adapts it when it runs with async views, at the cost of a sync/async switch per request.
-   Suggestions are best-effort. They come from a simple reading of the SQL and can be missing or imprecise, for example for many-to-many relations or subqueries.
-   The call site is the innermost frame in your code. When a loop runs inside a library, such as a nested DRF serializer, it points at your line that started the work, for example the line that builds `serializer.data`.
-   Queries made while a `StreamingHttpResponse` streams its content are not recorded.
-   The collector is per request and per thread. Queries run in other threads are not recorded.

## Example project

[`example/`](example/) is a runnable DRF project with a slow and a fast version of the same endpoint. Its README shows how to run it and what to look for.

## Contributing

Issues and pull requests are welcome. To set up a development environment:

```bash
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
ruff check .
ruff format --check .
pytest --cov=queryguard --cov-report=term-missing
```

Please add tests for any change. Coverage must stay at or above 90%.

## License

MIT. See [LICENSE](LICENSE).
