Skip to main content

Django Explain Errors Middleware

This Django middleware captures unhandled errors and exceptions, sends them to a language model for explanation, and prints the explanation to stdout when debug mode is enabled. It works with the OpenAI API out of the box, with Anthropic's Claude models through Anthropic's OpenAI-compatible endpoint, and with any other OpenAI-compatible endpoint (Ollama, LM Studio, Azure, or a corporate gateway) by setting a base URL, so explanations can run entirely on a local model if you prefer not to send code off your machine.

It can optionally ground explanations in your own project source using a local vector index (RAG), so explanations reference the actual code that failed instead of staying generic (measured — see "Does RAG actually help?" below).

The middleware supports both synchronous (WSGI) and asynchronous (ASGI) views. It auto-detects the view chain at startup and routes requests through the matching path, so no extra configuration is required for either server type. Tracebacks are sanitized before leaving the process, and API calls are rate limited.

Scope

This package explains errors for a person, not for a coding agent to consume programmatically. The explanation is written for a human reader, in a terminal or an editor's integrated terminal, and the output format assumes that reader.

If a coding agent is doing the debugging, it does not need this. Agents read tracebacks directly, and tools that expose live runtime state (debugger-over-MCP servers, mcp-django) serve that case better. This package is not trying to compete there.

Local development only. It requires DEBUG = True and is inert otherwise.

Features

  • Captures Django errors and exceptions
  • Explains errors using OpenAI, Anthropic's Claude models, or any other OpenAI-compatible endpoint (Ollama, LM Studio, Azure, gateways) via OPENAI_BASE_URL
  • Optional codebase-aware explanations (RAG) backed by a local sqlite-vec index (see the RAG section below)
  • Explanations in your language via EXPLAIN_ERRORS_LANGUAGE, with exception names, identifiers, and code kept in English
  • Redacts secrets, tokens, and emails from tracebacks before sending
  • Rate limits API calls with a configurable sliding window
  • Works with both sync (WSGI) and async (ASGI) views
  • Manages the API key using environment variables

Installation

  1. Install django-explain-errors by running:
pip install django-explain-errors
  1. Add the middleware to your Django project:

    • Open your settings.py file and add the middleware to the MIDDLEWARE list:

      MIDDLEWARE = [
          ...
          'explain_errors.middleware.ExplainErrorsMiddleware',
      ]
      

      In the default preserve mode, process_exception returns None, so exception handling continues normally no matter where the middleware sits in the list — it no longer needs to be last to avoid pre-empting other packages' error handling. It still needs to sit close enough to the view that unhandled exceptions actually reach it, before any other middleware that might catch and handle them itself. If you set EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=False, keep it last: returning a response there still ends exception handling early and pre-empts anything above it in the stack (Debug Toolbar, Sentry, Rollbar — see Compatibility below).

  2. Set up environment variables:

    • Create a .env file in your project's root directory and add your OpenAI API key. Alternatively, you can set the API key in settings.py:

      OPENAI_API_KEY=your_openai_api_key_here
      

    The API key is not required if you set OPENAI_BASE_URL to a local server such as Ollama, which does not authenticate requests.

Usage

  1. Ensure DEBUG is set to True:

    Open your settings.py file and set:

    DEBUG = True
    
  2. Trigger an error in your Django application:

    The middleware captures the error, sends it to the configured model for explanation, and prints the explanation to stdout. By default (EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=True), it then lets exception handling continue normally, so Django (or whatever else is watching, such as runserver_plus or Sentry — see Compatibility below) renders exactly what it would without this middleware installed. Set EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE = False to instead get a JSON 500 response containing the error message and the explanation.

Async Support

The middleware exposes both sync_capable = True and async_capable = True. At initialization it inspects get_response to decide whether it is part of a sync or async chain:

  • Under WSGI (for example runserver with sync views), requests flow through the synchronous handler.
  • Under ASGI (for example with async views), requests are awaited through the async handler. The blocking OpenAI call is offloaded with asgiref.sync.sync_to_async so the event loop is not blocked.

No additional settings are needed. See Installation above for where to place the middleware in MIDDLEWARE.

Compatibility

How this middleware interacts with other error-handling and debugging tools, in the default preserve mode and with EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=False:

