Code-First Migrations

Altruist automatically keeps your database schema in sync with your C# vault models. When you add or rename a [VaultColumn] property, the framework detects the difference and applies the change — no manual SQL, no migration files, no CLI commands. Destructive changes (dropping columns or tables) only happen when you ask for them explicitly.

Loading diagram...

How It Works

On every startup, Altruist:

  1. Inspects the current database schema (tables, columns, indexes, constraints, foreign keys)
  2. Reads your [Vault] model classes to determine the desired schema
  3. Diffs current vs desired and generates migration operations
  4. Executes the operations (CREATE TABLE, ADD COLUMN, RENAME COLUMN, etc.)

Note:

This happens automatically on startup. You don't run a CLI command, create migration files, or write SQL. Change your C# model, restart the server, and the database updates itself.

Supported Operations

OperationWhen it runs
Create tableNew [Vault] model added
Add columnNew [VaultColumn] property added to existing model
Keep column (warning)[VaultColumn] property removed from model — the column and its data stay; see Removed Columns
Relax NOT NULLA kept column is NOT NULL without a default, so inserts would fail without it
Drop column[VaultDropColumn("name")] on the class, [VaultColumnDelete] on a property, or drop-unmapped-columns: true
Rename column[VaultRenamedFrom("old_name")] on property
Change column typeProperty CLR type changed (e.g., int → long)
Add unique constraint[VaultUniqueKey] added
Drop unique constraint[VaultUniqueKey] removed
Create index[VaultColumnIndex] added, or [VaultSortingBy] set
Drop indexIndex-producing attribute removed
Copy column data[VaultColumnCopy("source")] on new property
Delete column[VaultColumnDelete] on property (copy runs first)
Add foreign key[VaultForeignKey] added
Drop foreign key[VaultForeignKey] removed
Drop table[VaultTableDelete] on class
Archive + drop table[VaultArchived("archive_name")] on class
Create history table[Vault("name", StoreHistory = true)] set
Sync history columnsHistory table tracks main table column changes

Example: Adding a Feature

Start with a simple player model:

[Vault("players")]
[VaultPrimaryKey(nameof(StorageId))]
public class PlayerVault : VaultModel
{
    [VaultColumn] public override string StorageId { get; set; } = "";
    [VaultColumn] public string Name { get; set; } = "";
    [VaultColumn] public int Level { get; set; } = 1;
}

First startup creates the players table with storageId, name, level columns.

Now add guild support:

[Vault("players")]
[VaultPrimaryKey(nameof(StorageId))]
public class PlayerVault : VaultModel
{
    [VaultColumn] public override string StorageId { get; set; } = "";
    [VaultColumn] public string Name { get; set; } = "";
    [VaultColumn] public int Level { get; set; } = 1;
    [VaultColumn] public int Gold { get; set; } = 0;                // NEW
    [VaultColumn(nullable: true)] public string? GuildId { get; set; }  // NEW
}

Next startup detects two new columns and runs:

ALTER TABLE altruist.players ADD COLUMN gold INTEGER NOT NULL DEFAULT 0;
ALTER TABLE altruist.players ADD COLUMN guildId TEXT;

No migration files. No commands. Just change the class and restart.

Note:

For Postgres, DEFAULT 0 is now real planner behavior, not just illustrative SQL. The migration planner reads CLR initializer/default property values from the vault model and emits matching SQL defaults during both create-table and add-column flows when that value can be mapped safely to a store default.

For example:

[VaultColumn] public int Gold { get; set; } = 0;
[VaultColumn] public bool IsOnline { get; set; } = false;
[VaultColumn] public string Region { get; set; } = "eu-west";

can generate Postgres defaults such as:

DEFAULT 0
DEFAULT false
DEFAULT 'eu-west'

This guarantee should currently be read as Postgres-specific. Do not assume every provider maps every CLR initializer to a database default unless that provider's planner explicitly supports it.

Foreign Keys

Use [VaultForeignKey] to define relationships. The migration system creates and manages foreign key constraints automatically:

[Vault("guilds")]
[VaultPrimaryKey(nameof(StorageId))]
public class GuildVault : VaultModel
{
    [VaultColumn] public override string StorageId { get; set; } = "";
    [VaultColumn] public string Name { get; set; } = "";
}

[Vault("players")]
[VaultPrimaryKey(nameof(StorageId))]
public class PlayerVault : VaultModel
{
    [VaultColumn] public override string StorageId { get; set; } = "";
    [VaultColumn] public string Name { get; set; } = "";

