Gitea Database Migration Strategies: Version-Driven Schema Management in Go

Gitea employs a sequential, version-driven migration system that tracks schema state in a single version table and executes atomic Go functions to migrate the database forward from any previous version.

The go-gitea/gitea project implements a robust, code-first approach to schema evolution. Unlike external SQL script-based tools, Gitea's database migration strategies leverage compiled Go functions and the XORM ORM to ensure type-safe, atomic schema changes that can resume safely if interrupted.

Core Architecture of Gitea's Migration System

The Version Table and State Tracking

At the heart of Gitea's migration strategy lies a singleton version table that stores the current database schema version. According to the source code in models/migrations/migrations.go, the system synchronizes this table with the Version struct defined at lines 66-71. This single-row table contains a Version field representing the integer ID of the last successfully applied migration.

When the application starts, the migration engine compares this stored value against the list of available migrations defined in the codebase. This design ensures that Gitea always knows exactly which schema changes have been applied, even if previous migration attempts were interrupted.

Migration Registration and Structure

Each database migration is defined as a migration struct containing three critical fields: an integer ID, a human-readable description, and a migrate function pointer. The prepareMigrationTasks() function assembles these into a sequential slice.

The system enforces a minimum supported version through the minDBVersion constant (set to 70), which protects against attempts to upgrade from unsupported ancient releases. As implemented in models/migrations/migrations.go, the calcDBVersion() function validates that the migration list starts at minDBVersion and that the final ID matches the expected database version, ensuring the codebase and database remain in sync.

How Gitea Executes Database Migrations

The Sequential Execution Flow

The Migrate function—invoked by the gitea migrate CLI command— orchestrates the entire process. Located in models/migrations/migrations.go, this function performs the following steps:

  1. Synchronizes the Version model with the database
  2. Determines the current version or creates a fresh record for new installations
  3. Verifies the version is within supported bounds (respecting minDBVersion)
  4. Initializes the Git subsystem via git.InitSimple
  5. Iterates over pending migrations determined by getPendingMigrations()

The getPendingMigrations() function slices the migration array based on the current database version, ensuring only newer migrations execute. After each migration completes successfully, the system updates the version table row, making each step atomic and resumable.

Version Validation and Safety Guards

Gitea implements strict safety checks to prevent data corruption. The EnsureUpToDate function (referenced in models/migrations/migrations.go) allows runtime code to verify that the database matches the expected version before proceeding with application logic. If a version mismatch is detected, Gitea instructs operators to run the migration command rather than attempting automatic schema changes during runtime.

Migration Implementation Patterns

Version-Specific Migration Packages

Individual migrations live in version-specific packages following the models/migrations/v1_XX/ directory structure (e.g., models/migrations/v1_10/v90.go). These implementations are simple Go functions that receive a *xorm.Engine parameter, allowing them to either use XORM's Sync method for table/column adjustments or execute raw SQL statements when necessary.

For example, adding a new migration for version 1.27 (ID 326) follows this pattern:

// models/migrations/v1_27/v326.go
package v1_27

import "xorm.io/xorm"

// AddIssueLabelsTable creates a new table for issue labels.
func AddIssueLabelsTable(x *xorm.Engine) error {
    type IssueLabel struct {
        ID      int64  `xorm:"pk autoincr"`
        IssueID int64  `xorm:"INDEX"`
        LabelID int64  `xorm:"INDEX"`
    }
    return x.Sync(new(IssueLabel))
}

Registration occurs within prepareMigrationTasks() in the main migrations file:

// Inside prepareMigrationTasks() in models/migrations/migrations.go
newMigration(326, "add issue_labels table", v1_27.AddIssueLabelsTable),

No-Op Migrations for Stability

When a migration becomes obsolete or is superseded by later changes, Gitea does not remove it from the sequence. Instead, the system inserts a noopMigration stub to maintain stable IDs. This approach ensures that migration numbers remain consistent across releases and that historical database states can always be understood by examining the code.

Running Migrations in Production

Database migrations are executed via the Gitea CLI rather than automatic startup processes. Operators run:


# From a terminal on a deployed Gitea instance

gitea migrate

# Output will show each step, e.g.:

# Migration[326]: add issue_labels table

This command entry point resides in commands/migrate.go, which delegates to the Migrate function in the models package. By separating migration execution from application startup, Gitea allows administrators to control when schema changes occur and to back up databases before potentially destructive operations.

Summary

  • Version-controlled state: Gitea tracks schema evolution using a monotonic integer stored in a single-row version table.
  • Sequential and additive: Every schema change appends a new migration to the list; historical migrations are never removed, only converted to no-ops when superseded.
  • Code-first approach: Migrations are compiled Go functions using XORM, providing type safety and access to application logic rather than static SQL files.
  • Atomic execution: Each migration updates the version row independently, allowing the process to resume safely if interrupted.
  • Safety boundaries: The minDBVersion constant and EnsureUpToDate checks prevent incompatible upgrades and runtime version mismatches.

Frequently Asked Questions

How does Gitea track which migrations have already run?

Gitea maintains a single-row version table in the database containing a Version field. When migrations execute, the system compares this stored integer against the migration list defined in models/migrations/migrations.go and executes only those with higher IDs via getPendingMigrations().

What happens if a Gitea migration fails halfway through?

Because Gitea updates the version table row immediately after each individual migration succeeds, the process is resumable. If a failure occurs during migration 150, the database remains at version 149. Running gitea migrate again will skip completed migrations and resume from the failed step.

Can I downgrade Gitea to a previous database version?

No. Gitea's database migration strategies are designed as forward-only operations. The system does not implement rollback mechanisms or down-migrations. To revert to a previous version, you must restore from a database backup taken before the upgrade.

Why does Gitea use Go functions instead of SQL files for migrations?

Using Go functions provides compile-time safety, access to the XORM ORM for database-agnostic schema changes, and the ability to reuse application logic and constants. This approach prevents syntax errors that might only surface at runtime with raw SQL scripts and ensures migrations remain synchronized with the codebase structure.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →