惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
人人都是产品经理
人人都是产品经理
Cisco Talos Blog
Cisco Talos Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
V
V2EX
博客园 - 三生石上(FineUI控件)
Martin Fowler
Martin Fowler
WordPress大学
WordPress大学
D
Docker
S
SegmentFault 最新的问题
博客园 - 聂微东
美团技术团队
Apple Machine Learning Research
Apple Machine Learning Research
月光博客
月光博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Last Week in AI
Last Week in AI
M
MIT News - Artificial intelligence
F
Fortinet All Blogs
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
The GitHub Blog
The GitHub Blog
GbyAI
GbyAI
L
LangChain Blog
Vercel News
Vercel News
博客园 - 叶小钗
MongoDB | Blog
MongoDB | Blog
Stack Overflow Blog
Stack Overflow Blog
H
Help Net Security
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
The Cloudflare Blog
Engineering at Meta
Engineering at Meta
T
Threat Research - Cisco Blogs
T
Threatpost
Scott Helme
Scott Helme
T
Tailwind CSS Blog
Latest news
Latest news
Stack Overflow Blog
Stack Overflow Blog
Blog — PlanetScale
Blog — PlanetScale
The Register - Security
The Register - Security
罗磊的独立博客
P
Proofpoint News Feed
腾讯CDC
S
Schneier on Security
雷峰网
雷峰网
A
About on SuperTechFans
T
Tenable Blog
F
Full Disclosure
Cyberwarzone
Cyberwarzone
博客园_首页
有赞技术团队
有赞技术团队
K
Kaspersky official blog

文章列表

Compulsive curiosity, or, how I built an infinite idea machine Gift details on the subscriber portal Portal link in the archive nav The physicists who convinced Fermilab to send Brazil's emails First, add no friction: How micropayments lost and subscriptions won Filter subscribers and automations by source Automations, rebuilt What email will look like in the future Filter subscribers by bounce date and reason Email could have been X.400 times better Three features are moving behind the paywall Firewall changes and improvements Put your name and voice into your company newsletter Simplified email address settings Subscription wall Inboxes were overwhelming before we'd even named them The US government tried really hard to screw up email Public postmortem: database connection exhaustion Ask a nerd: what is the best way to unsubscribe from newsletters? Bookshop.org embeds Email was into agents before they were cool Passwordless login Rename metadata keys in bulk A spring cleaning for our legal docs Ask a nerd: what happens when you click the spam button? Passkey support for two-factor authentication How Buttondown's API versioning works Safer defaults for the email creation API How to send email to space How we enabled Content Security Policy for everyone Recovery codes for two-factor authentication Filter sent emails by engagement rate How we migrated to TypeIDs without breaking clients How we check every link in your email Use newsletter metadata in your emails Should we bring back email exploders? Sort and filter by open and click rates Custom click tracking domains More newsletter settings in the API Revamped replies Custom email templates for everyone Simplified cancellation Ask a Nerd: Does email length affect deliverability? The changelog, reborn Swedish localization Forwarding an email is not always straightforward Public descriptions for tags OpenAPI spec for archives How Rodrigo brings a humanistic view to consumer technology How Brandon Lucas Green shares his music and supports artists Subscribers can come from anywhere. Even another newsletter platform's form. Your newsletter's archives are more valuable than your list Survey responses on the web Better tag self-management Smarter automation filters Granular API keys Ask A Nerd: How does newsletter cadence affect deliverability? New design settings pages Snippets Starred views More ways to customize your archives Inbox filtering Mastodon follower analytics Ask a Nerd: What are good open, click, and response rates for an email newsletter? How we migrated our database to PlanetScale Two new archive themes Ask a Nerd: Does attaching files to your newsletter hurt deliverability? Custom buttons now work in Markdown mode Seline and Tinylytics support Unban subscribers Announcement bars for your archives Public postmortem: archive downtime Bang paths, source routing, and how email trips were planned 2025 disposables.app Russian localization Ask a Nerd: Can you improve email deliverability with a personal domain? More locale options How we interview customers at Buttondown Bluesky analytics Minimum viable complexity Reply to conversations How Jeffery Hicks goes behind-the-scenes in his newsletter Changes to our stack in 2025 2026: Emails Randomize survey answer order TK reminders in the editor What the hell is a UTM? Why we insourced analytics Scroll sync in the editor 2026: Archives How Kelly Jensen uses Buttondown to discuss key library issues How Jamie Thingelstad uses Buttondown to explore tech topics Keeping feature creep at bay Improved filters Content Security Policy in archives Open source Sniperl.ink Auto-activating RSS reader subscriptions What the hell is ActivityPub? Gift subscriptions
Speeding up a Django view
Justin Duke · 2023-04-26 · via

Introduction

Buttondown is a Django app for sending emails, mostly beloved by technical users who like its REST API and Markdown support. Like most other email platforms, it also hosts archives of sent emails, and these archives process a millions of pageviews every month in traffic.

These archive pages are classical Django detail views: here is an example of one. You can see from the URL that the contract is pretty simple: given a username and a slug, return the email. Simple enough!

I noticed that Google Search Console was dinging Buttondown for a slow TTFB: Buttondown's archive pages were averaging around 1.2s, and the guidance is to be below one second. These views are pretty uncomplicated, so I dove in with Django Debug Toolbar (ol' faithful) to see what was happening:

example of SQL queries

