Hex.pmHex DocsLicense

Nulid

Nanosecond-Precision Universally Lexicographically Sortable Identifier (NULID) for Elixir

Elixir bindings for the nulid Rust crate, powered by Rustler NIFs.

A NULID is a 128-bit identifier with:

Installation

Add nulid to your list of dependencies in mix.exs:

def deps do
  [
    {:nulid, "~> 0.2.0"}
  ]
end

A Rust toolchain (1.88+) is required to compile the NIF. Install one via rustup.

Usage

Generating NULIDs

# As a 26-character Base32 string
{:ok, nulid} = Nulid.generate()
# => {:ok, "01AN4Z07BY79K47PAZ7R9SZK18"}

# As a 16-byte binary
{:ok, binary} = Nulid.generate_binary()
# => {:ok, <<...16 bytes...>>}

Encoding and Decoding

{:ok, binary} = Nulid.generate_binary()

# Binary -> String
{:ok, string} = Nulid.encode(binary)

# String -> Binary
{:ok, ^binary} = Nulid.decode(string)

Inspecting Components

{:ok, binary} = Nulid.generate_binary()

{:ok, ns} = Nulid.nanos(binary)    # nanoseconds since epoch
{:ok, ms} = Nulid.millis(binary)   # milliseconds since epoch
{:ok, rand} = Nulid.random(binary) # 60-bit random value
{:ok, false} = Nulid.nil?(binary)  # nil check

{:ok, true} = Nulid.nil?(<<0::128>>)

Monotonic Generator

For guaranteed strictly increasing IDs, even within the same nanosecond:

gen = Nulid.Generator.new()

{:ok, id1} = Nulid.Generator.generate(gen)
{:ok, id2} = Nulid.Generator.generate(gen)
{:ok, id3} = Nulid.Generator.generate(gen)

id1 < id2 and id2 < id3
# => true

Binary output:

gen = Nulid.Generator.new()

{:ok, bin1} = Nulid.Generator.generate_binary(gen)
{:ok, bin2} = Nulid.Generator.generate_binary(gen)

bin1 < bin2
# => true

Distributed Generation

For multi-node deployments, assign each node a unique ID (0-65535):

gen = Nulid.Generator.new(node_id: 1)

{:ok, id} = Nulid.Generator.generate(gen)

The node ID is embedded in the random bits, guaranteeing cross-node uniqueness even with identical timestamps.

Ecto Integration

Nulid.Ecto implements the Ecto.Type behaviour. Add ecto to your dependencies to enable it.

Schema

defmodule MyApp.Schema do
  defmacro __using__(_) do
    quote do
      use Ecto.Schema
      @primary_key {:id, Nulid.Ecto, autogenerate: true}
      @foreign_key_type Nulid.Ecto
    end
  end
end

defmodule MyApp.User do
  use MyApp.Schema

  schema "users" do
    field :name, :string
    has_many :posts, MyApp.Post
    timestamps()
  end
end

defmodule MyApp.Post do
  use MyApp.Schema

  schema "posts" do
    field :title, :string
    belongs_to :user, MyApp.User
    timestamps()
  end
end

Migration

NULIDs are stored as 16-byte binaries:

create table(:users, primary_key: false) do
  add :id, :binary, primary_key: true, size: 16
  add :name, :string
  timestamps()
end

create table(:posts, primary_key: false) do
  add :id, :binary, primary_key: true, size: 16
  add :title, :string
  add :user_id, references(:users, type: :binary), null: false
  timestamps()
end

Why NULID over ULID?

Feature ULID NULID
Total Bits 128 128
String Length 26 chars 26 chars
Timestamp Bits 48 (milliseconds) 68 (nanoseconds)
Randomness Bits 80 60
Time Precision 1 millisecond 1 nanosecond
Lifespan Until 10889 AD Until ~11326 AD

NULID trades 20 bits of randomness for 20 extra bits of timestamp precision, giving nanosecond-level ordering while still providing 1.15 quintillion unique IDs per nanosecond.

Development

# Fetch dependencies
make deps

# Run all CI checks (format, clippy, test)
make ci

# Run tests only
make test

# Format code (Elixir + Rust)
make fmt

# Show all available targets
make help

License

MIT - see LICENSE for details.