Plain is headed towards 1.0! Subscribe for development updates →

plain.querystats

On-page database query stats in development and production.

On each page, the query stats will display how many database queries were performed and how long they took.

Watch on YouTube

Clicking the stats in the toolbar will show the full SQL query log with tracebacks and timings. This is even designed to work in production, making it much easier to discover and debug performance issues on production data!

Django query stats

It will also point out duplicate queries, which can typically be removed by using select_related, prefetch_related, or otherwise refactoring your code.

Installation

# settings.py
INSTALLED_PACKAGES = [
    # ...
    "plain.staff.querystats",
]

MIDDLEWARE = [
    "plain.middleware.security.SecurityMiddleware",
    "plain.sessions.middleware.SessionMiddleware",
    "plain.middleware.common.CommonMiddleware",
    "plain.csrf.middleware.CsrfViewMiddleware",
    "plain.auth.middleware.AuthenticationMiddleware",
    "plain.middleware.clickjacking.XFrameOptionsMiddleware",

    "plain.staff.querystats.QueryStatsMiddleware",
    # Put additional middleware below querystats
    # ...
]

We strongly recommend using the plain-toolbar along with this, but if you aren't, you can add the querystats to your frontend templates with this include:

{% include "querystats/button.html" %}

Note that you will likely want to surround this with an if DEBUG or is_staff check.

To view querystats you need to send a POST request to ?querystats=store (i.e. via a <form>), and the template include is the easiest way to do that.

Tailwind CSS

This package is styled with Tailwind CSS, and pairs well with plain-tailwind.

If you are using your own Tailwind implementation, you can modify the "content" in your Tailwind config to include any Plain packages:

// tailwind.config.js
module.exports = {
  content: [
    // ...
    ".venv/lib/python*/site-packages/plain*/**/*.{html,js}",
  ],
  // ...
}

If you aren't using Tailwind, and don't intend to, open an issue to discuss other options.

plain.toolbar

The staff toolbar is enabled for every user who is_staff.

Plain staff toolbar

Installation

Add plaintoolbar to your INSTALLED_PACKAGES, and the {% toolbar %} to your base template:

# settings.py
INSTALLED_PACKAGES += [
    "plaintoolbar",
]
<!-- base.template.html -->
{% load toolbar %}
<!doctype html>
<html lang="en">
  <head>
    ...
  </head>
  <body>
    {% toolbar %}
    ...
  </body>

More specific settings can be found below.

Tailwind CSS

This package is styled with Tailwind CSS, and pairs well with plain-tailwind.

If you are using your own Tailwind implementation, you can modify the "content" in your Tailwind config to include any Plain packages:

// tailwind.config.js
module.exports = {
  content: [
    // ...
    ".venv/lib/python*/site-packages/plain*/**/*.{html,js}",
  ],
  // ...
}

If you aren't using Tailwind, and don't intend to, open an issue to discuss other options.

plain.requestlog

The request log stores a local history of HTTP requests and responses during plain work (Django runserver).

The request history will make it easy to see redirects, 400 and 500 level errors, form submissions, API calls, webhooks, and more.

Watch on YouTube

Requests can be re-submitted by clicking the "replay" button.

Django request log

Installation

# settings.py
INSTALLED_PACKAGES += [
    "plainrequestlog",
]

MIDDLEWARE = MIDDLEWARE + [
    # ...
    "plainrequestlog.RequestLogMiddleware",
]

The default settings can be customized if needed:

# settings.py
DEV_REQUESTS_IGNORE_PATHS = [
    "/sw.js",
    "/favicon.ico",
    "/staff/jsi18n/",
]
DEV_REQUESTS_MAX = 50

Tailwind CSS

This package is styled with Tailwind CSS, and pairs well with plain-tailwind.

If you are using your own Tailwind implementation, you can modify the "content" in your Tailwind config to include any Plain packages:

// tailwind.config.js
module.exports = {
  content: [
    // ...
    ".venv/lib/python*/site-packages/plain*/**/*.{html,js}",
  ],
  // ...
}

