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

推荐订阅源

让小产品的独立变现更简单 - 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 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 Subscription wall Simplified email address settings 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 check every link in your email Should we bring back email exploders? Use newsletter metadata in your emails 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
How we migrated to TypeIDs without breaking clients
Tanvir Raj · 2026-03-01 · via

From the very beginning, Buttondown has used UUIDs as primary keys. Every subscriber, every email, they all get an ID like 550e8400-e29b-41d4-a716-446655440000.

UUIDs work fine as database keys, but they're opaque. If you're building on our API and you get back 550e8400-e29b-41d4-a716-446655440000, there's nothing telling you whether that's a subscriber, an email, a tag, or a newsletter. You have to track that context yourself — in your code, your logs, your error messages. Multiply that across every ID in every response, and it gets noisy fast. With sub_01h455vb4pex5vsknk084sn02q, the prefix carries the context for you: it's a subscriber. Your integration code can validate IDs by type, your logs become self-documenting, and when something breaks at 2 AM, the ID in the stack trace tells you exactly where to look.

Stripe popularized this idea with prefixed IDs, and the TypeID spec gave us a clean way to do it. A TypeID is a UUID encoded in base32 with a human-readable prefix. Same UUID underneath, just dressed up for the outside world.

flowchart LR
    A["REQUEST
sub_01h455vb4pex
or
550e8400-e29b-..."] --> B["NORMALIZE_ID()
Always yields
a UUID"] B --> C["DATABASE
Stores UUID only"] C -->|">= 2026-01-01"| D["sub_01h455vb4pex"] C -->|"< 2026-01-01"| E["550e8400-e29b-..."]

To make this work consistently across our models, we added a single convention each model declares a short type_id_prefix. When Python loads the model class, our BaseModel.__init_subclass__ hook automatically registers that prefix in a global TypeIDRegistry, which we later use to recognize legitimate prefixes during validation and response migrations.

class BaseModel(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    objects = TypeIDAwareManager()

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        if hasattr(cls, "type_id_prefix"):
            TypeIDRegistry.register(cls.type_id_prefix)

Adding TypeID support to any model is a one-liner:

class Subscriber(BaseModel):
    type_id_prefix = "sub"

class Email(BaseModel):
    type_id_prefix = "em"

BaseModel then has a type_id property that encodes the UUID into a TypeID using the typeid package and the model's prefix:

@property
def type_id(self) -> str:
    prefix = getattr(self.__class__, "type_id_prefix", self.__class__.__name__.lower())
    id_value = UUID(self.id) if isinstance(self.id, str) else self.id
    return str(from_uuid(prefix=prefix, suffix=id_value))

Here's what this looks like in practice:

newsletter = Newsletter.objects.create(name="My Newsletter")
newsletter.id       # → "550e8400-e29b-41d4-a716-446655440000"
newsletter.type_id  # → "news_01h455vb4pex5vsknk084sn02q"

Once models can produce a TypeID, wiring it into the API is mostly a serialization change. Just like BaseModel, we have a BaseSchema with a resolve_id method that returns obj.type_id instead of the raw UUID, so every schema that inherits from it gets TypeIDs for free. By default, any object's ID in a response is a TypeID. More on how we return UUIDs for older clients with API versioning later.

The more interesting question is: what happens when a request comes in with a TypeID? The database only has UUIDs, so we need to decode it before querying.

Under the hood, we have a decode_type_id function that parses the base32 suffix back into a UUID. On top of that, normalize_id is the function we actually call. It passes UUIDs through untouched and only decodes TypeIDs:

def decode_type_id(type_id: str) -> UUID | None:
    if "_" not in type_id:
        return None
    suffix = TypeID.from_string(type_id).suffix  # type: ignore
    decoded_bytes = bytes(base32.decode(suffix))
    return UUID(bytes=decoded_bytes)


def normalize_id(identifier: str) -> str:
    if is_valid_uuid(identifier):
        return identifier
    decoded = decode_type_id(identifier)
    return str(decoded) if decoded else identifier

Calling normalize_id in every route view would be tedious and error-prone. The trick is applying the conversion consistently, including __in lookups and foreign-key filters like subscriber_id. Rather than sprinkling it across views, we pushed it down into the Django ORM.

We wrote a munge_kwargs_for_type_id function that scans every kwarg: if the key is id or ends with _id (including lookups like subscriber_id__in), it converts any TypeID values to UUIDs. Then we wrapped it in a custom QuerySet that intercepts the core ORM calls:

class TypeIDAwareQuerySet(models.QuerySet):
    def get(self, *args, **kwargs):
        return super().get(*args, **munge_kwargs_for_type_id(self.model, kwargs))

    def filter(self, *args, **kwargs):
        return super().filter(*args, **munge_kwargs_for_type_id(self.model, kwargs))

    def exclude(self, *args, **kwargs):
        return super().exclude(*args, **munge_kwargs_for_type_id(self.model, kwargs))

Because BaseModel sets objects = TypeIDAwareManager(), every model gets this for free:

# All of these work:
Subscriber.objects.get(id="sub_01h455vb4pex5vsknk084sn02q")
Subscriber.objects.get(id="550e8400-e29b-41d4-a716-446655440000")
Subscriber.objects.filter(id__in=["sub_abc123", "sub_def456"])
Email.objects.filter(subscriber_id="sub_01h455vb4pex5vsknk084sn02q")

This is where most of the heavy lifting lived. Once TypeIDAwareManager() was in place, we could migrate the API incrementally without breaking anyone. Every Django query could accept either a UUID or a TypeID, and everything still resolved to the same rows.

The last piece is API versioning. Every API request carries an X-API-Version header, and we apply response migrations based on that version. We added one migration function that applied convert_typeid_to_uuid. Clients on 2026-01-01 or later get TypeIDs. Older clients get UUIDs, automatically. On the input side, both formats always work. Zero breaking changes.

{
      "id": "sub_01h455vb4pex5vsknk084sn02q",
      "email_id": "em_7x9kq3m2abc"
}

If your version is older, the migration converts TypeIDs back to plain UUIDs:

{
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "email_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}

The core concept is straightforward, but the migration touched almost every API endpoint, and the edge cases added up. One example was automation metadata. Automations store action data as JSON, with tag IDs buried inside nested dictionaries. We had to walk the metadata tree and normalize any TypeID we found without breaking the structure. Foreign keys were the other big surface area. It's not just the id field, but every _id field like referring_subscriber_id and tag_ids in bulk actions, each needing to accept TypeIDs on input and return them on output.

We didn't flip a switch. We migrated 29 routes one at a time, starting with low risk endpoints like images and attachments, building confidence, and working up to high-traffic ones like subscribers and emails. Every endpoint got tests that sent both a UUID and a TypeID and asserted the response matched the requested API version.

With TypeIDs rolled out across the entire API, every ID in Buttondown is now self-describing. Support tickets are easier to debug, API integrations are safer, and log lines actually tell you what you're looking at.

If you're using the Buttondown API, you don't need to do anything. Your existing integrations keep working. But if you'd like to start using TypeIDs, just set your X-API-Version header to 2026-01-01 or later, and every ID in the response will tell you exactly what it is.