Open-source project
mdomke/python-ulid avatar
mdomke/python-ulid

python-ulid: sortable identifiers with a policy you can choose

ULID implementation for Python

570 stars31 forksPythonMIT

At a glance

What is it?
python-ulid is a Python implementation of the ULID standard, giving you 128 bit identifiers that are 26 characters long, sort by creation time, and come in a choice of shapes when many are created inside the same millisecond. The interesting design decision in the 4.x line is that generation is a policy object rather than a fixed behaviour, so the library lets you choose between strict monotonicity, lax monotonicity and pure randomness.
Who is it for?
python-ulid suits a Python service that needs identifiers which sort by creation time, whether that is for database keys, event ordering, log correlation or a distributed queue, and it suits teams that want the 26 character Crockford base32 form rather than a 36 character UUID.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 17 days ago.
What is it written in?
Mainly Python, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Twenty six characters, 48 bits of time and 80 bits of randomness

A ULID is a universally unique lexicographically sortable identifier, and the README lists the properties that make it interesting.

It is 128 bit compatible with UUID, so it occupies the same amount of information. It offers 1.21e+24 unique ULIDs per millisecond, which is the size of the randomness space divided by the number of milliseconds. It is lexicographically sortable. It is canonically encoded as a 26 character string, as opposed to the 36 character UUID. It uses Crockford's base32 for better efficiency and readability, at 5 bits per character. It is case insensitive, and it has no special characters, which makes it URL safe.

The structure is the obvious one once you see it:

text
   01AN4Z07BY      79KA1307SR9X4MV3
  |----------|    |----------------|
   Timestamp          Randomness
     48bits             80bits

Forty-eight bits of milliseconds since the Unix epoch, and eighty bits of randomness, concatenated and then encoded. The reason the whole thing sorts by time is that the timestamp comes first and the encoding is alphabet preserving, so a byte-wise comparison of the encoded strings is a comparison of creation times. That is the property that makes a ULID useful in a database index or a log, and the reason it exists as a competitor to UUIDv4 rather than to UUIDv7.

The README also notes a monotonic sort order, correctly detecting and handling the same millisecond, which is the subject of a later section.

Installation and the first identifier

Installation is a pip install, and the package is published as python-ulid:

bash
$ pip install python-ulid

There is one optional dependency, Pydantic, which comes as an extra:

bash
$ pip install python-ulid[pydantic]

The basic usage is a constructor and an import:

python
>>> from ulid import ULID
>>> ULID()
ULID(01E75HZVW36EAZKMF1W7XNMSB4)

The object prints in that form, with the identifier in parentheses, which is the repr the library chose for its main type.

There are also named constructors for the case where the timestamp comes from somewhere other than now:

python
>>> import time, datetime
>>> ULID.from_timestamp(time.time())
ULID(01E75J1MKKWMGG0N5MBHFMRC84)
>>> ULID.from_datetime(datetime.datetime.now())
ULID(01E75J2XBK390V2XRH44EHC10X)

from_timestamp takes a Unix epoch value and from_datetime takes a datetime object, and both are the escape hatches for the case where your identifier has to reflect a business time rather than the moment of generation, which is a common requirement in an import pipeline.

One value, five encodings, and a readable timestamp

The same ULID can be rendered in several ways, and the README shows all of them on one object.

python
>>> str(ulid)
'01BTGNYV6HRNK8K8VKZASZCFPE'
>>> ulid.hex
'015ea15f6cd1c56689a373fab3f63ece'
>>> int(ulid)
1820576928786795198723644692628913870
>>> bytes(ulid)
b'\x01^\xa1_l\xd1\xc5f\x89\xa3s\xfa\xb3\xf6>\xce'
>>> ulid.to_uuid()
UUID('015ea15f-6cd1-c566-89a3-73fab3f63ece')

The canonical form is the 26 character string, the hex form is the raw bytes in hexadecimal, the integer is the whole 128 bit value, and to_uuid produces a standard UUID object, which is the bridge for code that already speaks UUID.