    [VaultColumn(nullable: true)]
    [VaultForeignKey(typeof(GuildVault), nameof(GuildVault.StorageId))]
    public string? GuildId { get; set; }
}

The framework:

  • Creates the guilds table first (dependency ordering via topological sort)
  • Creates the players table with a foreign key constraint on guildId referencing guilds.storageId
  • Default ON DELETE behavior is CASCADE — override with the third parameter:
[VaultForeignKey(typeof(GuildVault), nameof(GuildVault.StorageId), VaultForeignKeyDeleteBehavior.SetNull)]
public string? GuildId { get; set; }
Delete BehaviorSQLWhen to use
CascadeON DELETE CASCADEChild rows deleted with parent (default)
SetNullON DELETE SET NULLFK set to null when parent deleted
RestrictON DELETE RESTRICTPrevent deleting parent if children exist
NoActionON DELETE NO ACTIONSame as Restrict (Postgres default)
SetDefaultON DELETE SET DEFAULTFK set to column default

Note:

The migration planner resolves cross-schema foreign keys automatically. If PlayerVault is in keyspace game and GuildVault is in keyspace social, the FK correctly references social.guilds.storageId.

Unique Constraints

// Single column unique
[Vault("players")]
[VaultPrimaryKey(nameof(StorageId))]
public class PlayerVault : VaultModel
{
    [VaultColumn] public override string StorageId { get; set; } = "";
    [VaultColumn] [VaultColumnIndex] public string Name { get; set; } = "";  // indexed
}

// Composite unique (multiple columns together must be unique)
[Vault("inventory_slots")]
[VaultPrimaryKey(nameof(StorageId))]
[VaultUniqueKey(nameof(CharacterId), nameof(SlotIndex))]  // one item per slot
public class InventorySlotVault : VaultModel
{
    [VaultColumn] public override string StorageId { get; set; } = "";
    [VaultColumn] public string CharacterId { get; set; } = "";
    [VaultColumn] public int SlotIndex { get; set; }
    [VaultColumn] public string? ItemId { get; set; }
}

History Tables

Enable change tracking by setting StoreHistory = true. The framework creates a parallel _history table that records every version of a row:

[Vault("players", StoreHistory = true)]
[VaultPrimaryKey(nameof(StorageId))]
public class PlayerVault : VaultModel
{
    [VaultColumn] public override string StorageId { get; set; } = "";
    [VaultColumn] public string Name { get; set; } = "";
    [VaultColumn] public int Level { get; set; } = 1;
}

This creates both players and players_history. Every save operation writes to both tables. The history table has a composite primary key of (storageId, timestamp) so every version is preserved.

Note:

History tables mirror the main table's columns and automatically stay in sync during migrations. If you add a column to PlayerVault, the migration adds it to both players and players_history.

Keyspaces (Schemas)

Each vault model can target a different database schema via the Keyspace parameter:

[Vault("players", Keyspace = "game")]
public class PlayerVault : VaultModel { ... }

[Vault("guilds", Keyspace = "social")]
public class GuildVault : VaultModel { ... }

[Vault("transactions", Keyspace = "economy")]
public class TransactionVault : VaultModel { ... }

The migration system creates each schema if it doesn't exist and manages tables within their respective schemas independently.

Removed Columns

When a database column is no longer mapped by the model (you deleted the property, or renamed it without [VaultRenamedFrom]), the migration keeps the column and its data. On every startup it logs a warning naming the columns:

Migration: table altruist.players has column(s) no longer mapped by PlayerVault: legacy_score. They are kept (data preserved) ...

If a kept column is NOT NULL and has no default, inserts from the model would fail because they no longer supply a value. In that case the planner relaxes it instead of dropping it:

ALTER TABLE altruist.players ALTER COLUMN "legacy_score" DROP NOT NULL;

The same rule applies to history tables.

To actually drop a column, say so explicitly. Pick one of these:

Per column, property already removed: put [VaultDropColumn] on the class:

[Vault("players")]
[VaultDropColumn("legacy_score", "Replaced by Rating in v2.4")]
public class PlayerVault : VaultModel { ... }

Per column, property kept as history: use [VaultColumnDelete] on the property.

Every unmapped column, globally: this is the old behavior, so use it only when the models own the database exclusively:

altruist:
  persistence:
    migration:
      drop-unmapped-columns: true   # default false

Explicit drops run after any [VaultColumnCopy]. [VaultDropColumn] cannot name a column the model still maps; reading the model fails if it does.

Note:

Up to 0.9.9-beta every unmapped column was dropped automatically. That meant a rename without [VaultRenamedFrom] silently lost data. If you relied on that cleanup, add [VaultDropColumn] for the columns you want gone, or set drop-unmapped-columns: true.