If you aren't using Tailwind, and don't intend to, open an issue to discuss other options.

  1import time
  2import traceback
  3from collections import Counter
  4
  5import sqlparse
  6
  7from plain.utils.functional import cached_property
  8
  9IGNORE_STACK_FILES = [
 10    "threading",
 11    "socketserver",
 12    "wsgiref",
 13    "gunicorn",
 14    "whitenoise",
 15    "sentry_sdk",
 16    "querystats/core",
 17    "plain/template/base",
 18    "plain/utils/decorators",
 19    "plain/utils/deprecation",
 20    "plain/db",
 21    "plain/utils/functional",
 22    "plain/core/servers",
 23    "plain/core/handlers",
 24]
 25
 26
 27def pretty_print_sql(sql):
 28    return sqlparse.format(sql, reindent=True, keyword_case="upper")
 29
 30
 31def get_stack():
 32    return "".join(tidy_stack(traceback.format_stack()))
 33
 34
 35def tidy_stack(stack):
 36    lines = []
 37
 38    skip_next = False
 39
 40    for line in stack:
 41        if skip_next:
 42            skip_next = False
 43            continue
 44
 45        if line.startswith('  File "') and any(
 46            ignore in line for ignore in IGNORE_STACK_FILES
 47        ):
 48            skip_next = True
 49            continue
 50
 51        lines.append(line)
 52
 53    return lines
 54
 55
 56class QueryStats:
 57    def __init__(self, include_tracebacks):
 58        self.queries = []
 59        self.include_tracebacks = include_tracebacks
 60
 61    def __str__(self):
 62        s = f"{self.num_queries} queries in {self.total_time_display}"
 63        if self.duplicate_queries:
 64            s += f" ({self.num_duplicate_queries} duplicates)"
 65        return s
 66
 67    def __call__(self, execute, sql, params, many, context):
 68        current_query = {"sql": sql, "params": params, "many": many}
 69        start = time.monotonic()
 70
 71        result = execute(sql, params, many, context)
 72
 73        if self.include_tracebacks:
 74            current_query["tb"] = get_stack()
 75
 76        # if many, then X times is len(params)
 77
 78        current_query["result"] = result
 79
 80        current_query["duration"] = time.monotonic() - start
 81
 82        self.queries.append(current_query)
 83        return result
 84
 85    @cached_property
 86    def total_time(self):
 87        return sum(q["duration"] for q in self.queries)
 88
 89    @staticmethod
 90    def get_time_display(seconds):
 91        if seconds < 0.01:
 92            return f"{seconds * 1000:.0f} ms"
 93        return f"{seconds:.2f} seconds"
 94
 95    @cached_property
 96    def total_time_display(self):
 97        return self.get_time_display(self.total_time)
 98
 99    @cached_property
100    def num_queries(self):
101        return len(self.queries)
102
103    # @cached_property
104    # def models(self):
105    #     # parse table names from self.queries sql
106    #     table_names = [x for x in [q['sql'].split(' ')[2] for q in self.queries] if x]
107    #     models = connection.introspection.installed_models(table_names)
108    #     return models
109
110    @cached_property
111    def duplicate_queries(self):
112        sqls = [q["sql"] for q in self.queries]
113        duplicates = {k: v for k, v in Counter(sqls).items() if v > 1}
114        return duplicates
115
116    @cached_property
117    def num_duplicate_queries(self):
118        # Count the number of "excess" queries by getting how many there
119        # are minus the initial one (and potentially only one required)
120        return sum(self.duplicate_queries.values()) - len(self.duplicate_queries)
121
122    def as_summary_dict(self):
123        return {
124            "summary": str(self),
125            "total_time": self.total_time,
126            "num_queries": self.num_queries,
127            "num_duplicate_queries": self.num_duplicate_queries,
128        }
129
130    def as_context_dict(self):
131        # If we don't create a dict, the instance of this class
132        # is lost before we can use it in the template
133        for query in self.queries:
134            # Add some useful display info
135            query["duration_display"] = self.get_time_display(query["duration"])
136            query["sql_display"] = pretty_print_sql(query["sql"])
137            duplicates = self.duplicate_queries.get(query["sql"], 0)
138            if duplicates:
139                query["duplicate_count"] = duplicates
140
141        summary = self.as_summary_dict()
142
143        return {
144            **summary,
145            "total_time_display": self.total_time_display,
146            "queries": self.queries,
147        }
148
149    def as_server_timing(self):
150        duration = self.total_time * 1000  # put in ms
151        duration = round(duration, 2)
152        description = str(self)
153        return f'querystats;dur={duration};desc="{description}"'