Database Migrations in Kotlin: How to Manage Schema Without Pain
When developing in Kotlin, especially in conjunction with JVM, sooner or later the task of synchronizing the database structure between developers, test, and production servers arises. Manually executing SQL scripts leads to desynchronization and errors. The solution is database migrations. This is an automated way to version schema changes: creating tables, adding columns, indexes, and triggers.
In this article, we'll look at three popular tools for Kotlin: Flyway, Liquibase, and the built-in mechanism of Exposed. You'll learn how to integrate them into a project, write migrations, and avoid common problems.
Flyway: Simplicity and Versioning
Flyway is the most popular migration tool in the JVM ecosystem. It supports Kotlin, Java, and Groovy. The principle of operation: you write SQL scripts (or Java/Kotlin classes), and Flyway tracks which ones have already been applied using the flyway_schema_history table.
Adding to Gradle (Kotlin DSL):
dependencies { implementation("org.flywaydb:flyway-core:9.22.3") // for PostgreSQL implementation("org.flywaydb:flyway-database-postgresql:9.22.3")}Example migration V1__create_users.sql:
CREATE TABLE users ( id SERIAL PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, email VARCHAR(100) NOT NULL, created_at TIMESTAMP DEFAULT NOW());
CREATE INDEX idx_users_email ON users(email);Running from Kotlin code:
import org.flywaydb.core.Flyway
fun main() { val flyway = Flyway.configure() .dataSource("jdbc:postgresql://localhost:5432/mydb", "user", "pass") .load() flyway.migrate()}Advantages:
- Minimal configuration
- Support for rollbacks (undo) in the paid version
- Works with any SQL
Disadvantages:
- No built-in schema validation
- Difficult to manage branching
Liquibase: Flexibility and Declarative Approach
Liquibase offers two approaches: changes can be described in XML, YAML, JSON, or directly in SQL. For Kotlin projects, the YAML format is convenient — it's readable and easily versioned.
Adding to Gradle:
dependencies { implementation("org.liquibase:liquibase-core:4.24.0")}Example changelog file db.changelog-master.yaml:
databaseChangeLog: - changeSet: id: 1 author: developer changes: - createTable: tableName: users columns: - column: name: id type: BIGINT autoIncrement: true constraints: primaryKey: true - column: name: username type: VARCHAR(50) constraints: nullable: false unique: true - column: name: email type: VARCHAR(100) - createIndex: indexName: idx_users_email tableName: users columns: - column: name: emailRunning from Kotlin:
import liquibase.Liquibaseimport liquibase.database.jvm.JdbcConnectionimport liquibase.resource.ClassLoaderResourceAccessorimport java.sql.DriverManager
fun runMigrations() { val connection = DriverManager.getConnection( "jdbc:postgresql://localhost:5432/mydb", "user", "pass" ) val database = DatabaseFactory.getInstance() .findCorrectDatabaseImplementation(JdbcConnection(connection)) val liquibase = Liquibase("db.changelog-mas