Column Rename

Add [VaultRenamedFrom] to tell the planner the old column name. The framework issues ALTER TABLE ... RENAME COLUMN instead of drop + add — data is preserved:

[Vault("players")]
[VaultPrimaryKey(nameof(StorageId))]
public class PlayerVault : VaultModel
{
    [VaultColumn] public override string StorageId { get; set; } = "";
    [VaultColumn] public string Name { get; set; } = "";

    // Was "hp" in the database, now "health"
    [VaultColumn]
    [VaultRenamedFrom("hp")]
    public int Health { get; set; }
}

On next startup the planner detects the rename and runs:

ALTER TABLE altruist.players RENAME COLUMN "hp" TO "health";

This is a metadata-only operation in Postgres — instant, no data copy, safe for tables with millions of rows.

Note:

You can leave [VaultRenamedFrom] on the property permanently — it serves as documentation showing the column's history. If the old column no longer exists in the database, the framework ignores the attribute silently. Only the most recent valid rename (where the old column still exists in the database) is executed.

You can choose either style — both work:

Stack for full history (self-documenting code):

[VaultRenamedFrom("hp")]       // v1 → v2
[VaultRenamedFrom("health")]   // v2 → v3
public int HitPoints { get; set; }

Single attribute (minimal):

[VaultRenamedFrom("health")]   // only the latest rename
public int HitPoints { get; set; }

Stacked attributes are checked top-to-bottom — the first one matching an existing database column is executed, the rest are ignored.

Column Type Change

Type changes are auto-detected. When a property's C# type changes (e.g., int → long), the planner compares the desired store type against what's in the database and generates the appropriate migration.

// Was int (INTEGER in Postgres), now long (BIGINT)
[VaultColumn]
public long Gold { get; set; }

The framework handles two strategies automatically:

Same type family (e.g., int → bigint, varchar → text) — in-place ALTER COLUMN TYPE, no data copy:

ALTER TABLE altruist.players ALTER COLUMN "gold" TYPE BIGINT USING "gold"::BIGINT;

Cross-type (e.g., int → text) — use [VaultColumnCopy] + [VaultColumnDelete] to copy data to a new column with the desired type, then drop the old one. See Copy and Delete Columns below.

Type familyMembers
Integersmallint, integer, bigint
Floatreal, double precision
Texttext, varchar, char
Timestamptimestamp, timestamptz
Numericnumeric, decimal

Changes within a family are fast (in-place). Changes across families require [VaultColumnCopy].

Note:

Types are compared after folding Postgres catalog spellings onto the model's type map. For example, timestamp without time zone is treated as timestamp, timestamp with time zone as timestamptz, the array udt _int4 as integer[], and int4 as integer. An unchanged model therefore plans no operations on restart. Earlier versions re-altered every DateTime column (including created-at) on every boot, and failed outright on array columns.

Rename + Type Change Together

Both can happen in one migration. The planner processes renames first, then checks types on the renamed column:

// Was "hp" (INTEGER), now "health" (BIGINT)
[VaultColumn]
[VaultRenamedFrom("hp")]
public long Health { get; set; }

Generates two operations:

ALTER TABLE altruist.players RENAME COLUMN "hp" TO "health";
ALTER TABLE altruist.players ALTER COLUMN "health" TYPE BIGINT USING "health"::BIGINT;

Copy and Delete Columns

For complex migrations — splitting a column, converting types with custom logic, or removing deprecated fields — use [VaultColumnCopy] and [VaultColumnDelete].

VaultColumnCopy

Copies data from an existing column into a new column during migration, with automatic type casting:

[Vault("players")]
[VaultPrimaryKey(nameof(StorageId))]
public class PlayerVault : VaultModel
{
    [VaultColumn] public override string StorageId { get; set; } = "";

    // Old column — will be deleted after copy
    [VaultColumnDelete("Replaced by GoldDisplay (string) in v2.3")]
    [VaultColumn("gold")]
    public int Gold { get; set; }

    // New column — copies data from "gold" with int → text cast
    [VaultColumn("gold_display")]
    [VaultColumnCopy("gold")]
    public string GoldDisplay { get; set; } = "";
}

The framework generates:

-- 1. Add the new column
ALTER TABLE altruist.players ADD COLUMN "gold_display" TEXT;

