Skip to content

ULID Database Storage Guide

Optimizing ULID storage for maximum database performance in XOOPS 4.0.

This guide covers storage strategies, indexing considerations, and migration patterns for using ULIDs as primary keys.


flowchart TB
subgraph Storage["Storage Options"]
CHAR["CHAR(26)<br/>26 bytes<br/>Human-readable"]
BINARY["BINARY(16)<br/>16 bytes<br/>Compact"]
VARCHAR["VARCHAR(26)<br/>27+ bytes<br/>Variable"]
end
subgraph Tradeoffs["Trade-offs"]
READ[Readability]
SIZE[Storage Size]
PERF[Index Performance]
end
CHAR -->|Best| READ
BINARY -->|Best| SIZE
CHAR -->|Good| PERF
BINARY -->|Best| PERF
Storage TypeSizeIndex PerformanceReadabilityRecommended Use
CHAR(26)26 bytesExcellent✓ DirectPrimary choice
BINARY(16)16 bytesBest✗ Needs conversionHigh-volume tables
VARCHAR(26)27+ bytesGood✓ DirectNot recommended

For most XOOPS modules, CHAR(26) provides the best balance of performance and usability.

CREATE TABLE `xoops_vision2026_articles` (
`id` CHAR(26) NOT NULL,
`author_id` CHAR(26) NOT NULL,
`category_id` CHAR(26) NOT NULL,
`title` VARCHAR(255) NOT NULL,
`slug` VARCHAR(100) NOT NULL,
`content` LONGTEXT NOT NULL,
`status` ENUM('draft', 'published', 'archived') NOT NULL DEFAULT 'draft',
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NULL,
`published_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_slug` (`slug`),
KEY `idx_author` (`author_id`),
KEY `idx_category` (`category_id`),
KEY `idx_status_published` (`status`, `published_at`),
CONSTRAINT `fk_article_author`
FOREIGN KEY (`author_id`) REFERENCES `xoops_vision2026_authors`(`id`)
ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT `fk_article_category`
FOREIGN KEY (`category_id`) REFERENCES `xoops_vision2026_categories`(`id`)
ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
  1. Fixed Length: No overhead for length storage (unlike VARCHAR)
  2. Sortable: ULIDs sort correctly with string comparison
  3. Readable: Can be queried directly without conversion
  4. Good Performance: Clustered index benefits from sequential inserts

Use case-insensitive collation for maximum compatibility:

`id` CHAR(26) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL

Using ASCII charset saves space (1 byte per char vs 4 for utf8mb4).


For tables with millions of rows, BINARY(16) provides optimal storage and index performance.

CREATE TABLE `xoops_vision2026_articles` (
`id` BINARY(16) NOT NULL,
`author_id` BINARY(16) NOT NULL,
`category_id` BINARY(16) NOT NULL,
-- ... other columns
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Create helper functions for conversion:

-- Convert ULID string to BINARY
DELIMITER //
CREATE FUNCTION `ulid_to_binary`(ulid_string CHAR(26))
RETURNS BINARY(16) DETERMINISTIC
BEGIN
-- Crockford Base32 decoding
DECLARE result BINARY(16);
-- Implementation would decode Base32 to binary
-- For simplicity, use application-level conversion
RETURN result;
END //
-- Convert BINARY to ULID string
CREATE FUNCTION `binary_to_ulid`(ulid_binary BINARY(16))
RETURNS CHAR(26) DETERMINISTIC
BEGIN
DECLARE result CHAR(26);
-- Implementation would encode binary to Base32
RETURN result;
END //
DELIMITER ;

Handle conversion in the repository layer:

<?php
final class MySQLArticleRepository implements ArticleRepository
{
public function find(ArticleId $id): ?Article
{
$stmt = $this->pdo->prepare(
'SELECT * FROM articles WHERE id = :id'
);
// Convert ULID to binary for query
$stmt->execute([
'id' => $this->ulidToBinary($id),
]);
$row = $stmt->fetch();
if (!$row) {
return null;
}
// Convert binary back to ULID
$row['id'] = $this->binaryToUlid($row['id']);
return $this->hydrate($row);
}
private function ulidToBinary(ArticleId $id): string
{
$ulid = Ulid::fromString($id->toString());
return $ulid->toBinary();
}
private function binaryToUlid(string $binary): string
{
return Ulid::fromBinary($binary)->toString();
}
}

flowchart LR
subgraph Insert["Insert Performance"]
AI_I[Auto-increment<br/>★★★★★]
ULID_I[ULID<br/>★★★★☆]
UUID_I[UUID v4<br/>★★☆☆☆]
end
subgraph Select["Select by ID"]
AI_S[Auto-increment<br/>★★★★★]
ULID_S[ULID CHAR<br/>★★★★☆]
UUID_S[UUID<br/>★★★☆☆]
end
subgraph Range["Range Queries"]
AI_R[Auto-increment<br/>★★★★★]
ULID_R[ULID<br/>★★★★☆]
UUID_R[UUID v4<br/>★☆☆☆☆]
end

UUID v4 Index Fragmentation:

Insert order: Random across entire B-tree
Page splits: Frequent
Cache efficiency: Poor

ULID Index Behavior:

Insert order: Mostly sequential (time-ordered)
Page splits: Minimal (append-like)
Cache efficiency: Good (recent IDs clustered)
OperationAuto-IncULID CHAR(26)ULID BINARY(16)UUID v4
Insert (rows/sec)15,00012,00013,5008,000
Select by ID0.1ms0.12ms0.11ms0.15ms
Range scan (1000)2ms2.5ms2.2ms8ms
Index size16MB42MB26MB58MB

Phase 1: Add ULID Column

-- Add new ULID column
ALTER TABLE `articles`
ADD COLUMN `ulid` CHAR(26) NULL AFTER `id`;
-- Create index for the new column
ALTER TABLE `articles`
ADD UNIQUE KEY `idx_ulid` (`ulid`);

Phase 2: Backfill ULIDs

<?php
// Backfill script
$batchSize = 1000;
$offset = 0;
do {
$stmt = $pdo->prepare(
'SELECT id FROM articles WHERE ulid IS NULL LIMIT :limit OFFSET :offset'
);
$stmt->execute(['limit' => $batchSize, 'offset' => $offset]);
$rows = $stmt->fetchAll();
foreach ($rows as $row) {
$ulid = Ulid::generate();
$update = $pdo->prepare(
'UPDATE articles SET ulid = :ulid WHERE id = :id'
);
$update->execute([
'ulid' => $ulid->toString(),
'id' => $row['id'],
]);
}
$offset += $batchSize;
} while (count($rows) === $batchSize);

Phase 3: Switch Primary Key

-- Make ULID NOT NULL
ALTER TABLE `articles`
MODIFY COLUMN `ulid` CHAR(26) NOT NULL;
-- Update foreign keys in related tables
ALTER TABLE `comments`
ADD COLUMN `article_ulid` CHAR(26) NULL;
-- Backfill foreign keys...
-- Drop old primary key, set new one
ALTER TABLE `articles`
DROP PRIMARY KEY,
ADD PRIMARY KEY (`ulid`),
DROP COLUMN `id`;
-- Rename column
ALTER TABLE `articles`
CHANGE COLUMN `ulid` `id` CHAR(26) NOT NULL;

Since both are string-based, migration is simpler:

-- UUIDs are 36 chars, ULIDs are 26
-- Create new column
ALTER TABLE `articles`
ADD COLUMN `ulid` CHAR(26) NULL;
-- Generate ULIDs for existing records (preserving creation order)
-- Use created_at timestamp to generate time-appropriate ULIDs
-- In PHP:
$rows = $pdo->query('SELECT uuid, created_at FROM articles ORDER BY created_at');
foreach ($rows as $row) {
$timestamp = new DateTimeImmutable($row['created_at']);
$ulid = Ulid::generate($timestamp);
$pdo->prepare('UPDATE articles SET ulid = ? WHERE uuid = ?')
->execute([$ulid->toString(), $row['uuid']]);
}

<?php
namespace Xoops\Vision2026\Infrastructure;
use XoopsObject;
use Xmf\Ulid;
class ArticleObject extends XoopsObject
{
public function __construct()
{
$this->initVar('id', XOBJ_DTYPE_TXTBOX, null, true, 26);
$this->initVar('author_id', XOBJ_DTYPE_TXTBOX, null, true, 26);
$this->initVar('title', XOBJ_DTYPE_TXTBOX, '', true, 255);
// ... other fields
}
/**
* Generate new ULID before insert
*/
public function setNew(): void
{
parent::setNew();
if (empty($this->getVar('id'))) {
$this->setVar('id', Ulid::generate()->toString());
}
}
}
<?php
class ArticleHandler extends XoopsPersistableObjectHandler
{
public function __construct($db)
{
parent::__construct(
$db,
'vision2026_articles',
ArticleObject::class,
'id', // Primary key field
'title' // Identifier field
);
}
/**
* Override insert to ensure ULID is set
*/
public function insert($object, $force = true): mixed
{
if ($object->isNew() && empty($object->getVar('id'))) {
$object->setVar('id', Ulid::generate()->toString());
}
return parent::insert($object, $force);
}
/**
* Get by ULID string
*/
public function getByUlid(string $ulid): ?ArticleObject
{
if (!Ulid::isValid($ulid)) {
return null;
}
return $this->get($ulid);
}
}
<?php
// xoops_version.php or module.json schema
$modversion['tables'] = [
'vision2026_articles' => [
'columns' => [
'id' => [
'type' => 'CHAR',
'length' => 26,
'null' => false,
'primary' => true,
],
'author_id' => [
'type' => 'CHAR',
'length' => 26,
'null' => false,
'index' => true,
],
// ...
],
],
];

// Good: Application generates ULID
$article = Article::create(
ArticleId::generate(),
$title,
$content,
$authorId,
$categoryId,
);
// Bad: Database generates ID
// ULIDs should not be generated in triggers/stored procedures
// Good: Type-safe
public function find(ArticleId $id): ?Article;
// Bad: Stringly-typed
public function find(string $id): ?Article;
-- Always index ULID foreign keys
KEY `idx_author` (`author_id`),
KEY `idx_category` (`category_id`),

4. Use Composite Indexes for Common Queries

Section titled “4. Use Composite Indexes for Common Queries”
-- For queries like: WHERE status = 'published' ORDER BY published_at DESC
KEY `idx_status_published` (`status`, `published_at` DESC),
-- For queries like: WHERE author_id = ? ORDER BY created_at DESC
KEY `idx_author_created` (`author_id`, `created_at` DESC),

5. Consider Partitioning for Very Large Tables

Section titled “5. Consider Partitioning for Very Large Tables”
-- Partition by time range (extracted from ULID)
CREATE TABLE `articles` (
`id` CHAR(26) NOT NULL,
`created_at` DATETIME NOT NULL,
-- ...
PRIMARY KEY (`id`, `created_at`)
) PARTITION BY RANGE (YEAR(created_at)) (
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026),
PARTITION p2026 VALUES LESS THAN (2027),
PARTITION pmax VALUES LESS THAN MAXVALUE
);

Symptom: Insert performance degrades over time

Cause: Index fragmentation (unlikely with ULIDs, but possible)

Solution:

OPTIMIZE TABLE articles;
-- or
ALTER TABLE articles ENGINE=InnoDB;

Symptom: CHAR(26) indexes are too large

Solution: Switch to BINARY(16) for high-volume tables

Symptom: Queries return no results for valid ULIDs

Solution: Use case-insensitive collation:

ALTER TABLE articles
MODIFY id CHAR(26) COLLATE ascii_general_ci;

  • XMF Components
  • XMF Reference Implementations
  • Repository Layer
  • Schema Design Best Practices

#ulid #database #mysql #performance #indexing #migration