Nine queries! Yikes! DDT is kind enough to provide all of the information you really need: what queries are being run, which ones are repeated, and in what order. Let's dive in, shall we?

Removing unnecessary joins

This is obvious when you say it out loud: the easiest queries to optimize are the ones that you can straight up remove because they're entirely unnecessary. In this case, I had two such queries:

  1. A query on monetization_stripeaccount
  2. A query on monetization_stripe_payment_link

Both of these pull data from Buttondown's mirrored store of Stripe data, and are entirely unnecessary on this page: we don't surface the payment link directly to users on this route, nor do we need information about the author's Stripe account. So we remove them from the serializer that was pulling them in:

class PublicNewsletterSerializer(serializers.ModelSerializer):
-     stripe_account = serializers.SerializerMethodField()
-     stripe_payment_link = serializers.SerializerMethodField()

...

-    def get_stripe_payment_link(self, obj: Newsletter) -> str:
-        if not obj.stripe_payment_link:
-            return ""
-        return obj.stripe_payment_link.data["url"]

-    def get_stripe_account(self, obj: Newsletter) -> str:
-        if not obj.stripe_account:
-            return ""
-        return obj.stripe_account.account_id

...

    class Meta:
        fields = (
            ...
-            "stripe_account",
-            "stripe_payment_link",
            ...
        )

Excellent! We're already down to seven queries, as confirmed by DDT:

example of SQL queries

Caching the object on the view

DDT helpfully informs us that some of these queries are duplicated: we grab the email, newsletter, and account twice. This is because of some janky logic I have in the permissions-checking for a given email: we show a slightly different version of the email depending on who's logged in (a premium subscriber may see different data compared to an unpaid one, for instance, or the author may be able to see posts that aren't published yet.)

Rather than fetching the email twice, then, let's just cache the email the first time it's fetched:

@method_decorator(xframe_options_exempt, name="dispatch")
class EmailView(TemplateView):
    def get_template_names(self) -> List[str]:
-        return determine_template_path(self.request, self.get_object())
+        return determine_template_path(self.request, self.email)

    def get_username(self) -> str:
        if hasattr(self.request, "newsletter_for_subdomain"):
@@ -89,7 +89,8 @@ def get_object(self) -> Optional[Email]:
        email_slug = self.kwargs.get("slug")
        username = self.get_username()

-        return find_email(email_id or email_slug or "", username)
+        self.email = find_email(email_id or email_slug or "", username)
+        return self.email

    def get_context_data(self, **kwargs: Any) -> Dict:
        context = super().get_context_data(**kwargs)

That brings us down to a mere four queries:

example of SQL queries

Denorming with select_related

If you look at those top three queries, it sounds fairly obvious what they're doing:

  1. Grab the email for a given slug and username.
  2. Grab the newsletter for that email.
  3. Grab the account for that newsletter.

These are all part of the same object, so we can use Django's select_related method to pull in those foreign keys as part of the initial query.

-            return emails.get(**kwargs)
+            return emails.select_related("newsletter__owning_account").get(**kwargs)

And with that one-liner, we're down to 385ms across only two queries:

example of SQL queries

Using defer

We're down to just two queries, but one of them is fairly large — 300ms for a simple join on an index seems awfully large, right?

Well, here's a fun bit of esoterica about Buttondown's implementation: Email objects are large. They contain a bunch of different cached versions of themselves, like:

  • How the email should look as a 'teaser'
  • How the email should look on email
  • How the email should look in Markdown

and so on, to make sending emails as quick as possible. But — especially for larger emails — these add up in size, and the simple act of serializing them from Postgres to Python is non-trivially slow.

This is compounded by the fact that Django's ORM defaults to the equivalent of a select * — if you fetch a row, you're getting all of the columns included in that row.

So, as a final touch, we use Django's defer command which allows us to selectively exclude certain fields that we know we don't need (in this case, it's various versions of the email that are rendered for other views):

- return emails.select_related("newsletter__owning_account").get(**kwargs)
+ return emails.select_related("newsletter__owning_account").defer(
+    "rendered_html_for_email",
+    "rendered_html_for_web",
+    "body",
+ ).get(**kwargs)

That saves us another 100ms, bringing us down to around 300ms!

example of SQL queries

Conclusion

We started with a view that took 1050ms across nine queries and we ended up with one that took 300ms across two queries. Pretty excellent for an hour's work and ~thirty lines of code, considering this view gets hit a few million times a month.

It bears repeating that none of the techniques described above are particularly novel. None required extensive tooling beyond django-debug-toolbar; exactly zero rocket surgery was performed in the course of this optimization.

And yet! This code stuck around, unoptimized, for quite some time. There is so much performance work out there in so many applications that looks like the above: twenty-dollar bills, sitting there crumpled on the ground, waiting to be picked up.

To wit, there are other things I could probably do to get this endpoint even faster:

  • That second query is checking to see if the newsletter being fetched has any "metadata fields" associated with it. Metadata fields are a bit of an edge case feature, and only around 15% of newsletters use them; I could add a field like Newsletter.has_metadata_fields and check that before making the query, to eliminate it for the 85% of newsletters who don't use it at all.
  • The primary query to pull out an email and a newsletter would be slightly faster (~30ms) if I queried the newsletter by ID instead of username. I could keep a cache mapping usernames to IDs to take advantage of that.

And all of those are good ideas for different days — but I'm a sucker for performance work that ends up with negative lines of code added.