The project is in a healthy, maintained state
has_public_record gives a model a random prefixed public id that grows in length as the table fills.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

has_public_record

Short, prefixed, URL-safe public ids for ActiveRecord models: usr_K3F9A2. The prefix says what the id belongs to, the random part is short, and it grows by itself as a table fills.

class User < ApplicationRecord
  has_public_record prefix: "usr"
end

user = User.create!
user.public_id        # => "usr_K3F9A2"
user.public_id_bare   # => "K3F9A2"
user_path(user)       # => "/users/usr_K3F9A2"   (to_param)
User.find_by_public_id!("usr_K3F9A2")

Requires Ruby 3.2+ and Rails 7.1+.

Why prefixed, and why not the database id

Database ids leak counts and invite enumeration. A prefixed id is safe to put in URLs and logs, and the prefix lets you resolve an id with no other context: HasPublicRecord.resolve("usr_K3F9A2") returns the User.

Options

has_public_record prefix: "usr",          # required, unique across the app
                  column: :public_id,     # where the id is stored
                  length: 8,              # starting length of the random part for this model
                  max_bumps: 4            # how far the length may grow before ExhaustedError

A prefix is 1 to 10 characters: lower-case letters and digits only, starting with a lower-case letter ("usr" and "inv2" are fine; "Usr", "2fa" and "a_b" raise InvalidPrefixError). Two models cannot share a prefix (DuplicatePrefixError).

Declare it on the base class of an STI hierarchy, once. A subclass cannot declare it again, and an abstract class cannot declare it at all (it has no table). Declaring costs no database access, so it is safe while the app boots.

The column

The column is a string with a unique index. It uses a custom attribute type, so anything assigned or queried is normalized: User.new(public_id: "usr_k3f9a2") stores usr_K3F9A2, and User.where(public_id: "k3f9a2") finds it. Reading a row returns exactly what is stored, so a legacy bare code stays visible as bare until you migrate it. Raw SQL (update_all with a string, execute) bypasses the type, and insert_all may or may not use it depending on your Rails version, so do not rely on either.

The id is read-only once created (regenerate_public_id! is the one way to replace it). Ids you supply on create must start with the model's prefix and use only alphabet characters.

Reference

On the model:

Method What it does
find_by_public_id(ref) / find_by_public_id!(ref) Finds by usr_K3F9A2 or the bare K3F9A2, in any letter case. Another model's id or junk gives nil (or raises RecordNotFound)
from_param(param) / from_param!(param) The same, named for what a controller has: User.from_param!(params[:id])
with_public_id(*refs) A relation for several ids; ids of other models are ignored
public_id?(ref) Whether the ref belongs to this model
normalize_public_id(ref) The stored form of a ref ("usr_K3F9A2"), or nil. The alphabet is not checked, so old ids stay findable
public_id_prefix, public_id_column What the model declared
public_id_format A regexp for route constraints: resources :users, constraints: { id: User.public_id_format }
missing_public_id Rows with a NULL or empty id
generate_public_id One fresh unused id
generate_public_ids(count, reserved: []) count distinct fresh ids with one existence query. reserved are ids to avoid that are not in the table yet
backfill_public_ids!(batch_size: 1000) Gives every row without an id one; one query and one transaction per batch; returns the count
with_generated_public_ids(rows) Fills the id into rows for insert_all / upsert_all, keeping ids you supplied
fixture_public_id(label) A stable id for a fixture label

On a record:

Method What it does
public_id, public_id_bare, public_id_prefix The id, the id without its prefix, the prefix
to_param The public id once saved (so user_path(user) uses it), nil before
regenerate_public_id! Replaces the id (a leaked link, a rotated invite); every old URL stops working. Returns the new id

On the module:

Method What it does
HasPublicRecord.resolve(ref) / resolve! The record a ref points at, or nil / raises RecordNotFound
HasPublicRecord.resolve_many(*refs) Mixed refs in one query per model; returns { ref => record }
HasPublicRecord.model_for(ref) The model class for a ref
HasPublicRecord.parse(ref) Parsed(prefix:, bare:) or nil
HasPublicRecord.valid?(ref) Whether the ref is shaped like an id (any prefix)
HasPublicRecord.well_formed?(ref, prefix) Whether the ref is an id that prefix could have issued, with the current alphabet
HasPublicRecord.canonicalize(ref, prefix) The stored form of a ref for a prefix, or nil
HasPublicRecord.fold_case(text) Folds to the alphabet's letter case (or leaves a mixed-case alphabet alone)
HasPublicRecord.prefixes / prefix_for(model) { "usr" => "User", ... } / one model's prefix
HasPublicRecord.generate(prefix = nil, ...) { } Makes an id without a model
HasPublicRecord.identifier(prefix = nil, ...) { } A reusable generator without a model
HasPublicRecord.fixture_id(prefix, label, length:) A stable id for a label, for fixtures and seeds
HasPublicRecord.configure { } / config Settings
HasPublicRecord.reset_registry! / reset! Forget declared prefixes / everything (for tests; Rails calls the first on code reload in development)