The timestamp is directly accessible in two forms:

python
>>> ulid.timestamp
1505945939.153
>>> ulid.datetime
datetime.datetime(2017, 9, 20, 22, 18, 59, 153000, tzinfo=datetime.timezone.utc)

That the datetime is timezone aware matters for a library whose whole selling point is that time is embedded. The timestamp property gives the raw epoch value with its millisecond fraction, and the datetime property gives the same moment as an aware datetime.

Generation is a policy object, and the strict one can raise

In the 4.x line, generation is not a fixed behaviour. Every ULID is produced by a ULIDGenerator, which samples a clock for the timestamp, sources entropy for the randomness, and enforces a monotonicity policy. The bare ULID() constructor and the from_* factory methods delegate to a shared module-level default_generator.

For most use cases the default is enough. To customise, with a different clock, a custom entropy source, or another monotonicity policy, you create your own generator and call generate():

python
>>> from ulid import ULIDGenerator
>>> generator = ULIDGenerator()
>>> generator.generate()
ULID(01HB0N8Q4RCE7YB1M2VZK9WX3T)

Three policies decide how randomness is resolved when several identifiers are generated inside one millisecond.

StrictMonotonicPolicy is the default. It increments the randomness by one on a same-millisecond collision and raises ValueError if the randomness is exhausted, which is the behaviour the README describes under monotonic support, where two ULIDs with the same timestamp have randomness values where the second is the first plus one.

LaxMonotonicPolicy increments like the strict one but regenerates fresh randomness instead of raising when the randomness is exhausted. PureRandomPolicy ignores previous state and always draws fresh randomness, maximising entropy at the cost of same-millisecond sort order:

python
>>> from ulid import ULIDGenerator, PureRandomPolicy
>>> generator = ULIDGenerator(policy=PureRandomPolicy())
>>> generator.generate()
ULID(01HB0N9F3TA5KDQ6ZE0WYV7MRC)

And the default generator can be replaced globally, so ULID() and the from_* constructors use your generator:

python
>>> import ulid
>>> from ulid import ULID, ULIDGenerator, LaxMonotonicPolicy
>>> ulid.default_generator = ULIDGenerator(policy=LaxMonotonicPolicy())

That is a real change of shape for a small library. Previously the behaviour was a property of the implementation, and now it is an argument, with the strict, raising behaviour kept as the default for compatibility.

Pydantic support is a type annotation and nothing more

The Pydantic integration is the shortest part of the documentation and the easiest to get right, because it is a type annotation.

python
from pydantic import BaseModel
from ulid import ULID


class Model(BaseModel):
  ulid: ULID

model = Model(ulid="DX89370400440532013000")  # OK
model = Model(ulid="not-a-ulid")  # Raises ValidationError

The ULID class is directly usable as a field type, the string is parsed into the object, and an invalid string raises a ValidationError as Pydantic normally does. There is no custom validator to write and no configuration to add beyond the optional dependency.

That matters more than it might look. A Pydantic model with a ULID field gets validation, serialisation and a JSON schema derived from the type, so if your API framework generates its schema from the model, the ULID field is documented as a ULID rather than as an opaque string. The schema for the field, though, is the Pydantic schema for the type, and whether that is the same document as the JSON Schema produced by the generator in the previous section is a different question, since a Pydantic model describes a field, not a payload contract.

The README does not discuss the interaction between the two, which is a reasonable thing to test yourself before relying on it.

A CLI for generating, inspecting and converting identifiers

The package ships a command line interface, which is unusual for a library of this size and useful for scripting.

The CLI can be invoked either by the script name ulid or as a Python module, python -m ulid, and it allows you to generate, inspect and convert ULIDs. The first example in the README is the simplest one:

bash
$ ulid build
01HASFKBN8SKZTSVVS03K5AMMS

The command takes options, including one that builds an identifier from a datetime rather than from the current time, which is the same distinction the from_datetime constructor offers in Python.