Package Preserve mode (default) EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=False
Django Debug Toolbar Works — Django renders its normal debug page, and Debug Toolbar injects into it. Broken — the JSON 500 response has no HTML to inject into.
Sentry, Rollbar Work — the exception propagates and Django re-raises it, so got_request_exception fires. Broken — returning a response ends exception handling before got_request_exception fires.
Django REST Framework Partial — only exceptions DRF does not already handle itself reach this middleware. Partial, same reason.
Silk and other profiling panels Timings are inflated by the OpenAI call, since process_exception blocks the request path. Same.
CORS, GZip, WhiteNoise No interaction. No interaction.
runserver_plus / Werkzeug debugger Works — returning None re-raises the original exception, and django-extensions replaces Django's debug-page renderer with one that re-raises instead, so Werkzeug's WSGI wrapper catches it and shows the interactive debugger. Broken — the JSON 500 response ends exception handling before it reaches runserver_plus's exception hook, so the Werkzeug debugger never appears.

Debug Toolbar, Sentry, and Werkzeug were verified empirically, on both Django 4.2 and Django 6.1, in both modes. DRF, Silk, and CORS/GZip/WhiteNoise are reasoned from the mechanism rather than tested.

One additional behavior worth knowing, also verified on both Django versions: when the explanation call itself fails (a bad key, a timeout, an unreachable endpoint), a Sentry-instrumented project captures a separate event for that failure. explain_errors catches the exception internally, so it never becomes an unhandled exception, but Sentry's httpx integration captures it anyway by instrumenting inside the HTTP client library rather than relying on got_request_exception. That event is unrelated to whatever error the developer is actually investigating.

Configuration

