and explicit architecture over convenience.
Architecture Decisions & Rationale
- Concern-Based Injection: Primary key generation logic should be isolated from business models. A concern allows consistent behavior across
Shipment, Invoice, and User records without polluting base classes.
before_create vs before_validation: Using before_validation risks regenerating the identifier during updates or failed validations. before_create guarantees the ID is assigned exactly once, at insertion time.
- String Storage Over Binary: While 16-byte binary storage reduces disk footprint, string representation (
char(26)) simplifies debugging, API serialization, and log correlation. The storage overhead is negligible for most applications, and modern SSDs handle the extra bytes without measurable latency.
- Explicit Primary Key Declaration: Rails defaults to integer auto-increment. Disabling it and explicitly marking the string column as
primary_key: true prevents ActiveRecord from attempting to cast or sequence the column.
Implementation Steps
Step 1: Add the Generator Dependency
The ulid gem provides a CSPRNG-backed generator compliant with the ULID specification. Add it to your dependency manifest:
# Gemfile
gem "ulid", "~> 0.4"
Execute bundle install to resolve the dependency.
Step 2: Build the Identifier Concern
Create a dedicated module that handles generation and assignment. This version includes defensive checks and allows future extension (e.g., adding process ID mixing for extreme concurrency).
# app/models/concerns/chronological_identifier.rb
module ChronologicalIdentifier
extend ActiveSupport::Concern
included do
before_create :assign_chronological_id
end
private
def assign_chronological_id
return unless id.nil?
self.id = ULID.generate
end
end
Step 3: Define the Migration
Disable automatic ID generation and declare the string column as the primary key. This migration creates an InventoryItem model with a ULID-backed identifier.
# db/migrate/20240615120000_create_inventory_items.rb
class CreateInventoryItems < ActiveRecord::Migration[8.0]
def change
create_table :inventory_items, id: false do |t|
t.string :id, limit: 26, primary_key: true, null: false
t.string :sku, null: false
t.integer :quantity, default: 0, null: false
t.timestamps
end
add_index :inventory_items, :sku, unique: true
end
end
Step 4: Attach to the Model
Include the concern and verify ActiveRecord recognizes the string primary key.
# app/models/inventory_item.rb
class InventoryItem < ApplicationRecord
include ChronologicalIdentifier
validates :sku, presence: true, uniqueness: true
validates :quantity, numericality: { greater_than_or_equal_to: 0 }
end
Step 5: Validate in Console
Launch the Rails console and generate records to confirm chronological ordering and uniqueness.
InventoryItem.create!(sku: "HW-001", quantity: 50)
InventoryItem.create!(sku: "HW-002", quantity: 120)
InventoryItem.pluck(:id, :sku)
# => [["01J2X4N9...", "HW-001"], ["01J2X4N9...", "HW-002"]]
InventoryItem.order(:id).pluck(:sku)
# => ["HW-001", "HW-002"] (chronologically ordered)
The identifiers share a common prefix because they were generated within the same millisecond window. Sorting by id yields creation order without requiring a separate created_at index, reducing query complexity and storage overhead.
Pitfall Guide
1. Collation-Induced Sort Drift
Explanation: PostgreSQL defaults to locale-aware collation (e.g., en_US.UTF-8), which applies case-insensitive and accent-aware sorting rules. This breaks lexicographical ordering, causing ULIDs to sort incorrectly in ORDER BY clauses.
Fix: Enforce binary collation at the column level or query level:
ALTER TABLE inventory_items ALTER COLUMN id TYPE varchar(26) COLLATE "C";
Or use ORDER BY id COLLATE "C" in raw SQL. Rails migrations support this via collation: "C".
2. ID Regeneration on Updates
Explanation: Using before_validation or omitting the id.nil? guard causes the identifier to regenerate during update calls or failed validations, breaking foreign key references and audit trails.
Fix: Always scope generation to before_create and explicitly check return unless id.nil?. Never override id in before_save or before_update.
3. Assuming Cryptographic Security for Tokens
Explanation: ULIDs use a CSPRNG for the 80-bit random segment, but the 48-bit timestamp is predictable. They are unsuitable for session tokens, password resets, or API keys where unpredictability is mandatory.
Fix: Reserve ULIDs for primary keys and public identifiers. Use SecureRandom.urlsafe_base64 or dedicated token generators for security-sensitive contexts.
4. Index Bloat from String Primary Keys
Explanation: A 26-character string consumes more index space than a 16-byte UUID or 8-byte bigint. At scale, this increases memory pressure and slows index scans.
Fix: Accept the trade-off for readability, or store the ULID as bytea (PostgreSQL) / BINARY(16) (MySQL) and expose a generated/computed string column for APIs. Monitor pg_total_relation_size to validate impact.
5. Migration Locks on Existing Tables
Explanation: Adding a ULID column to a table with millions of rows triggers an ACCESS EXCLUSIVE lock, blocking reads and writes during backfill.
Fix: Use batch processing with find_in_batches, update in chunks, and leverage tools like pg_repack or gh-ost for zero-downtime schema changes. Never run synchronous UPDATE statements on production tables without pagination.
6. Concurrency Collision Assumptions
Explanation: ULIDs guarantee uniqueness within a millisecond using 80 bits of randomness. Generating >10,000 IDs per millisecond on a single process increases collision probability, though it remains statistically negligible.
Fix: For extreme write throughput (>1M inserts/sec), mix the process ID or thread ID into the random segment, or switch to a distributed generator like Snowflake. For 99% of Rails apps, the default generator is sufficient.
7. Foreign Key Type Mismatch
Explanation: Changing a primary key from bigint to string breaks existing associations if dependent models still expect integer foreign keys. ActiveRecord will raise type casting errors.
Fix: Update all belongs_to and has_many declarations to specify type: :string, or maintain a separate public_id column while keeping the internal id as bigint. Consistency across the schema is mandatory.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Public-facing API with sequential IDs | ULID | Hides business volume, maintains sortability, zero coordination | Low (storage overhead ~10 bytes/row) |
| High-write SaaS (>50k inserts/min) | ULID or Snowflake | Append-only indexing prevents B-Tree fragmentation; Snowflake if distributed coordination is acceptable | Medium (index memory increase) |
| Internal admin tool with low traffic | Auto-Increment | Simplicity outweighs obfuscation needs; minimal operational complexity | None |
| Security tokens / session IDs | SecureRandom or JWT | ULIDs are not cryptographically unpredictable; dedicated generators prevent enumeration | None |
| Legacy migration from bigint PK | Separate public_id column | Avoids foreign key breakage and allows gradual rollout without schema locks | Low (additional column + index) |
Configuration Template
# Gemfile
gem "ulid", "~> 0.4"
# app/models/concerns/chronological_identifier.rb
module ChronologicalIdentifier
extend ActiveSupport::Concern
included do
before_create :assign_chronological_id
end
private
def assign_chronological_id
return unless id.nil?
self.id = ULID.generate
end
end
# db/migrate/YYYYMMDDHHMMSS_create_table_name.rb
class CreateTable < ActiveRecord::Migration[8.0]
def change
create_table :table_name, id: false do |t|
t.string :id, limit: 26, primary_key: true, null: false, collation: "C"
t.string :name, null: false
t.timestamps
end
end
end
# app/models/table_name.rb
class TableName < ApplicationRecord
include ChronologicalIdentifier
end
Quick Start Guide
- Add Dependency: Run
bundle add ulid to install the generator gem.
- Generate Migration: Execute
rails g migration CreateRecords name:string, then edit the migration to disable auto-increment and declare the string primary key with collation: "C".
- Create Concern: Save the
ChronologicalIdentifier module to app/models/concerns/.
- Attach & Migrate: Include the concern in your model, run
rails db:migrate, and verify ordering with Record.order(:id).pluck(:id).
- Validate: Run
rails test or rspec to confirm idempotent generation and chronological sorting under concurrent inserts.
ULIDs resolve the fundamental tension between API security and database performance. By embedding temporal ordering into the identifier itself, you eliminate index fragmentation, simplify chronological queries, and remove the need for distributed coordination. Implement them deliberately, enforce collation rules, and monitor storage trade-offs, and they will serve as a reliable foundation for scalable Rails applications.