Lookup

Ids are matched in any letter case (usr_k3f9a2 finds usr_K3F9A2) when the alphabet has one case; an alphabet with both cases is left as typed. Lookups do not check the alphabet, so ids made under an older alphabet stay findable. public_id_format follows the current alphabet: when you change the alphabet, widen it.

resolve_many is for a pile of mixed refs (an audit log, polymorphic references). It groups refs by prefix and runs one query per model, and refs that match nothing are left out of the result:

HasPublicRecord.resolve_many("usr_K3F9A2", "ses_7QX2B1", "zzz_nope")
# => { "usr_K3F9A2" => #<User>, "ses_7QX2B1" => #<Session> }

Length growth

Ids start at default_length (6 by default, about 2 billion codes). When random tries keep hitting existing codes, the length grows by one and is remembered per table and column for the life of the process. It never grows past the starting length plus max_bumps, so a long outage cannot ratchet it up for good. Each growth is announced (see Notifications), so you can see a table getting dense before it matters.

Two inserts racing on the same random code raise ActiveRecord::RecordNotUnique from the unique index. That is very rare (it needs the same code from two writers in the same moment), so there is no built-in retry; rescue and retry in your own code if your traffic makes it worth it.

Configuration

# config/initializers/has_public_record.rb  (rails g has_public_record:install writes it, all commented out)
HasPublicRecord.configure do |config|
  config.default_length = 6
  config.attempts_per_length = 10
  config.max_length_bumps = 4
  config.alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"   # 2+ distinct, no "_", no whitespace; or :unambiguous
  config.lengths = { "users" => 8 }                          # per table; beats a model's length:
end

Settings are read when an id is made, never copied at load, so an app that keeps its settings elsewhere can assign them from its own boot code. config.alphabet = :unambiguous leaves out 0/O and 1/I/L, which makes ids easier to read aloud. Prefixes are code, not settings.

Adding it to an existing table

Two steps, so a populated table never needs a NOT NULL column it cannot satisfy:

rails g has_public_record:migration users              # nullable column + unique index
rails db:migrate
rails has_public_record:backfill MODEL=User            # gives every row an id
rails has_public_record:report                         # all clean?
rails g has_public_record:migration users --enforce    # now NOT NULL
rails db:migrate

Options: --column=name, and --concurrently (builds the index without locking writes on PostgreSQL; other databases build it normally). For new tables, t.string :public_id, null: false, index: { unique: true } is enough.

On a very large PostgreSQL table, change_column_null scans the table under a lock. The lock-light route (PostgreSQL 12+) is a CHECK constraint added unvalidated and then validated, after which NOT NULL needs no scan; the --enforce migration carries the lines as a comment.

Rake tasks

MODEL= accepts User, user or users.

Task What it does
has_public_record:backfill Fills missing ids (MODEL=User to limit)
has_public_record:report Counts missing ids, wrong prefixes and duplicates, and shows how full each table's code space is; fails if anything is off
has_public_record:rename MODEL=User FROM=old Previews moving stored ids from an old prefix to the declared one; add APPLY=1 to do it, in one transaction

Renaming a prefix breaks every stored URL and any id saved in another table.

Ids without a model

The generator needs no model. Whether a code is free is a block. The examples use things that are not models with has_public_record (a model that declares a prefix cannot also be used as a standalone prefix):

HasPublicRecord.generate("key", length: 10) { |id| !Redis.exists?(id) }        # => "key_7K2QX9ABCD"
HasPublicRecord.generate(nil, length: 5, key: "codes") { |id| !Redis.exists?(id) }  # bare code: key names its growth

coupons = HasPublicRecord.identifier("cpn", length: 8) { |id| !Coupon.exists?(code: id) }
coupons.generate                  # grows and notifies exactly like models do
coupons.valid?("cpn_7K2QX9AB")    # => true
coupons.bare("cpn_7K2QX9AB")      # => "7K2QX9AB"

Standalone prefixes are not registered for HasPublicRecord.resolve; only models are.

Testing

Rails fixtures and insert_all skip callbacks, so rows get no id unless you supply one:

# fixtures: the same label always gives the same id
alice:
  public_id: <%= User.fixture_public_id(:alice) %>

# bulk inserts: one existence query for the whole batch
User.insert_all(User.with_generated_public_ids(rows))

backfill_public_ids! is the after-the-fact fix for rows that were inserted without an id.

Associations

Plain Rails associations are enough. Keep integer foreign keys inside the system and use the public id only at the edge, scoped through the owner so one person cannot reach another's records:

current_user.sessions.find_by_public_id!(params[:id])

Notifications

Through ActiveSupport::Notifications, never a logger of its own:

Event Payload
grow.has_public_record key, table, column (both nil for a standalone generator), from, to
rename.has_public_record table, column, from, to, count (rows actually changed)
regenerate.has_public_record table, column, from, to

Engines

Works inside an engine. Prefixes are global per process, so choose distinctive ones. An engine should not call HasPublicRecord.configure (it is process-wide); use the per-model length: and max_bumps: instead.

License

MIT.