Setting / variable Required Description
OPENAI_API_KEY (env or settings) Yes, unless OPENAI_BASE_URL points at an endpoint that does not authenticate API key used to authenticate with the configured endpoint. Read first from the environment, then from settings.
DEBUG Yes The middleware is only active when DEBUG=True. When False, requests pass through untouched.
OPENAI_MODEL No Model used for explanations. Defaults to gpt-4o-mini.
OPENAI_MAX_TOKENS No Ceiling on tokens generated for the explanation, not a target — the system prompt itself asks for a concise answer. Defaults to 1000; scales up automatically when EXPLAIN_ERRORS_LANGUAGE is set (see below), unless you set this explicitly, which always overrides the scaling.
OPENAI_TIMEOUT No Request timeout in seconds for the OpenAI client. Defaults to 10.
OPENAI_MAX_TRACEBACK_CHARS No Total character budget for the traceback sent to the model. Application frames (your own code, as opposed to Django, the standard library, or installed packages) are always kept; library frames fill whatever budget remains, nearest the raise point first, with an ... N library frames omitted ... line where frames are dropped. If the application frames alone exceed the budget, falls back to keeping the last N characters of the raw traceback. Defaults to 3000.
EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE No When True (the default), the middleware prints the explanation to stdout and returns None, so exception handling continues normally and Django renders its standard debug page. Set to False to instead return a JSON 500 response, which ends exception handling early (see Compatibility above).
OPENAI_BASE_URL (env or settings) No Base URL for any OpenAI-compatible API (for example Ollama at http://localhost:11434/v1). When set, a missing API key is replaced with a placeholder since local servers do not require one.
EXPLAIN_ERRORS_MAX_CALLS No Together with EXPLAIN_ERRORS_WINDOW_SECONDS, caps API spend to at most this many explanations within a rolling window; once the cap is hit, further errors in that window are not sent for explanation until an earlier call ages out. Defaults to 5.
EXPLAIN_ERRORS_WINDOW_SECONDS No Length in seconds of the rolling window EXPLAIN_ERRORS_MAX_CALLS is measured against. Defaults to 60 (with the defaults, at most 5 explanations per 60-second window).
EXPLAIN_ERRORS_REDACT_PATTERNS No Extra regex pattern strings (each passed to re.compile) applied to the traceback, appended after the built-in secret/token/email patterns. An invalid pattern is skipped with a warning rather than raising. Defaults to [].
EXPLAIN_ERRORS_REDACT_DISABLE_DEFAULTS No When True, skips the built-in secret/token/email redaction patterns entirely and redacts only what EXPLAIN_ERRORS_REDACT_PATTERNS specifies. Turning this on removes the default protection against leaking secrets and PII in tracebacks. Defaults to False.
EXPLAIN_ERRORS_REDACT_REPLACEMENT No Replacement string substituted for anything matched by the redaction patterns. Defaults to "[REDACTED]".
EXPLAIN_ERRORS_LANGUAGE No Language the explanation prose is written in, as a plain name or code (for example "Spanish" or "es"). Defaults to None, meaning English. Exception names, identifiers, code, file paths, and tracebacks always stay in English regardless of this setting.

Using local models (Ollama)

Point OPENAI_BASE_URL at any OpenAI-compatible server to run explanations against a local model instead of the OpenAI API:

OPENAI_BASE_URL = "http://localhost:11434/v1"
OPENAI_MODEL = "llama3.1"
EXPLAIN_ERRORS_RAG_EMBED_MODEL = "nomic-embed-text"

With a local endpoint, no traceback or source code leaves your machine, which matters if you work somewhere that cannot send code to a third-party API.

If you use the RAG layer, rebuild the index after changing the embedding model or provider. Stored vectors are model-specific.

Using Anthropic (Claude) models

Anthropic's Claude models work today through Anthropic's OpenAI-compatible API, with no Anthropic-specific code required:

OPENAI_BASE_URL = "https://api.anthropic.com/v1/"
OPENAI_API_KEY = "your_anthropic_api_key_here"
OPENAI_MODEL = "..."  # see Anthropic's current model list below

OPENAI_MODEL is mandatory here: the default (gpt-4o-mini) doesn't exist on Anthropic's API and will 404. Use one of the model names from Anthropic's model overview; model names are versioned and retired over time, so check that page rather than relying on a name pinned here.

Anthropic documents this compatibility layer as intended primarily for testing and comparing model capabilities, not as a production integration path. That's an acceptable tradeoff for a development-only middleware, but worth knowing going in.

Reasoning models behind OPENAI_BASE_URL spend part of the token budget on internal reasoning before producing visible output. At a low OPENAI_MAX_TOKENS, the budget can be used up by reasoning alone, and the explanation comes back empty. Raise OPENAI_MAX_TOKENS if you see this.

A note on API keys and 401s

explain_errors reads OPENAI_API_KEY (see Configuration above); it does not read provider-specific variables such as ANTHROPIC_API_KEY. If no key is found and OPENAI_BASE_URL is set, the client substitutes a placeholder key rather than raising an error — a convenience for local servers like Ollama or LM Studio, which ignore the key entirely. Against a real remote endpoint such as Anthropic's, that placeholder is sent as-is and rejected, so a missing OPENAI_API_KEY shows up as an opaque 401 Unauthorized rather than a clear configuration error. If you see a 401 with OPENAI_BASE_URL pointed at a remote provider, check that OPENAI_API_KEY — not a provider-specific variable — is actually set.

Explanation language

By default, explanations are written in English. Set EXPLAIN_ERRORS_LANGUAGE to read them in another language instead:

EXPLAIN_ERRORS_LANGUAGE = "Spanish"  # or the code form, "es"

There is no fixed list of supported languages. EXPLAIN_ERRORS_LANGUAGE accepts any language the configured model can write, because the setting adds one clause to the system prompt (see LANGUAGE_CLAUSE_TEMPLATE in explain_errors/middleware.py), not a translation catalog with its own maintained language list. How well it works varies by model; see the known limitation below.

This is independent of Django's own LANGUAGE_CODE, which controls the language your site serves to its users, not the language you read explanations in. Regardless of EXPLAIN_ERRORS_LANGUAGE, exception type names, Django and Python identifiers, code, file paths, and tracebacks are always kept in English, so they stay greppable and matchable against documentation and search results. Only the explanatory prose is translated.

Known limitation: small local models behind OPENAI_BASE_URL (see "Using local models" above) tend to degrade sharply outside English. Output quality with EXPLAIN_ERRORS_LANGUAGE set does not transfer uniformly across providers — it is generally solid against OpenAI and Anthropic's APIs, but a small local model that writes fluent English explanations may produce broken or mixed-language output once asked to switch languages.

When a language is configured and OPENAI_MAX_TOKENS isn't set explicitly, the token ceiling defaults to 3,000 instead of 1,000, a flat 3x multiplier applied the same way regardless of how well or poorly a given language is known to tokenize, so explanations in languages that use more tokens per word than English aren't cut off mid-sentence. This is a ceiling, not a target: billing follows tokens actually generated, so the extra headroom costs nothing if unused. Setting OPENAI_MAX_TOKENS explicitly always overrides this scaling, at any value, including one lower than the unscaled 1,000 default. The multiplier itself is deliberately generous rather than precise: the underlying tokens-per-word figures are estimates, not direct measurements, and the default model (gpt-4o-mini) uses the o200k_base tokenizer, which handles non-Latin scripts considerably better than the cl100k_base-era ratios these estimates lean on.

Codebase-aware explanations (RAG)

By default, explanations are generated from the traceback alone. With the optional RAG (retrieval-augmented generation) layer enabled, the middleware also retrieves the most relevant chunks of your own project's source code from a local vector index and includes them in the prompt, so explanations can reference your actual functions and classes instead of guessing at them.

This feature is opt-in and adds no dependencies or behavior unless enabled.

Install the extra

pip install django-explain-errors[rag]

This pulls in sqlite-vec, a single-file, no-server vector store. The core package stays dependency-light if you don't need RAG.

Build the index

Add explain_errors to INSTALLED_APPS (needed for Django to discover the management command), then run:

python manage.py build_error_index

This walks your project, chunks Python files by top-level function/class (and other text files by fixed-size line windows), embeds each chunk with the OpenAI embeddings API, and writes them to a local index file. Re-run it whenever your source changes meaningfully. Indexing is not automatic. Rebuilding is idempotent: it builds into a temp file and atomically replaces the previous index.

Enable it

# settings.py

EXPLAIN_ERRORS_RAG_ENABLED = True

Settings

Setting Default Description
EXPLAIN_ERRORS_RAG_ENABLED False Master switch for the RAG layer.
EXPLAIN_ERRORS_RAG_INDEX_PATH <BASE_DIR>/.explain_errors_index.db Path to the local vector index file.
EXPLAIN_ERRORS_RAG_TOP_K 4 Number of chunks retrieved and injected into the prompt.
EXPLAIN_ERRORS_RAG_EMBED_MODEL "text-embedding-3-small" OpenAI embedding model used for indexing and retrieval.
EXPLAIN_ERRORS_RAG_INCLUDE None (defaults to BASE_DIR) List of directories to index.
EXPLAIN_ERRORS_RAG_EXCLUDE migrations, venvs, node_modules, static, media, .git Directory names to skip while indexing.
EXPLAIN_ERRORS_RAG_MAX_PROMPT_CHARS 6000 Combined character budget for the traceback + retrieved source sections of the prompt.

Every chunk of source code and every retrieval query is passed through the same sanitize_traceback() redaction used for tracebacks, which strips patterns that look like secrets, tokens, and emails before anything is sent to OpenAI or written to the index. It's a pattern-based filter, not a guarantee: it catches recognizable secret shapes, not arbitrary sensitive data that doesn't match one.

If RAG is enabled but the index is missing, sqlite-vec isn't installed, or retrieval fails for any reason, the middleware logs a warning and falls back to the traceback-only prompt. It never breaks error reporting.

RAG-grounded explanations tend to be longer than traceback-only ones. The default OPENAI_MAX_TOKENS already leaves generous headroom for this, but if you've lowered it, raise it back up when RAG is enabled so explanations are not truncated.

.gitignore

The index file is a local build artifact, not something to commit. Add it to your project's .gitignore:

.explain_errors_index.db

(Adjust the path if you set EXPLAIN_ERRORS_RAG_INDEX_PATH to something else.)

Before / after

A real result from the eval harness (missing_fk, one of the fixtures in evals/fixtures.py): a view creates a new Post without setting the required author foreign key. The traceback the model actually received was already truncated to OPENAI_MAX_TRACEBACK_CHARS, so it contains no application code at all, only Django/SQLite internals:

...(truncated)...
  File ".../django/db/models/base.py", line 904, in save
    self.save_base(
  ...
  File ".../django/db/backends/utils.py", line 105, in _execute
    return self.cursor.execute(sql, params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../django/db/backends/sqlite3/base.py", line 359, in execute
    return super().execute(query, params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
django.db.utils.IntegrityError: NOT NULL constraint failed: blog_post.author_id

Without RAG, traceback only:

The error "django.db.utils.IntegrityError: NOT NULL constraint failed: blog_post.author_id" occurs when you attempt to save a model instance but the author_id field does not receive a value, even though it requires one (is defined as NOT NULL).

This typically indicates that the author field in your Post model (or similar) is mandatory, but you did not provide an author when creating or updating the post.

To fix this:

  1. Ensure that you are assigning a valid user or author instance to the author field before saving the Post model. For example:

    post.author = some_user_instance
    
  2. If you intended for author_id to be optional, adjust the model definition to allow null values by modifying the field:

    author = models.ForeignKey(User, on_delete=models.CASCADE, null=True)
    
  3. If the migration is necessary (if you did change the model), run:

    python manage.py makemigrations
    python manage.py migrate
    

Make sure that every time you save a Post, it has a valid author assigned.

With RAG, grounded in the actual function:

The error django.db.utils.IntegrityError: NOT NULL constraint failed: blog_post.author_id occurs because when you're creating a new Post instance in clone_latest_post, you're not providing an author for the new draft. The author field in the Post model is a ForeignKey that cannot be null, and thus must be populated.

To fix this, modify the clone_latest_post function to include an author when creating the draft. You could retrieve the author from the latest post and use it like so:

def clone_latest_post(request):
    """Duplicate the most recent post as a new draft, ready for editing."""
    latest = Post.objects.order_by("-id").first()
    draft = Post.objects.create(
        title=f"Copy of {latest.title}",
        slug=f"copy-of-{latest.slug}",
        body=latest.body,
        author=latest.author,  # Add this line to specify the author
    )
    return HttpResponse(f"Created draft #{draft.id}")

This ensures the draft has a valid author, satisfying the NOT NULL constraint.

Does RAG actually help?

To check whether RAG-grounded explanations are actually better, not just longer, the package ships an eval harness (evals/): fifteen deliberately broken Django views, each explained twice (once from the traceback alone, once with RAG enabled) and judged by a separate model, blind to which explanation is which, against the error's known cause and correct fix location. Which side the judge sees as "A" is randomized per comparison so position can't bias the result.

Across three runs (45 judged comparisons, 2 judge failures, 43 scored), RAG-on won 35, RAG-off 5, and 3 tied. The gap isn't spread evenly across everything the judge checks. It's concentrated in whether the explanation names the right file and function, and whether it invents details along the way: on points_to_fix_location, RAG-on answered yes in 26 of the group-A comparisons against RAG-off's 13; on no_fabrication, 26 against 17. Without source access, gpt-4o-mini tends to invent a plausible-sounding function name or parameter rather than say it doesn't know; given the actual code via RAG, it mostly does not.

Three limitations are worth knowing before trusting this uncritically: RAG can anchor on the wrong retrieved chunk, as it did in one fixture (missing_post_key) where the fix got redirected to a retrieved template instead of the view; the judge is shown the failing function's own source, which is the same source RAG-on's retriever draws from, so part of RAG-on's no_fabrication advantage may be judge and generator overlapping on material RAG-off never sees rather than RAG-on being more careful; and claim statuses are spot-checked, not exhaustively audited -- a script that flagged 14 of 363 claims on one run, all correct on manual inspection, is a sample that turned up no false positive, not a proof that none exists. Full per-fixture results, the judge prompt, and how to reproduce this (about $1.37 for a --runs 3 pass, most of it judge cost) are in evals/README.md.

License

This project is licensed under the MIT License. See the LICENSE file for details.

Contributing

Contributions are welcome! Please open an issue or submit a pull request for any improvements or bug fixes.

Acknowledgements

Release files for django-explain-errors 0.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-explain-errors 0.7.0
File Size Uploaded
django_explain_errors-0.7.0.tar.gz 41.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-explain-errors 0.7.0
File Interpreter ABI Platform
django_explain_errors-0.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 66.3 kB

Release files / django_explain_errors-0.7.0.tar.gz

Download URL django_explain_errors-0.7.0.tar.gz
Size 41.1 kB
Tags Source
SHA-256 checksum
How to use checksums
31681aad889edc3fc41c3bf34837c7527bd8ee663a4c30aea05dd958357b3563
BLAKE2b-256 checksum
How to use checksums
e092e98770eecefa7a0d25b3188f8bd1a2233251ea1283140d674d525c67c617
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.

Transparency log

Release files / django_explain_errors-0.7.0-py3-none-any.whl

Download URL django_explain_errors-0.7.0-py3-none-any.whl
Size 25.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
274048ae74749f72eb77fe3b6e0ab6e085d75964df3954df75e09d049433ab5a
BLAKE2b-256 checksum
How to use checksums
1aa61c0943b0a3a9b9ca57e9c34537e36495de2a0fabe6180572471a91feafc2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.

Transparency log

Release history Release notifications | RSS feed

0.9.0

2 release files

0.8.0

2 release files

This release

0.7.0 This release

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page