If you have worked on a mature backend application, your db/migration folder probably looks like this:
V1_init.sql
V2add_users.sql
...
V142add_status_column.sql
V143fix_status_typo.sql
V144_drop_old_status.sql
Tools like Flyway and Liquibase have been the industry standard for over a decade. They operate on an imperative model: “Apply this exact script to get from version N to version N+1.”
But imperative migrations create friction. Two engineers branching off main will inevitably create conflicting V145__... scripts. Standing up a new test environment means replaying 145 scripts sequentially. And if someone accidentally commits a script containing DROP TABLE users;, the migration tool will blindly execute it against production.
Modern infrastructure (like Kubernetes and Terraform) moved away from imperative scripts years ago. They use declarative state: “Here is what the system should look like. Figure out how to make it so, safely.”
It is time we manage relational databases the same way.
Enter SchemaSynchronizer
SchemaSynchronizer is an open-source library that brings declarative, desired-state schema evolution to relational databases (PostgreSQL, MySQL, MariaDB, SQL Server, and Oracle).
Instead of maintaining a growing chain of migration scripts, you maintain a single schema-definition.json file representing the current, desired state of your database:
{
"formatVersion": 2,
"dialect": "postgresql",
"tables": {
"work_items": {
"createSql": "CREATE TABLE IF NOT EXISTS work_items (id UUID PRIMARY KEY, status VARCHAR(32))",
"columns": [
{"name": "id", "definition": "UUID NOT NULL"},
{"name": "status", "definition": "VARCHAR(32)"}
],
"indexes": [
"CREATE INDEX IF NOT EXISTS idx_work_items_status ON work_items (status)"
]
}
},
"changes": []
}
On every run, SchemaSynchronizer inspects the live database metadata via JDBC, compares it against your JSON definition, and applies safe, additive changes automatically. For destructive changes (like dropping a column), it halts and outputs pending SQL for a human to review.
Because architectures vary, SchemaSynchronizer is designed to be consumed in three different ways:
- Spring Boot Auto-Configuration
For Spring Boot applications, SchemaSynchronizer integrates natively as a database initializer. Add the dependency:
Maven:
<dependency>
<groupId>com.thinkaillc</groupId>
<artifactId>schema-synchronizer</artifactId>
<version>2.0.0</version>
</dependency>
Enable it in your application.properties:
schema-synchronizer.enabled=true
schema-synchronizer.schema=public
schema-synchronizer.fail-on-pending=true
# Let SchemaSynchronizer handle DDL; tell Hibernate to just validate
spring.jpa.hibernate.ddl-auto=validate
- Plain Java API
If you aren't using Spring Boot, or you want programmatic control, you can wrap SchemaSynchronizer around any existing JDBC connection. It has a minimal API surface and throws unchecked exceptions based on clear failure states:
ObjectMapper mapper = new ObjectMapper();
SchemaDefinition definition = mapper.readValue(Path.of("schema-definition.json").toFile(), SchemaDefinition.class);
SchemaSynchronizerOptions options = SchemaSynchronizerOptions.defaults();
SchemaSynchronizer synchronizer = new SchemaSynchronizer(mapper, null, "", options);
try (Connection connection = dataSource.getConnection()) {
SchemaSynchronizationResult result = synchronizer.synchronizeWithResult(connection, definition);
result.pendingSql().forEach(sql ->
System.err.println("Manual review required: " + sql)
);
}
- The Standalone CLI (Great for CI/CD)
Don't want to tie schema migrations to your application startup? You can use the standalone executable JAR to serialize an existing database or sync a target database directly from your terminal or CI/CD pipeline:
export SCHEMA_DB_PASSWORD='target-password'
java -jar schema-synchronizer-cli-2.0.0-standalone.jar sync \
jdbc:postgresql://localhost:5432/target_app app_user - \
schema-definition.json public schema_synchronizer_history
The CLI comes bundled with the JDBC drivers for all 5 supported engines, making it completely self-contained. It also features a dry-run command to preview changes without committing them.
The Safety Boundary: Safe vs. Destructive DDL
The most dangerous part of automated migrations is accidental data loss. SchemaSynchronizer is built around a strict fail-closed safety boundary.
If you add a nullable description column to your JSON, SchemaSynchronizer issues the
ALTER TABLE ... ADD COLUMN automatically on startup.
But what if you remove a column from the JSON? SchemaSynchronizer never infers a DROP operation. Instead, execution halts (or exits with code 1 in the CLI) and prints copyable SQL for the operator to review:
[SchemaSynchronizer] Destructive difference detected.
Manual review required: ALTER TABLE work_items DROP COLUMN old_status;
This ensures that routine additive drift is repaired automatically across all your environments, but destructive reconciliation always crosses a manual-review boundary.
Ready to drop the migration scripts?
If you are tired of resolving merge conflicts on V204__add_index.sql, or want to enforce a safer boundary between additive DDL and destructive drops, check out the repository.
GitHub: eugenena/SchemaSynchronizer
Top comments (0)