UPLID is a Python library for Stripe-style prefixed identifiers -typed, time-sortable, and human-readable. I wrote it two years ago but never cleaned it up for release. Thanks to Claude Code I ran out of excuses. This post details the journey.

My Type Of Introduction

I started my career in the wild duck typing world of js/python/ruby. It felt freeing from my earlier awful experiences with Java in school. As I got more experienced, I came across a blog post on Meta’s type checking project for Python, Pyre -it was mind blowing. I hadn’t realized Python had type hints. At the time I was in charge of scaling a fast-growing startup’s data platform, a Python stack wrangling code from jupyter notebooks into production.

We were on a critical path of the sales process. I have a fond memory of a 4-hour outage where the CRO relocated his desk next to mine to minimize the latency of status updates. Types felt like a silver bullet for enforcing some discipline in the unruly code base. We were still pre-fastapi/pydantic -we had to hand-maintain stubs for about 80% of our dependencies and endure constant grumbling from the data science folks. But it kept my cortisol levels manageable around each deployment.

A few years later I built a greenfield stack for my startup. Fastapi and pydantic promised me I could finally have my 100% type-checked Python codebase. There I also discovered the magic of branded types .

Branded Types

Every database table has a primary key. Let’s keep it simple and say these are auto-incrementing integers. For users you’ve got user ids that are 1, 2, 3, 4… and for threads, the same -an id column with 1, 2, 3, 4… . When these make it back to your application, the adapter code dutifully converts them to ints. From the type checker’s perspective, they have lost information.

Consider a function

def reply_create(
    usr_id: int,
    thread_id: int,
) -> Reply: ...
def reply_create(usr_id: int, thread_id: int) -> Reply: ...

then the following code

usr_id = 1
thread_id = 10
reply = reply_create(
    thread_id, usr_id
)
usr_id = 1
thread_id = 10
reply = reply_create(thread_id, usr_id)

Your type checker would give this a clean bill of health -ints to ints. What’s the problem? Without knowing which int is which, these identical types get mixed up, causing runtime bugs or requiring tests to catch it. But there is an easy way to preserve this information for the type checker.

from typing import NewType

UserId = NewType('UserId', int)
ThreadId = NewType('ThreadId', int)

Underneath they remain integers. Nothing changes at runtime. But now you can properly type the function, and any typechecker -ty, mypy, pyright -will catch it.

def reply_create(
    usr_id: UserId,
    thread_id: ThreadId,
) -> Reply...

reply_create(thread_id, usr_id)
def reply_create(usr_id: UserId, thread_id: ThreadId) -> Reply...

reply_create(thread_id, usr_id)

This simple change catches a whole category of errors at static analysis time -one less thing for developers and agents to think about while coding. Beyond database keys it’s great for API keys like StripeClientId and StripeSecretKey, and special strings like SQL blocks. But branded types solve the static analysis problem. They don’t help when identifiers leave your codebase and enter the real world.

Human/Machine Friendly Types

Your identifiers will escape the machine and enter human space. Your Slack will be full of people yelling customer ids back and forth, copying ids from error messages and logs.

“No no no not customer 681d1239-ebe3-44b2-bfed-ce893ce9f4d0, it’s customer 26c7a9df-ccb2-4686-99f4-988a3060dc18 who’s having the issue! Yes it keeps saying the thread 3db16e34-f72b-4a00-b1fd-ffc99528a3c0 does not exist, how is that possible?!”

You quickly run into a new issue. Branded types prevent mixing up a user_id and a thread_id in code. But when an id shows up at runtime, how does the system know what it belongs to? It’s just as easy for a human to mix up an order id with a customer id -doubly so for your downstream customers. They all look identical.

Stripe was not the first, but is perhaps the best-known example of Prefixed Ids

acct_34PgAgMrrfXIUdQ

They prefix each id with its type. Both humans and machines instantly know what the id refers to -a Stripe secret key or a customer id, no guessing needed.

UPLID Finally

I wanted all of it -branded for static analysis, runtime type checkable for external APIs, human-readable, URL safe, and native to Pydantic/FastAPI/SQLAlchemy. So I built UPLID .

from typing import Literal

from uplid import UPLID, factory
from pydantic import BaseModel, Field

UserId = UPLID[Literal["usr"]]
UserIdFactory = factory(UserId)

class User(BaseModel):
    id: UserId = Field(
        default_factory=UserIdFactory
    )

def user_find(
    usr_id: UserId,
) -> User: ...

user_find("some-random-id")
usr_id = UserIdFactory()
user_find(usr_id) # Type Checker Happy

bad_input = '{  "id": "a-random-id" }'
good_input = """{
  "id": "usr_0M3xL9kQ7vR2nP5wY1jZ4c"
}"""

# Raises Pydantic Validation Error
User.model_validate_json(bad_input)
# Cleanly returns User
User.model_validate_json(good_input)
from typing import Literal
from uplid import UPLID, factory
from pydantic import BaseModel, Field

UserId = UPLID[Literal["usr"]]
UserIdFactory = factory(UserId)

class User(BaseModel):
    id: UserId = Field(default_factory=UserIdFactory)

def user_find(usr_id: UserId) -> User: ...

user_find("some-random-id")
usr_id = UserIdFactory()
user_find(usr_id) # Type Checker Happy

bad_input = '{ "id": "a-random-id" }'
good_input = '{ "id": "usr_0M3xL9kQ7vR2nP5wY1jZ4c" }'

# Raises Pydantic Validation Error
User.model_validate_json(bad_input)
# Cleanly returns User
User.model_validate_json(good_input)

Python’s type hints are introspectable at runtime. Setting the prefix with a Literal string lets Pydantic use it for runtime type checking -the same annotation works at both compile time and runtime. Under the hood it uses Python 3.14’s native uuid7 for time-sortable unique ids, encoded as URL-safe base62 strings.

Since it works with Pydantic, FastAPI’s JSON payload parsing works out of the box. The ids map cleanly into Logfire and make the Logfire MCP even more potent for Claude debugging. The library also includes helpers for SQLAlchemy and Tiangolo’s excellent SQLModel .

Shoutout to Astral & UV which drastically simplified building and releasing UPLID on PyPI . Their type checker ty has reduced my deploy-from-merge times to a cool sub 17 seconds -a project I’ll talk about at another time.

Check out the source on GitHub or the package on PyPI . Try it today:

uv add uplid

Updates

-Initial publication.