blog

Migrating Buttondown to mypy

The journey of 37,000 lines of static types begins with a single annotation.

What is Buttondown?

Buttondown is an application for writing, sending, and growing newsletters — though that part isn't quite applicable to this essay! The more important part: Buttondown has a Python & Django backend of around 38,000 lines and it's been around for around four years, so it has a good-but-not-excessive amount of cruft and abstractions that I wish I could take back.

What is mypy?

The short answer: python : mypy :: JavaScript : TypeScript. [^1] The longer answer, as provided by the official mypy docs:

Mypy is an optional static type checker for Python that aims to combine the benefits of dynamic (or "duck") typing and static typing. Mypy combines the expressive power and convenience of Python with a powerful type system and compile-time type checking. Mypy type checks standard Python programs; run them using any Python VM with basically no runtime overhead.

Why did I do this?

I've actually been using something that's, like, mypy-adjacent for a while now. PyCharm was for a long time my Python IDE of choice [^2] and it had really strong support for type hints, meaning that a declaration like:

def count_words(input: str) -> int: return len(input.split())

would be enough to provide PyCharm with information for callers of count_words, such that I would get a very angry red squiggle if I tried to write something like:

arbitrary_string = "hello world" if "hello" in count_words(arbitrary_string): print("Greetings found!")