-- 2. Copy data with type cast, in batches of row locations (Postgres has no UPDATE ... LIMIT)
UPDATE altruist.players SET "gold_display" = "gold"::TEXT
WHERE ctid = ANY(ARRAY(SELECT ctid FROM altruist.players
                       WHERE "gold_display" IS NULL AND "gold" IS NOT NULL AND ("gold"::TEXT) IS NOT NULL
                       LIMIT 50000))
  AND "gold_display" IS NULL AND "gold" IS NOT NULL;   -- repeated until no row is left

-- 3. Drop the old column
ALTER TABLE altruist.players DROP COLUMN "gold";

Note:

Execution order is guaranteed: [VaultColumnCopy] runs first, then [VaultColumnDelete]. This ensures data is safely copied before the source column is removed. You can use both on the same migration without worrying about ordering.

The copy runs in batches, which bounds the size of each statement. Configure the batch size:

altruist:
  persistence:
    migration:
      batch-size: 50000  # rows per batch during column copy (default: 50000)

Note:

All batches run inside the migration's single transaction (see Transaction Safety), so a failed cast rolls the whole migration back. Batching keeps each statement small. It does not release locks between batches.

Use nameof() for compile-time safety when the source property still exists in the model:

[VaultColumnCopy(nameof(Gold))]    // resolved at compile time
public string GoldDisplay { get; set; } = "";

Or use a string literal if the source property has already been removed:

[VaultColumnCopy("gold")]          // string fallback
public string GoldDisplay { get; set; } = "";

VaultColumnDelete

Marks a column for deletion. The property stays in the class as self-documenting history but is ignored by the ORM at runtime (treated as [VaultIgnore]). Always pair with [Obsolete] so IntelliSense flags any code still referencing the deleted property:

[VaultColumnDelete("No longer needed — stats moved to separate table in v3.0")]
[Obsolete("Column deleted. Use PlayerStatsVault instead.")]
[VaultColumn("old_stats_json")]
public string OldStatsJson { get; set; } = "";

Note:

[VaultColumnDelete] properties are excluded from all queries, inserts, and updates — the ORM treats them as if they don't exist. They only affect the migration planner, which drops the column from the database. The [Obsolete] ensures your IDE shows warnings everywhere the property is still used, so you can clean up references before deploying.

Cross-Type Migration Pattern

The combination of [VaultColumnCopy] + [VaultColumnDelete] is the recommended way to handle cross-type migrations where you want full control:

[Vault("players")]
[VaultPrimaryKey(nameof(StorageId))]
public class PlayerVault : VaultModel
{
    [VaultColumn] public override string StorageId { get; set; } = "";

    // OLD: position stored as JSON string — mark for deletion
    [VaultColumnDelete("Split into X/Y/Z float columns in v2.0")]
    [Obsolete("Use PosX, PosY, PosZ instead.")]
    [VaultColumn("position_json")]
    public string PositionJson { get; set; } = "";

    // NEW: separate typed columns (simple cast won't work for JSON → float)
    [VaultColumn("pos_x")]
    public float PosX { get; set; }

    [VaultColumn("pos_y")]
    public float PosY { get; set; }

    [VaultColumn("pos_z")]
    public float PosZ { get; set; }
}

Note:

[VaultColumnCopy] uses SQL USING cast (source::target_type). This works for simple conversions (int → text, text → int). For complex transforms like JSON → multiple columns, don't use [VaultColumnCopy] — use a Database Initializer instead (see below).

Migrating Complex Columns with an Initializer

When a simple SQL cast isn't enough (JSON parsing, string splitting, computed values), write a Database Initializer that runs after the migration creates the new columns:

[Service]
public class PositionMigrationInitializer : IDatabaseInitializer
{
    private readonly IVault<PlayerVault> _players;
    private readonly ILogger _logger;

    public PositionMigrationInitializer(IVault<PlayerVault> players, ILoggerFactory loggerFactory)
    {
        _players = players;
        _logger = loggerFactory.CreateLogger<PositionMigrationInitializer>();
    }