Because the entry point is declared in the project metadata as ulid = ulid.__main__:entrypoint, the tool is a first class part of the distribution rather than an afterthought script, and it is installed with the package. The module invocation form matters in environments where the script directory is not on the path, and it is the form to use inside a container or a restricted environment.

The README's example list is cut off after the from-datetime option, so the full set of subcommands is in the documentation on Read the Docs rather than in the summary. Given the library's size, that documentation is short.

A small package with a stable release line and a real toolchain

The packaging details say a lot about how the project is run.

The build backend is Hatchling, with hatch-vcs for the version, taken from version control, and hatch-fancy-pypi-readme for the long description, which explains why the README is written in reStructuredText with custom sections and how it becomes a structured PyPI page.

The package requires Python 3.10 or newer, and the only unconditional dependency is typing-extensions for Python versions below 3.11. The classifiers cover Python 3.10 through 3.14, with POSIX Linux and MacOS X as the operating systems and a Development Status of 5, Production or Stable.

The repository layout shows the working practices. There is a ulid/ package, tests/, docs/, an AGENTS.md and a CONTEXT.md for automated contributors, a .pre-commit-config.yaml, a .ruff_defaults.toml that the project extends, a .readthedocs.yml for the documentation build, a .flox/ directory for the development environment, and a uv.lock for reproducible dependency resolution.

The release history shows a 4.0 boundary. Version 3.2.0 is dated 2026-07-17, and both 4.0.0 and 4.0.1 are dated 2026-07-20, with the last push to the repository on 2026-10-06. The 4.0 line is the one that introduced the generator and policy model described earlier, and the two releases on the same day are the migration and a follow-up fix.

For a library this small, the documentation quality is the standout. The README covers the encoding options, the timestamp access, the policy choices, the global default override, the Pydantic usage and the CLI, which is more than many larger identifier libraries explain.

Editorial conclusion

python-ulid suits a Python service that needs identifiers which sort by creation time, whether that is for database keys, event ordering, log correlation or a distributed queue, and it suits teams that want the 26 character Crockford base32 form rather than a 36 character UUID. It does not suit a project that needs globally unique identifiers without a meaningful creation order, since a UUIDv4 gives you uniqueness with no ordering guarantee and no clock dependency, and it does not suit a system whose clocks are not synchronised, because the ordering guarantee rests on the timestamp component. Check three things before you adopt it: which monotonicity policy matches your write pattern, since the strict policy raises ValueError when the randomness for a millisecond is exhausted, whether you want the optional Pydantic dependency, and that your clocks are reliable, since a ULID carries the time it was created and inherits the clock's problems. The last push to the repository was on 2026-10-06 and the newest release is 4.0.1 from 2026-07-20.

Frequently asked questions

How do I install python-ulid?

With pip install python-ulid, and with pip install python-ulid[pydantic] if you want the optional Pydantic dependency, which is the only extra the package declares.

What is the difference between the monotonicity policies?

StrictMonotonicPolicy is the default and increments the randomness on a same-millisecond collision, raising ValueError when the randomness is exhausted. LaxMonotonicPolicy increments but regenerates instead of raising, and PureRandomPolicy ignores previous state and always draws fresh randomness, which maximises entropy at the cost of same-millisecond sort order.

How do I use a custom generator with python-ulid?

Create a ULIDGenerator and call generate() on it, passing a policy if you want one. To change the behaviour of ULID() and the from_* constructors globally, reassign ulid.default_generator to your own instance.

Can I use ULID with Pydantic models?

Yes, the ULID class can be used directly as a field type in a Pydantic model, in which case a valid string is parsed into the object and an invalid one raises a ValidationError, with no custom validator needed.

Does python-ulid come with a command line tool?

Yes. The package installs an ulid script, and the CLI can also be run as a Python module with python -m ulid, and it can generate, inspect and convert ULIDs, for example with ulid build.

Official sources

  1. License: MIT
  2. mdomke/python-ulid on GitHub
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/mdomke-python-ulid.svg)](https://hysenlabs.com/projects/mdomke-python-ulid)

Was this useful?