Django Markdown: Render Markdown in Templates Safely
September 11, 2026 · 10 min read
Django Markdown: Render Markdown in Templates (Safely)
The cleanest Django Markdown setup is a custom template filter: Python-Markdown converts the text to HTML, nh3 strips anything dangerous, and mark_safe hands the result to the template. This tutorial builds that filter step by step, adds tables, fenced code, and syntax highlighting, then compares the maintained Django packages so you know when to install one instead.
How Does Markdown Rendering Work in Django?
Django templates escape output by default. If a model field holds **bold** and you write it into a template, the visitor sees the literal asterisks, and any HTML you generate from it is escaped into <strong>. Rendering Markdown therefore takes three steps: convert the Markdown string to HTML, decide that the HTML is trustworthy, and tell Django not to escape it.
The conversion is the easy part. Python-Markdown (pip install markdown, version 3.10.3 as of July 2026) turns a string into HTML with one call:
import markdown
markdown.markdown("Hello **Django**")
# '<p>Hello <strong>Django</strong></p>'
The trust decision is where most tutorials go wrong. The top-ranking Django Markdown tutorial pipes the output through the |safe filter with no sanitising step. That's fine when only you write the content. The moment a comment form, a user profile, or a wiki page feeds the same filter, the rules change. A visitor can type <script> or an onerror attribute into the Markdown, and it lands in every reader's browser unchanged.
Markdown allows raw HTML by design, so the converter passes those tags through on purpose.
Install Python-Markdown and nh3
Two packages, one line:
pip install markdown nh3
nh3 is a Python binding to the Ammonia sanitiser written in Rust. It replaces bleach, which most older Django Markdown guides recommend. Bleach's own README now carries a notice, dated 5 June 2026, that the project is no longer maintained and will receive no further releases, including for security issues. nh3 ships an allow-list of tags and attributes, strips everything else, and adds rel="noopener noreferrer" to links by default. Its API is a single clean() function plus a reusable Cleaner class.
Step 1: Write the Markdown Template Filter
Django looks for custom filters in a templatetags package inside an installed app. Create blog/templatetags/__init__.py (empty) and blog/templatetags/markdown_extras.py:
import markdown
import nh3
from django import template
from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe
register = template.Library()
# Let Pygments and the toc extension keep their classes and ids.
ALLOWED_ATTRIBUTES = {
**nh3.ALLOWED_ATTRIBUTES,
"span": {"class"},
"div": {"class"},
"pre": {"class"},
"code": {"class"},
**{f"h{n}": {"id"} for n in range(1, 7)},
}
EXTENSIONS = ["extra", "codehilite", "toc"]
@register.filter(name="markdown")
@stringfilter
def markdown_filter(value):
html = markdown.markdown(value, extensions=EXTENSIONS)
return mark_safe(nh3.clean(html, attributes=ALLOWED_ATTRIBUTES))
Read it bottom up. @stringfilter coerces the input to a string, so a None field doesn't crash the page. markdown.markdown() renders with the extra bundle (tables, fenced code, footnotes, definition lists, and more), codehilite for coloured code, and toc for heading IDs. nh3.clean() removes any tag or attribute not on the allow list. Only then does mark_safe tell Django the string can be output as-is.
Load it in a template with {% load markdown_extras %} and apply it to the field:
{% load markdown_extras %}
<article>
<h1>{{ post.title }}</h1>
{{ post.body|markdown }}
</article>
Restart the dev server after adding a new templatetags module; Django only discovers them at startup. That's the whole markdown django pipeline: one filter, one allow list, no |safe in the template.
Step 2: Tables, Code Blocks, and Syntax Highlighting
Plain Python-Markdown implements the original Markdown spec, which has no tables or fenced code. Both come from extensions, and the extra bundle above already includes tables and fenced_code. Test it with content your authors will actually write:
| Package | Latest | Django versions |
|---|---|---|
| django-markdownx | 4.0.11 | 4.2 to 6.0 |
| martor | 1.8.2 | 4.2 to 5.1 |
```python
def hello():
return "world"
```
The codehilite extension wraps each fenced block in <div class="codehilite"> and marks up tokens with Pygments span classes, but it doesn't ship any colours. Install Pygments (pip install pygments) and generate a stylesheet once:
pygmentize -S default -f html -a .codehilite > static/css/codehilite.css
Link that file from your base template. Swap default for monokai or any theme listed by pygmentize -L style. Our Markdown code block guide covers the fence syntax itself, and the table guide covers alignment rows, both of which the extensions honour.
Try the Django Markdown Test Content First
Before wiring content into a model, preview what your authors will write. The editor below renders the same Markdown that Python-Markdown with extra produces from a table and a fenced block, so you can check the HTML structure your filter will emit. The Markdown to HTML converter shows the raw HTML if you want to compare it against your filter's output line by line.
Should You Render at Save Time or at Request Time?
The filter above renders on every request. For a blog with a few hundred posts and a page cache, that's fine. In our testing the conversion is a small cost next to the database queries on the same page. For a forum page that renders 50 comments per view, or content that's read thousands of times per edit, render once and store the HTML.
The usual pattern is a second field populated in save():
from django.db import models
from blog.templatetags.markdown_extras import markdown_filter
class Post(models.Model):
body = models.TextField()
body_html = models.TextField(editable=False, blank=True)
def save(self, *args, **kwargs):
self.body_html = markdown_filter(self.body)
super().save(*args, **kwargs)
Then output {{ post.body_html|safe }} in the template. This is the one place |safe is acceptable, because the stored HTML has already been through nh3. The trade-off is staleness. If you change the extension list or the allow list, existing rows keep their old HTML until you re-save them, so keep a management command that re-renders everything.
We prefer request-time rendering plus Django's template fragment caching for most sites, and save-time rendering only when profiling shows Markdown in the top of the flame graph.
Which Django Markdown Package Should You Use?
Search results for this topic are cluttered by a package named exactly django-markdown, and it's dead: django-markdown 0.8.4 was released in December 2014 and nothing since. The maintained options solve three different problems, so pick by the problem rather than the name. Versions and dates are from PyPI on the day of writing.
| Package | Solves | Latest release | Django support (classifiers) |
|---|---|---|---|
| Python-Markdown + nh3 (this tutorial) | Rendering, sanitised | 3.10.3 / 0.3.7, July and August 2026 | Any; it's not Django-specific |
| django-markdownify | A ready-made markdownify filter with bleach-style settings | 0.9.7, May 2026 | Not declared |
| django-markdownfield | A model field that stores Markdown and rendered HTML together | 0.21.1, August 2026 | 5.2, 6.0, 6.1 |
| django-markdownx | Editor widget with live preview and image upload, for admin and forms | 4.0.11, May 2026 | 4.2 to 6.0 |
| martor | Editor widget with toolbar, preview, and emoji, modelled on GitHub's | 1.8.2, June 2026 | 4.2 to 5.1 |
| django-pagedown | Stack Overflow's PageDown editor as a widget | 2.2.1, July 2021 | 2.1 to 3.2 |
| django-markdown | Nothing you should rely on | 0.8.4, December 2014 | Pre-2.0 |
If you only need to display Markdown, the filter in this tutorial is smaller than any package and you control the allow list. If editors need a preview while they type, django-markdownx or martor add the widget; both still expect you to render the stored Markdown yourself, so the filter stays. django-markdownfield bundles the storage pattern from the previous section into a field.
One limitation of every option in the table: none of them render client side. A live preview in your own forms means either the package's widget or a bit of JavaScript calling a view that runs the same filter.
Common Django Markdown Mistakes
Using |safe on unsanitised output. The filter returns HTML, the template shows asterisks, so the developer adds |safe and moves on. Anything a user typed is now live HTML. Sanitise inside the filter and never rely on the template.
Forgetting the extra extension. Authors report that tables render as pipes and code fences as paragraphs. Python-Markdown's default is the original 2004 syntax; add extensions=["extra"] (or tables and fenced_code individually) and both work.
Stripping the classes you need. nh3's default allow list drops class and id, so after adding it, syntax highlighting vanishes and heading anchors stop working. Extend ALLOWED_ATTRIBUTES for span, div, pre, code, and the heading tags, as the filter above does, rather than turning sanitising off.
Django Markdown FAQ
A safe Django Markdown setup is about 20 lines: Python-Markdown with the extra, codehilite, and toc extensions, nh3 with a small allow list, and mark_safe at the end of the filter. Add a package only when you need an editor widget or a combined model field. To see what your authors' Markdown becomes before it reaches the model, draft it in the editor and compare against the Markdown cheat sheet for the syntax the extra bundle supports.