    public async Task InitializeAsync()
    {
        // Check if migration is needed (old column data exists, new columns are zero)
        var needsMigration = await _players
            .Where(p => p.PosX == 0 && p.PosY == 0 && p.PosZ == 0)
            .AnyAsync();

        if (!needsMigration) return;

        _logger.LogInformation("Migrating position_json to PosX/PosY/PosZ...");

        // Raw SQL to parse JSON and populate new columns
        // This runs once — the initializer can check a flag or column state
        await _players.ExecuteRawAsync(@"
            UPDATE altruist.players
            SET pos_x = (position_json::jsonb ->> 'x')::REAL,
                pos_y = (position_json::jsonb ->> 'y')::REAL,
                pos_z = (position_json::jsonb ->> 'z')::REAL
            WHERE position_json IS NOT NULL
              AND pos_x = 0 AND pos_y = 0 AND pos_z = 0;
        ");

        _logger.LogInformation("Position migration complete.");
    }
}
Loading diagram...

Note:

The migration creates the columns (DDL). The initializer fills them with data (DML). This separation keeps the migration system simple and transactional while giving you full control over data transforms.

Dropping Tables

By default, removing a [Vault] class from your code does not drop the table — this prevents accidental data loss. To explicitly drop or archive a table, use these attributes.

VaultTableDelete — Hard Delete

Drops the table (and its history table if it exists) on next startup:

[VaultTableDelete("Replaced by PlayerStatsVault in v3.0")]
[Obsolete("This vault is deleted. Use PlayerStatsVault instead.", error: true)]
[Vault("old_player_stats")]
public class OldPlayerStatsVault : VaultModel
{
    [VaultColumn] public override string StorageId { get; set; } = "";
    // ... old columns
}

The reason parameter documents why the table was removed. Always pair with [Obsolete] — this makes C# IntelliSense highlight every place in your codebase that still references the deleted vault, so you can find and fix them before shipping:

[Obsolete] parameterEffect
error: false (default)Compiler warning — yellow squiggly, IDE strikethrough
error: trueCompiler error — won't build if any code references this class

Note:

[VaultTableDelete] is irreversible. The table and all its data are permanently dropped. There is no undo. Use [VaultArchived] instead if you want to preserve data.

VaultArchived — Safe Delete (Archive First)

Copies all data to an archive table before dropping the original. Safer than [VaultTableDelete] — your data is preserved:

[VaultArchived("archived_old_stats", "Migrated to PlayerStatsVault in v3.0")]
[Obsolete("Archived. Use PlayerStatsVault.", error: true)]
[Vault("old_player_stats")]
public class OldPlayerStatsVault : VaultModel
{
    [VaultColumn] public override string StorageId { get; set; } = "";
    // ... old columns
}

On next startup, the framework:

  1. Creates archived_old_stats with the same structure as old_player_stats
  2. Copies all rows: INSERT INTO archived_old_stats SELECT * FROM old_player_stats
  3. Drops the old_player_stats_history table (if it exists)
  4. Drops the old_player_stats table
CREATE TABLE IF NOT EXISTS altruist.archived_old_stats (LIKE altruist.old_player_stats INCLUDING ALL);
INSERT INTO altruist.archived_old_stats SELECT * FROM altruist.old_player_stats;
DROP TABLE altruist.old_player_stats_history;
DROP TABLE altruist.old_player_stats;

Note:

The archive table is a plain database table — query it directly with SQL anytime. It's not managed by Altruist after creation (no vault class needed), so it won't be touched by future migrations.

Note:

Always use [Obsolete] alongside [VaultTableDelete] or [VaultArchived]. Without it, the vault class still compiles cleanly — services that inject it won't show any warning, and you'll discover the broken dependency at runtime instead of compile time.

When to Use Which

ScenarioAttributeData preserved?
Table is truly obsolete, data not needed[VaultTableDelete] + [Obsolete(..., error: true)]No
Migrating to new schema, want backup[VaultArchived] + [Obsolete(..., error: true)]Yes (in archive table)
Just removing from code, keeping in DBRemove the class (no attribute)Yes (table untouched)

Transaction Safety

All migration operations run inside a single database transaction. If any operation fails, the entire migration is rolled back — your database is never left in a half-migrated state.

BEGIN;
  CREATE TABLE altruist.guilds ...     ✓
  ALTER TABLE altruist.players ...     ✓
  ADD CONSTRAINT fk_players_guild ...  ✗ (fails)
ROLLBACK;  -- everything reverted

Note:

Postgres supports transactional DDL (unlike MySQL which auto-commits). This means CREATE TABLE, ALTER TABLE, ADD CONSTRAINT are all atomic — either all succeed or none apply.

What the Migration System Does NOT Do

Note:

  • Migrate data — the system handles DDL (structure), not DML (data). If you need to transform existing rows (e.g., backfill a new column, merge data from two tables), use a Database Initializer.
  • Auto-drop unused tables — removing a [Vault] class does not drop the table. Use [VaultTableDelete] or [VaultArchived] explicitly (see above).
  • Auto-drop unused columns — removing a property keeps its column (see Removed Columns). Use [VaultDropColumn], [VaultColumnDelete] or drop-unmapped-columns: true.

Migration Pipeline

Loading diagram...

The pipeline is fully pluggable. Each step (inspector, planner, executor) is an interface with provider-specific implementations. Postgres ships built-in. Custom database providers implement their own.