Two managers beat one clever manager
The tempting move is to make the filtered manager the default:
class Post(models.Model):
objects = PublishedManager() # hides drafts everywhere
That reads nicely until you open the admin and your drafts are invisible โ admin uses the model's _default_manager. Keep the default unfiltered and add the filtered one explicitly:
objects = models.Manager() # admin, shell, data migrations
published = PublishedManager() # public views
Then public code is deliberately verbose: Post.published.all(). That's a feature. Anyone reading the view can see the access rule without opening the model.
select_related for FKs, prefetch_related for everything else
select_related is a SQL JOIN โ it only works across forward FK and one-to-one relations. prefetch_related runs a second query and stitches results in Python, which is the only way to handle many-to-many and reverse FKs.
The part that took me too long: prefetch_related("tags") loads all tags, and calling post.tags.all() in a template silently reuses that cache. If you need a filtered prefetch, say so:
from django.db.models import Prefetch
Post.published.prefetch_related(
Prefetch("tags", queryset=Tag.objects.order_by("name"))
)
A loop over 20 posts with one M2M is 1 query instead of 21. Multiply by a paginated list and it's the difference between a 40 ms and a 400 ms page.
Render once, sanitise always
Markdown in, HTML out โ but not on every request. Convert in save() and store the result:
def save(self, *args, **kwargs):
self.body_html = render_markdown(self.body_md)
super().save(*args, **kwargs)
Two rules that go with it:
- Sanitise. Markdown allows raw HTML, so
nh3.clean()on the way in. Never|safean unsanitised field. - Allow
classattributes. Pygments emits<div class="highlight"><pre><span class="k">, so a strict allowlist that dropsclassturns your code blocks into unstyled text. Allowclassondiv,pre,spanandcodeโ nothing else needs it.
One catch with nh3: if you pass link_rel, you cannot also allow rel on <a>. It raises rather than silently overriding you.
SQLite is fine, with the right pragmas
WAL plus IMMEDIATE transactions handles far more concurrency than SQLite gets credit for:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"OPTIONS": {
"init_command": "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;",
"transaction_mode": "IMMEDIATE",
},
"TIMEOUT": 20,
"CONN_MAX_AGE": 60,
}
}
The default DEFERRED mode upgrades a read transaction to a write lock lazily, which is exactly how you get database is locked under load. IMMEDIATE takes the write lock up front and waits out TIMEOUT instead of failing.
The docs make one point worth repeating: with IMMEDIATE, keep transactions short โ which is why ATOMIC_REQUESTS is discouraged here. A whole request holding the write lock is the one pattern that will hurt.
Where SQLite stops: many concurrent writers, long-running analytics, or anything needing replication. Reads scale fine; write contention doesn't.
Manifest storage and the missing-manifest trap
CompressedManifestStaticFilesStorage rewrites {% static %} to hashed filenames, so it needs staticfiles.json โ which only exists after collectstatic. In production that's fine if you run collectstatic during the image build. In tests it isn't:
ValueError: Missing staticfiles manifest entry for 'css/output.css'
Fix it in the test settings, not by running collectstatic before tests:
@override_settings(
STORAGES={
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
}
}
)
class ViewTests(TestCase):
...
Related: if you commit generated CSS, build it before the image build. collectstatic copies files, it doesn't compile them โ a stale output.css in the repo ships stale styles with no error anywhere.
Run the test runner from the project directory
python src/manage.py test from a repo root discovers zero tests when the apps live under src/. The runner discovers from the working directory, and manage.py only puts src/ on sys.path โ it doesn't change where discovery starts.
Either cd src first, or pass labels explicitly:
cd src && uv run python manage.py test
uv run python src/manage.py test blog
NO TESTS RAN exits 0, so a CI job would happily go green while testing nothing. That one is worth a check in whatever runs your tests.