This was net useful in of itself, and even if you have no plans to integrate with mypy I would recommend getting in the habit of using type hints! (Much electronic ink has been spilled about the niceties of writing type signatures as an exercise in thinking more deeply about your interfaces & contracts. I won't rehash those arguments, but rest assured I agree with them.) However, that was the depth of my investment. Back when I did this in ~2019 or so, I looked into actually providing a typecheck step for the Python codebase and was stymied by lack of third-party "stub" support. [^3] That was around eighteen months or so ago, and things have improved significantly since then! On a lark, I decided to pick back up the branch (aptly named mypy-world-domination) and saw that both django-stubs and boto3-stubs have progressed significantly, to the point where the majority of issues flagged by mypy were not "hey, I have no idea what django-rest-framework is" but "hey, you're not handling this Optional correctly."

After some configuration futzing, I was greeted with a veritable wall of mypy errors:

$ poetry run invoke typecheck utils/functional.py:58: error: Incompatible return value type (got "Dict[Any, Any]", expected "Stream") markdown_rendering/extensions/newtab.py:28: error: "handleMatch" undefined in superclass emails/views/utils.py:99: error: "BulkActionView" has no attribute "model"

...truncated for concision...

api/tests/test_emails.py:31: error: "Client" has no attribute "credentials" api/tests/test_emails.py:34: error: "HttpResponse" has no attribute "data" Found 822 errors in 281 files (checked 887 source files)

822 instances of mypy telling me something was amiss. It was time to get to work!

Where did I stumble?

Optional unwrapping

The first stumbling block is perhaps the most stereotypical: dealing with optionals. My code now has a lot of the following pattern:

def cancel_premium_subscription(subscriber: Subscriber) -> None: stripe_subscription: Optional[StripeSubscription] = subscriber.stripe_subscription if not stripe_subscription: return stripe_subscription.cancel()

It's fair to say, too, that this is perhaps working as intended. Nulls are the worst thing or whatever that quote is, and I think my codebase suffers from "None-as-control-flow" syndrome a good deal. Part of this has been ameliorated by borrowing some concepts like Result types from other, more mature static environments, but I would love to see some a bit more semantic sugar not unlike the constructs offered by TypeScript or Swift:

    const person = {
      name: "Jane",
      pets: [{
        type: "dog",
        name: "Spike"
      }, {
        type: "cat",
        name: "Jet",
        breed: {
          type: "calico",
          confidence: 0.8
        },
      }]
    };

    // Returns [undefined, "calico"]
    console.log(person.pets.map(pet => pet.breed?.type)

Mutable global payloads

In what is probably one of many regrettable architectural decisions, I rely on Django middlewares to handle a lot of things that happen within the lifespan of an API request. This might looks like this:

class CustomDomainRoutingMiddleware(MiddlewareMixin): def process_request(self, request: HttpRequest) -> None: custom_domain: Optional[str] = extract_custom_domain(request) if custom_domain: newsletter = Newsletter.objects.get(domain=custom_domain) request.newsletter_for_subdomain = newsletter

This approach is valid Python, and mostly recommended within Django documentation, but mypy is not a fan for two reasons:

  1. We're setting global state on an HttpRequest which has no concept of a newsletter_for_subdomain attribute.
  2. We then need a way of retrieving that attribute from an HttpRequest object; any subsequent access of request.newsletter_for_subdomain will also raise warning signs.

My suspicion is that the right approach here is to declare an omnibus HttpRequest subclass with all potential global payloads:

class ButtondownRequest(HttpRequest): newsletter_for_subdomain: Optional[Newsletter]

... and so on

But when I go through such a process, I run into lots of violations of the Liskov substitution principle. Of the three stumbling blocks listed, this is the one that I would bet has the most obvious (or at least “obvious in retrospect”) solution. One of the trickinesses of migrating to mypy in 2022 is that, while it's easier and more worthwhile than it was in 2020, documentation & war stories are still somewhat scarce.

Type refinement

Django (and thus Buttondown) express foreign key relationships as optionals. For example, I have a Subscriber model that represents a single email address subscribed to a newsletter. A simplified version of this model looks something like the following:

class Subscriber(models.Model): email_address = EmailField() creation_date = DateTimeField() import_date = DateTimeField(null=True) # blank if was not imported

Every subscriber corresponds to a single newsletter

newsletter = ForeignKey(Newsletter)

Premium subscribers also exist in Stripe

stripe_subscription = ForeignKey(StripeSubscription, null=True)

This is all django-stubs and mypy need to get a pretty useful understanding of what a Subscriber entails; it's got a handful of non-optional fields (such as email_address and creation_date and newsletter) and some optional fields (import_date and stripe_subscription). The tricky part here is when you want to express an invariant upon all Subscribers. Let's say I have a cron that filters through all premium subscribers and checks to make sure the backing Stripe subscription isn't cancelled:

def check_premium_subscriptions() -> Iterable[Subscriber]: premium_subscribers = Subscriber.objects.exclude(stripe_subscription=None) for subscriber in premium_subscribers: if subscriber.stripe_subscription.status == 'cancelled': yield subscriber

Sadly, mypy is not a huge fan of this — subscriber.stripe_subscription is an Optional[StripeSubscription] and calling .status on it is therefore dangerous. You could, I think, persuasively argue that this is solved with something like a Result type (there's a very interesting Pythonic one here). A more elegant solution, though, and one that closely maps onto TypeScript's approach to nuanced type refinement, would be being able to declare a version of Subscriber that has a StripeSubscription. This issue in of itself is still interesting, though, because it suggests a better way to structure this cron and avoid the refinement entirely — iterating on the subscription rather than the subscriber:

def check_premium_subscriptions() -> Iterable[Subscriber]: subscriptions = StripeSubscription.objects.filter(status='cancelled') for subscription in subscriptions: if subscription.subscriber: yield subscription.subscriber

This kind of forced re-examination of cross-object relationships was a very useful byproduct of driving down my mypy errors, in much the same way that the act of expressing type signatures forces you to think a little more deeply about the contracts & interfaces you're reifying.

How long did the whole thing take?

Buttondown's Python codebase currently sits at 38,103 lines. Upon the initial run of poetry run typecheck [^4], mypy reported 822 errors. Ouch. Resolving those errors took me approximately eleven hours to resolve in full. I pulled out the metaphorical banhammer, annotating a file with # mypy: ignore-errors, only thrice:

  1. Django's abstraction for creating RSS feeds has some very specific interfaces that I resolve in very gross ways, and rather than try to shoehorn it into something mypy would endorse I ignored the entire thing.
  2. That "some models have stripe models associated with them" wasn't a toy example; it applied directly to how I handle paid accounts, and since I'm knee-deep in a refactor of that codebase I decided to punt on the module in question.
  3. Python's quasi-official markdown rendering package has some significant duck-typing implications that make it very hard for me to rigorously type-check my extensions on top of it.

That was around two solid engineer-days spread across two weeks (I was doing this whilst traveling, so around an hour or two every day). The work was an even 80/20 split:

  • 80% of the work was rote and pleasantly formulaic ("oh, that's actually an Optional[float] and not a float!"; "oh, I need to express this as an Iterator and not an Iterable!");
  • 20% of the work was unbounded and painful, a bit of an "unknown unknowns" situation ("how do I express a strongly typed spread operator?"; "how do I express a partial mock?")

What advice do I wish I had?

  • This might be cheating, but if you know you want to eventually move to mypy start as early as possible. Even if you need to litter your codebase with Any and # type: ignore annotations, the sooner you start the better.
  • Getting the ground running with functional bits of your codebase as quickly as possible facilitates the entire process! Rather than trying to boil the ocean in your first go, start off with small little ponds of well-typed functionality before moving onto the hairier parts of your codebase. Django's app-based architecture lends itself very well to this, since ideally you're breaking out logically disjoint parts of your application early and often.
  • Aggressively separate "type reification" (keeping the logic of the codebase intact, but annotating as necessary) with "type fixes" (changing the logic of the codebase in order to clean up your types). My first few efforts commingled the two, which led to issues where I was seeing divergences in the behavior of unit tests and it wasn't immediately obvious what changes had caused them.

Was it worth it?

Yes! As mentioned above, I don't think I'd advise folks in trying to do a "big-bang"-style migration in the manner I did unless your codebase is sufficiently small; because I was working on this branch alongside other feature branches, churn was non-trivial and it would have made more sense to go package-by-package, starting with smaller and more reified interfaces and moving onward. One of the more common cliches about shifting towards type safety, as alluded to earlier, is the concept of "forcing you to think in types". An example of this is something like the below method that I had kicking around:

def send_draft(email, recipient)

"Recipient" is not a proper noun in Buttondown's codebase, and once I started adding types it became obvious that it was a bit of a chimera:

def send_draft(email: Email, recipient: Union[Subscriber, Account, SyntheticSubscriber]) -> None

This need to declare an interface for something that "looks like a person with an email address" led to a number of arcane issues and duct-tape over the years — keeping audit logs of emails Buttondown’s sent to subscribers versus accounts is different, for instance. Just the act of writing out the contract made it much more obvious what the right behavior should be: rather than having an omnibus "send draft" method that tries to handle things differently, I refactored the logic to decompose the 'recipient' into a single email address, giving me something much more simple to reason about:

def send_draft(email: Email, email_address: str) -> None

That being said, "thinking about types" and reifying your interfaces are caviar problems. I like those things, but I (and likely you) am in a position where elegant abstractions are a luxury compared to the value proposition of writing safer code. To that end, I thought I'd end by talking about some specific, real-world (albeit silly!) bugs that mypy revealed for me:

  1. I have a simple dataclass — AdminNewScheduledEmailNotifier — that pings me in Slack whenever a new email is scheduled. I pass in a ScheduledEmail but mistakenly declared the type as an Email, which is an object with slightly different properties. Notably, ScheduledEmail has schedule_date whereas Email has publish_date. mypy detected this — and found a code branch where I was not getting notified about newly scheduled emails for certain newsletters.
  2. I display cohort information for subscribers — essentially how many subscribers a given newsletter pulls up in a given bucket of time. I declared the dataclass for this structure to be of a List[List[float]] whereas in fact it was a List[List[Optional[float]]; this meant that while the Python side of things was fine (dataclasses do not throw if you pass in malformed data) my frontend assumptions of the returned data were not, and as a result mypy actually helped me fix a frontend bug that I had been nigh-unable to reproduce for months.
  3. I had a whole lot of duplicative test code where I was just passing in completely extraneous kwargs! For example, Subscriber.objects.create(user=user) where user is not actually an attribute on Subscriber. While this isn't a bug, it's certainly confusing, and can lead to serious issues down the line when I programmatically modify the codebase.
  4. Lots and lots of mishandled Optionals. Too many to count.

The promised land

I'm writing this post a few weeks after I actually completed and shipped the migration, so as to provide space for a bit of a coda — now that I've actually done the dang thing, what does day-to-day development feel like? The answer is — more of the same, but with an additional guard rail. I'm writing code with very, very few optionals now unless a foreign key is involved, and precommit lets me know when I've missed an off-ramp somewhere. Plus, I get to write functional pipelines like the following:

A function that pulls in archived emails from an external source such as WordPress, Hey World, or Tinyletter

def execute_online_import( retrieve_urls: Callable[[ArchiveImport], Iterable[str]], convert_response: Callable[[requests.Response], Email], archive_import: ArchiveImport, ) -> List[Email]: urls = retrieve_urls(archive_import) extant_email_subjects = typing.cast( List[str], Email.objects.filter( newsletter=archive_import.newsletter, ).values_list("subject", flat=True), ) pipeline = pipe( requests.get, convert_response, partial(filter_extant_emails, extant_email_subjects=extant_email_subjects), partial(maybe_finalize_email, archive_import=archive_import), ) emails = [pipeline(url) for url in urls] return [email.unwrap() for email in emails if email != Nothing]

Whereas before, Python made it a dangerous proposition to deal with partials and composition in this manner — what if convert_response doesn't map cleanly onto the arguments of filter_extant_emails!? — it's now safe.

Useful resources

  • Hypermodern Python, a very opinionated series of essays about structuring a well-typed & well-executed Python codebase. While this wasn't the specific impetus for me going whole-hog on mypy, it certainly was an accelerating factor.
  • typeshed, the official repository of type stubs. Without this package's growth and prominence I would have been at an absolute loss.
  • dry-python's returns, a collection of utility functions to improve type safety in your codebase. I don't use a lot of this package — mostly I use the pipeline functions which allow me to compose functions in a typesafe manner — but it's an excellent resource to read through and shift over parts of your codebase to more of a mypy-friendly mode.
  • Zulip and Cal Paterson both had great writeups of their shifts to mypy.

[^1]: Perhaps a more accurate comparison here would be with Sorbet, a Ruby type checker that sits on top of Ruby. But I am surmising that more people are familiar with TypeScript than with Sorbet, so there you go. [^2]: I've since replaced PyCharm with VSCode. This is for two reasons, neither of which are PyCharm’s fault! VSCode's Vue ecosystem is really robust compared to JetBrains', and I use VSCode at my day job (in the rare occurrences when I code these days), so context-switching is minimal. Still, I heartily recommend PyCharm if you're interested in very, very strong integration with the Python ecosystem. [^3]: "stubs" are a silly name for a useful concept that ideally should not exist. They refer to separately-published sets of type signatures for packages that themselves do not have type signatures. For instance, Django has made a conscious choice to not yet include type information in their package, so a stubs package — aptly titled django-stubs — consists solely of type signatures for Django itself. [^4]: This is an incantation that may not look familiar. I use poetry for Python dependency management and Invoke for task execution.

Coda

Thank you to Sumana Harihareswara for proofreading this essay!

What customers say about Buttondown

Doesn't matter if you're sending out irregular updates to a few dozen friends, or marketing your business to a thousand recipients. It is absolutely a joy to use. The documentation is great. And Justin provides the best support I have ever encountered. Amazon isn't the world's most customer-centric company, Buttondown is.
Tilde Lowengrimm
Head of Strategy, Red Queen Dynamics
I made the transition from MailerLite and I have no regrets. I also like that Buttondown focuses on the essentials by design and keeps me grounded and centered on what really matters.
Arthur Cendrier
Author, Thoughtful Inquiry
Overall, Buttondown has been terrific to work with and I recommend them for anybody who's thinking of starting a newsletter or moving over like I did.
Andy Magnusson
Customer Engineering Leader
Wanna know how good Buttondown as a product experience is? I upgraded to Basic before sending the first email, and then upgraded again two days later.
Zak El Fassi
Founder, Zaigood Labs
Mailchimp lost me due to their inferior product and the nightmarish merry-go-around experience with their overseas support team. Buttondown won me over with their superior product and second to none customer service.
Sav Tripodi
CEO, Sanico Software
Your support is amazing and I deeply appreciate how available and helpful you are. I LOVE being able to turn tracking pixels off. I didn't even realize this was an option when I signed up and am SO HAPPY to not track people.
Andrea Mignolo
Method & Matter
I'm also impressed with how responsive you are, and how you directly answer customers. Makes it really clear that signing up for your service was a good decision.
Nicole Tietz-Sokolskaya
Software engineer and writer
Very happy with Buttondown, works smoothly, it's very configurable and I love the minimalist design of the UI. It makes me focus on my writing. Plus, I'm super happy to support independent software and I should mention - the support I receive whenever I have a question is warm and quick :)
Martina Pugliese
Data scientist and storyteller
I just tested the RSS to Email feature for one of my blogs and it was incredibly easy to set up. It took me about 30 mins to figure out the same feature in Mailchimp.
Nicolas Bernadowitsch
Blogger
This long weekend I fulfilled a long-standing promise to myself to switch my RSS-to-email provider from Mailchimp to Buttondown, and it’s been such a great experience. It’s cheaper, more flexible, less cluttered, and it’s run by Justin Duke who is just delightful and answered a bunch of my questions over the weekend (even though I asked him to please not!).
Rian van der Merwe
Director of Product at PagerDuty
I've been wrangling half a dozen tools to get my stuff up and running recently, almost all of which had some hiccup. Buttondown had zero. It did everything I expected and needed the first time.
Catherine Cusick
Self-Employed FAQ
I, like almost everyone else I've seen talk about Buttondown, am IMMENSELY happy and impressed with your customer service. It turns out we can have nice things, which is really refreshing.
Ed Yong
Staff writer, The Atlantic
Email makes the world go ‘round, and Buttondown is how I manage it all for my keyboard projects.
Tim van Damme
Founder, MVKB
It's a truth, that should be more universally acknowledged, that Buttondown is the best newsletter software. Simple, does exactly what it sets out to do, and reasonably priced.
Noel Welsh
Founder, Inner Product
Buttondown is the perfect fit for my headless newsletter use case. And I contacted support with some specific requests and Justin responded within 30 minutes with great answers and a nice pinch of charm.
Sam Roberts
Software engineer, Tamr
Hands down the easiest way to run a newsletter - and the free version is generous!
Javeed Khatree
SEO expert
With API and Markdown support, you can build workflows that make it so easy to write.
Westley Winks
Peace Corps
I’ve never enjoyed writing newsletters as much as I do with Buttondown.
Kevin Lewis
You Got This!
Buttondown remains the easiest thing I use regularly, and I am grateful for that.
Casey
Journalist
It's a humble app doing a common job but with end users in mind.
Si Jobling
Engineering Manager
Buttondown has been an amazing experience for me. The service is constantly being improved and customer service is the best. My newsletter with Buttondown has grown from a fairly small list to over 15,000 subscribers, and it hasn't broken a sweat yet.
Cassidy Williams
CTO, Contenda
I switched over to Buttondown from Mailchimp because of the difficulty I had with Mailchimp's campaigns, so Buttondown's easy and user-friendly system has been a genuine breath of fresh air.
Jessi Eoin
Illustrator + Comic Artist
You’ve truly built a great product that I feel good about using (vs a monopoly from our tech overlords).
Rachel
2030 Camp
I love how personal Buttondown feels, especially compared to Mailchimp, Convertkit, and services like that.
Simen Strøm Braaten
Designer
This product has been exactly what I’ve needed!
Nathan Bird
Podcast host, Chattanooga Civics
It's already so refreshing compared to the mega companies.
Casey Watts
Author, Debugging Your Brain
Definitely will be using for the foreseeable future. It’s a great service and I feel well cared for. Thank you!
Phoebe Sinclair
Author
I’m a sucker for elegant UI and I really love your site, but above that I think your product has so much value for so many different people. I’m not a coder, I’m only familiar with the bare basics, but I was able to figure out and utilise Buttondown quickly.
Claudia Nathan
Founder, The Repository
The killer feature for me: Buttondown will take an RSS feed then automatically slurp up the content (in their words) and then send it to our subscribers. Job done. They seem like a good company too, so I’d say this is a winner.
Andy Bell
Founder, Set Studio
As a recent user of Buttondown, they are super on the ball. A week ago I discovered a security vulnerability and reported it on Friday afternoon. They acknowledged and fixed it in under two hours. On a Friday night! Talk about going above and beyond for your users!
Predrag Gruevski
Principal Engineer, Kensho
Well may I just say your support experience is already approximately 1 billion times better than ConvertKit. Excited to be switching!
Michael J. Metts
Author, Writing is Designing
Privacy focused sending and sign up form; lets me focus on writing - editor is "just" markdown; simple, elegant design template looks like a blog post; the founder is amazing - he's helped with every question I've had, even outside of Buttondown.
Joe Masilotti
Founder, RailsDevs
We need more nice and professional services like yours on the web.
Tobias Horvath
Designer and developer
No one is paying me to say this, but I love @buttondown so far for my lil newsletter. It’s so smart, simple, and attractive (and to my knowledge, not actively anti-trans!). Customer service is also legitimately excellent.
Julie Kliegman
Copy chief, Sports Illustrated
I love it! It lets me breathe, not compete as I write with other writers.
Devin Kate Pope
Writer and editor
It’s a pleasure working with you. Thank you! (And what a contrast with Mailchimp, where I spent two weeks and a dozen of emails trying find out why our form goes down sometimes (only sometimes), and never really got a real answer.)
Anton Sotkov
Software Engineer, IA
Buttondown exemplifies how I wish most software worked, and I hope to achieve a similar thing with the software I develop in the future.
Matt Favero
Software engineer
It feels incomparably good to be able to email just like a guy named Justin when you have a @buttondown question 15 minutes before you’re about to blast a Geistlist email. (Not a guarantee but wow this guy is human-level good.)
Jacob Ford
Designer About Town
Enter Buttondown, Justin Duke’s lovely little newsletter tool. It’s small, elegant, and integrates well. And it is also eminently affordable.
Will Buckingham
Author
Your settings page is a joy to use and everything about Buttondown makes me happy.
Gareth Jelley
Magazine editor
have been on Buttondown for ~18 months and I can't recommend it enough.
Elizabeth Minkel
Podcast host
You really do make ALL other customer service look terrible by comparison.
Chris Mead
Improv teacher
There is a caring person on the other side of this software, which is one of the things I like the most about Buttondown.
Keith Calder
Film & TV Producer
I’d also like to add that @buttondown is an absolute joy to use. Hats off, Justin!
Elliot Jay Stocks
Creative Director, Google Fonts
Shoutout to @buttondown and @jmduke for building an amazing bootstrapped product for newsletters, all while being very open to feedback and connecting directly with customers 🙏 Easily one of the most enjoyable product experiences I've had.
Den Delimarsky
Head of Ecosystem, Netlify
if you are looking for "newsletter tool for hackers" i tentatively believe the answer is @buttondown full api, compose in markdown, good docs for setting up domain auth, simple subscribe form HTML that you style yourself (or not)
Brian David Hall
Author, Your Website Sucks
I really like @buttondown as a blogging platform, it has the simplicity of Substack but the corporate culture is less toxic.
Chad Loder
Extremism researcher
I worked with @buttondown and asked for some new payment support beyond the supporter single tier / pay-what-you-want options. Justin was great and built it in just a couple days.
Dan Hon
Author & consultant
I write nonfiction and I use @buttondown buttondown.email/Changeset - indie, GREAT personal customer support, very nice default styling, all the options I want including ones to protect my readers' privacy
Sumana Harihareswara
Open source maintainer
I use @buttondown because it does exactly what I need (manage subscribers and send markdown emails), not more and not less 👍 As a bonus it's made by an indie dev which I love!
Max Stober
Founder, GraphCDN
If you’re considering running an email newsletter, or if you already run one and are considering a change of provider, I highly recommend @buttondownemail. Super-easy app, very fair pricing with a generous free tier, and exemplary support. 💯
Peter Gasston
Technologist and speaker
imo @buttondown is easily one of the best-designed services i’ve used in recent years, if you have a substack you should really consider switching!
Kabir Goel
Engineer, Cal
Thanks for getting me excited about email newsletters again.
Garrick van Buren
I'm very thrilled that I can just write in Markdown without having to deal with email builders and all that crap.
Parham Doustdar
Thanks again for all the help! You’ve really turned something super complex into something super easy – sending new issues is as simple as firing off a text message.
Kartik Chaturvedi
Thanks for creating a simple way for people who want to, like, put words in a hole and have it sent to people... I am just thankful that something just nice and human exists on the internet.
Emmanuel Quartey
I tried 3 other newsletter services today and I felt like wanting to rip my hair out. They were all painfully slow. I'm so glad I found Buttondown.
Mohamed Elbadwihi
I’ve found Buttondown to be a great fit for my workflow and have been delighted by all of Justin’s thoughtful features and improvements to the product.
Michael Lee
Like seriously, so many lovely little easter eggs in one could-be-boring service.
Alexandra Muck
I just switched over from Tinyletter and I'm really excited to have found a place to host my tiny newsletter that doesn't seem like it's assuming everyone sending newsletters is an email marketer / growth hacker.
Tessa Alexanian
I'm in love with the simplicity of Buttondown.
Ekfan
I’ve used similar tools in the past and Buttondown is by far the simplest to use and most promising.
Fabrizio Rinaldi
Thank you for creating such a simple and brilliant tool. I’ve just signed up and the experience has been smooth and painless (the docs are great too!)
Oliver Holms
As a developer who has hated every email system I've ever used this is so nice.
Drew Hornbein
I wish I still wrote a newsletter just so I could use buttondown again. It’s like that.
Steven Kornweiss
No credit card required. Only pay for what you use. Cancel anytime.