diff --git a/VERSION b/VERSION
index f9d9d8c4..11e801e0 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-20250208
+20250209
diff --git a/database/2025_02_09_005714_created_user_name_history_table.php b/database/2025_02_09_005714_created_user_name_history_table.php
new file mode 100644
index 00000000..b7f6a922
--- /dev/null
+++ b/database/2025_02_09_005714_created_user_name_history_table.php
@@ -0,0 +1,28 @@
+<?php
+use Index\Db\DbConnection;
+use Index\Db\Migration\DbMigration;
+
+final class CreatedUserNameHistoryTable_20250209_005714 implements DbMigration {
+    public function migrate(DbConnection $conn): void {
+        $conn->execute(<<<SQL
+            CREATE TABLE msz_users_names_history (
+                history_id          INT(10) UNSIGNED    NOT NULL AUTO_INCREMENT,
+                user_id             INT(10) UNSIGNED        NULL DEFAULT NULL,
+                history_name_before VARCHAR(255)        NOT NULL COLLATE 'utf8mb4_unicode_520_ci',
+                history_name_after  VARCHAR(255)        NOT NULL COLLATE 'utf8mb4_unicode_520_ci',
+                history_private     TINYINT(3) UNSIGNED NOT NULL DEFAULT '0',
+                history_created     TIMESTAMP           NOT NULL DEFAULT current_timestamp(),
+                PRIMARY KEY (history_id),
+                INDEX users_names_history_users_foreign (user_id),
+                INDEX users_names_history_name_before_index (history_name_before),
+                INDEX users_names_history_created_index (history_created),
+                INDEX users_names_history_private_index (history_private),
+                CONSTRAINT users_names_history_users_foreign
+                    FOREIGN KEY (user_id)
+                    REFERENCES msz_users (user_id)
+                    ON UPDATE CASCADE
+                    ON DELETE SET NULL
+            ) COLLATE='utf8mb4_bin';
+        SQL);
+    }
+}
diff --git a/public-legacy/profile.php b/public-legacy/profile.php
index 9a940d1d..2d5ee719 100644
--- a/public-legacy/profile.php
+++ b/public-legacy/profile.php
@@ -26,6 +26,12 @@ $viewerId = $viewingAsGuest ? '0' : $viewerInfo->id;
 try {
     $userInfo = $msz->usersCtx->getUserInfo($userId, 'profile');
 } catch(RuntimeException $ex) {
+    $userId = $msz->usersCtx->namesHistory->resolvePastUserName($userId);
+    if($userId !== null) {
+        header(sprintf('Location: %s', $msz->urls->format('user-profile', ['user' => $userId])));
+        return;
+    }
+
     http_response_code(404);
     Template::render('profile.index', [
         'profile_is_guest' => $viewingAsGuest,
diff --git a/src/Users/UserNameHistoryInfo.php b/src/Users/UserNameHistoryInfo.php
new file mode 100644
index 00000000..4fea1c75
--- /dev/null
+++ b/src/Users/UserNameHistoryInfo.php
@@ -0,0 +1,31 @@
+<?php
+namespace Misuzu\Users;
+
+use Carbon\CarbonImmutable;
+use Index\Db\DbResult;
+
+class UserNameHistoryInfo {
+    public function __construct(
+        public private(set) string $id,
+        public private(set) string $userId,
+        public private(set) string $nameBefore,
+        public private(set) string $nameAfter,
+        public private(set) bool $private,
+        public private(set) int $createdTime
+    ) {}
+
+    public static function fromResult(DbResult $result): UserPasswordInfo {
+        return new UserPasswordInfo(
+            id: $result->getString(0),
+            userId: $result->getString(1),
+            nameBefore: $result->getString(2),
+            nameAfter: $result->getString(3),
+            private: $result->getBoolean(4),
+            createdTime: $result->getInteger(5),
+        );
+    }
+
+    public CarbonImmutable $createdAt {
+        get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
+    }
+}
diff --git a/src/Users/UserNamesHistoryData.php b/src/Users/UserNamesHistoryData.php
new file mode 100644
index 00000000..04c3b8db
--- /dev/null
+++ b/src/Users/UserNamesHistoryData.php
@@ -0,0 +1,139 @@
+<?php
+namespace Misuzu\Users;
+
+use RuntimeException;
+use Index\Db\{DbConnection,DbStatementCache};
+
+class UserNamesHistoryData {
+    private DbStatementCache $cache;
+
+    public function __construct(DbConnection $dbConn) {
+        $this->cache = new DbStatementCache($dbConn);
+    }
+
+    /** @return iterable<UserNameHistoryInfo> */
+    public function getUserNamesHistory(
+        UserInfo|string|null $userInfo = null,
+        ?bool $private = null
+    ): iterable {
+        $hasUserInfo = $userInfo !== null;
+
+        $args = 0;
+        $query = <<<SQL
+            SELECT history_id, user_id, history_name_before, history_name_after, history_private, UNIX_TIMESTAMP(history_created)
+            FROM msz_users_names_history
+        SQL;
+        if($hasUserInfo) {
+            ++$args;
+            $query .= ' WHERE user_id = ?';
+        }
+        if($private !== null)
+            $query .= sprintf(' %s history_private %s 0', ++$args > 1 ? 'AND' : 'WHERE', $private ? '<>' : '=');
+
+        $stmt = $this->cache->get($query);
+        if($hasUserInfo)
+            $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
+        $stmt->execute();
+
+        return $stmt->getResultIterator(UserNameHistoryInfo::fromResult(...));
+    }
+
+    public function getUserNameHistory(string $historyId): UserNameHistoryInfo {
+        $stmt = $this->cache->get(<<<SQL
+            SELECT history_id, user_id, history_name_before, history_name_after, history_private, UNIX_TIMESTAMP(history_created)
+            FROM msz_users_names_history
+            WHERE history_id = ?
+        SQL);
+        $stmt->nextParameter($historyId);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$result->next())
+            throw new RuntimeException('could not find that name history entry');
+
+        return UserNameHistoryInfo::fromResult($result);
+    }
+
+    public function deleteUserNameHistory(UserNameHistoryInfo|string $historyInfo): void {
+        $stmt = $this->cache->get(<<<SQL
+            DELETE FROM msz_users_names_history
+            WHERE history_id = ?
+        SQL);
+        $stmt->nextParameter($historyInfo instanceof UserNameHistoryInfo ? $historyInfo->id : $historyInfo);
+        $stmt->execute();
+    }
+
+    public function deleteUserNamesHistory(UserInfo|string $userInfo): void {
+        $stmt = $this->cache->get(<<<SQL
+            DELETE FROM msz_users_names_history
+            WHERE user_id = ?
+        SQL);
+        $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
+        $stmt->execute();
+    }
+
+    public function setUserNameHistoryVisibility(
+        UserNameHistoryInfo|string $historyInfo,
+        bool $private
+    ): void {
+        $stmt = $this->cache->get(<<<SQL
+            UPDATE msz_users_names_history
+            SET history_private = ?
+            WHERE history_id = ?
+        SQL);
+        $stmt->nextParameter($private ? 1 : 0);
+        $stmt->nextParameter($historyInfo instanceof UserNameHistoryInfo ? $historyInfo->id : $historyInfo);
+        $stmt->execute();
+    }
+
+    // this should probably let you specify a time period
+    public function setUserNamesHistoryVisibility(
+        UserInfo|string $userInfo,
+        bool $private
+    ): void {
+        $stmt = $this->cache->get(<<<SQL
+            UPDATE msz_users_names_history
+            SET history_private = ?
+            WHERE user_id = ?
+        SQL);
+        $stmt->nextParameter($private ? 1 : 0);
+        $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
+        $stmt->execute();
+    }
+
+    public function recordUserNameChange(
+        UserInfo|string $userInfo,
+        string $nameBefore,
+        string $nameAfter,
+        bool $private = false
+    ): UserNameHistoryInfo {
+        $stmt = $this->cache->get(<<<SQL
+            INSERT INTO msz_users_names_history (
+                user_id, history_name_before, history_name_after, history_private
+            ) VALUES (?, ?, ?, ?)
+        SQL);
+        $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
+        $stmt->nextParameter($nameBefore);
+        $stmt->nextParameter($nameAfter);
+        $stmt->nextParameter($private ? 1 : 0);
+        $stmt->execute();
+
+        return $this->getUserNameHistory($stmt->lastInsertId);
+    }
+
+    public function resolvePastUserName(string $pastName): ?string {
+        $stmt = $this->cache->get(<<<SQL
+            SELECT IF(history_private, NULL, user_id)
+            FROM msz_users_names_history
+            WHERE user_id IS NOT NULL
+            AND history_name_before = ?
+            ORDER BY history_created DESC
+            LIMIT 1
+        SQL);
+        $stmt->nextParameter($pastName);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        return $result->next() ? $result->getStringOrNull(0) : null;
+    }
+}
diff --git a/src/Users/UsersContext.php b/src/Users/UsersContext.php
index a0cb4071..82ffdeb4 100644
--- a/src/Users/UsersContext.php
+++ b/src/Users/UsersContext.php
@@ -13,6 +13,7 @@ class UsersContext {
     public private(set) UserPasswordsData $passwords;
     public private(set) UserTotpsData $totps;
     public private(set) UserBirthdatesData $birthdates;
+    public private(set) UserNamesHistoryData $namesHistory;
 
     /** @var array<string, UserInfo> */
     private array $userInfos = [];
@@ -35,6 +36,7 @@ class UsersContext {
         $this->passwords = new UserPasswordsData($dbConn);
         $this->totps = new UserTotpsData($dbConn);
         $this->birthdates = new UserBirthdatesData($dbConn);
+        $this->namesHistory = new UserNamesHistoryData($dbConn);
     }
 
     public function getUserInfo(string $value, int|string|null $select = null): UserInfo {