The Hidden Bias of ORM-Based Migrations (And What to Do About It)
Your database knows things your ORM does not.
It knows about the materialized view a previous developer created to speed up a slow report. It knows about the RLS policies keeping tenant data isolated. It knows about the triggers auditing every write to a sensitive table. Your ORM migration tool, in most cases, has no awareness of any of it.
This is not a criticism of ORMs. It is a description of what they are built to do. An ORM is a runtime tool: it maps database rows to objects so your application code can work with them. The migration tool it ships with exists to bootstrap that runtime, not to manage your database as a system in its own right.
Alembic and Django Migrations are genuinely good at what they were designed for. Understanding why they fall short in certain situations requires understanding that design intent.
Why Django Migrations and Alembic Work Well#
Both tools have earned their place. Alembic and Django Migrations follow a code-first approach: you define tables and columns as Python classes, and the tool generates the SQL to create or alter them. For greenfield projects and CRUD-heavy applications, this is often everything you need. Schema changes happen close to the code that drives them, and the tooling is familiar to every Python developer on the team.
The question is not whether these tools are good. The question is whether they were built to be your schema management layer, or your ORM's schema management layer. Those are different things.
The Structural Bias of ORM-Based Migrations#
ORMs are distributed with migration tools out of necessity. Without a way to set up the database schema, the ORM cannot function. But the migration tool is always a secondary concern.
The core job of an ORM is to abstract the database layer and provide a roughly uniform experience across different systems (PostgreSQL, MySQL, SQL Server, etc.). As an abstraction layer, ORMs concentrate on shared database features like tables, indexes, and columns, rather than on more advanced, database-specific capabilities.
Speaking as someone who has worked closely with ORM internals: migrations are treated as a necessary evil. ORMs exist to bridge code and the database at runtime. Schema management is a CI/CD and deployment concern, and it does not naturally fit inside that model.
As a result, ORM migration tools tend to handle only the common cases. In projects that require a more involved schema management process, a dedicated tool is worth considering.
The Advanced Features Gap#
In many systems, the database is treated as a simple persistence layer. But modern databases are capable of far more. There is a growing trend of using databases for logic, aggregation, and access control that lives outside the application layer.
ORMs typically do not provide a way to manage these features:
Views and Materialized Views
Views are virtual tables generated from a SELECT query. Materialized views store the query results in a physical table that can be indexed and refreshed independently. They are valuable for read-heavy or computationally expensive queries. ORM migrations do not model them.
Stored Functions and Triggers
Logic can be encapsulated in database-level functions or triggers that fire on inserts, updates, or deletes. This ensures consistent validation or auditing at the database level, independent of the application. ORMs largely ignore this capability.
Row-Level Security (RLS)
RLS policies control which rows a user can see or modify. This is particularly useful for multi-tenant systems or applications with strict data access requirements. Almost no ORM migration tool manages RLS policies as first-class schema objects.
Custom Data Types and Enums
PostgreSQL allows you to define custom types and constrained ENUM types. Support in ORM migrations is partial at best, and migrations involving ENUM changes are notoriously fragile.
Extensions
PostgreSQL extensions like PostGIS add specialized capabilities to the database. Managing extensions as part of your schema lifecycle is outside the scope of ORM migration tools.
Where Migrata Comes In#
Migrata does not replace your ORM. It replaces your ORM's migration tool.
The design principle behind Migrata is that your schema source of truth should be SQL, and the tool that manages it should speak SQL natively. Not a DSL. Not an ORM model. Not an HCL config file. Plain SQL DDL, organized into files, committed to Git alongside your application code.
A materialized view is just another file in your ./schema directory:
-- ./schema/views/team_points.sql
CREATE MATERIALIZED VIEW team_points AS
SELECT team_name, SUM(points) AS total_points
FROM user_account
GROUP BY team_name;
A trigger is just another SQL file:
-- ./schema/triggers/updated_at.sql
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
When you run migrata diff, it compares your entire ./schema directory against the live database and generates the precise SQL to reconcile any differences:
migrata diff \
--from "postgresql://user:pass@localhost:5432/mydb" \
--to ./schema
Migrata picks up the new view and trigger automatically. There is no provider plugin to configure, no intermediate schema format to compile, and no HCL to learn.
Why SQL-First Matters#
Other tools in this space solve the advanced features problem by adding a layer on top of SQL. They ask you to express your schema in their language, then translate it. That approach has a ceiling: the tool can only represent what its DSL was designed to express. Anything outside that set requires workarounds, escape hatches, or raw SQL injected into an otherwise-declarative config.
Migrata inverts the model. SQL is not the output of the tool. SQL is the input. Your schema files are standard DDL that any database administrator, any SQL tool, and any future migration tool can read without translation. You are not locked into Migrata's syntax because there is no Migrata syntax.
This has practical consequences:
- Readability: Pull requests show real SQL diffs. Reviewers do not need to know Migrata to understand what a schema change does.
- Portability: If you ever want to move to a different tool, your schema files go with you unchanged.
- Database fidelity: Because Migrata reads SQL directly, it can track any object the database supports, including objects that did not exist when Migrata was written.
The goal was never to build a better abstraction over SQL. SQL is already the right language for describing database structure. The tool should stay out of the way.
One Tool Across Your Stack#
If your company runs a uniform tech stack, the migration tool is rarely a topic of debate. But as companies grow, especially when adopting a microservices architecture, different services end up using different ORMs, different languages, or different frameworks. Alembic owns the Python services. Prisma Migrate owns the TypeScript services. Something else owns the Go services.
This fragmentation makes it hard for any platform or infrastructure function to enforce consistent schema practices across the organization.
Because Migrata works directly against live databases and plain SQL files, it is language-agnostic and ORM-agnostic. A platform team can standardize on Migrata regardless of whether the service is Python, Go, Java, or Node. The schema management layer stops being coupled to whatever runtime framework a team happened to choose.
The migration tool you use in production should not depend on your ORM.
Conclusion#
Django Migrations and Alembic are excellent tools that have served Python developers well for years. They make schema changes simple and work seamlessly with their respective ORMs. That is exactly what they were built to do.
But ORMs are runtime tools. Schema management is a deployment concern. Those are different responsibilities, and conflating them produces migration tools optimized for the ORM's needs, not the database's capabilities.
For projects that need to manage advanced database features, maintain consistency across a mixed tech stack, or simply want a cleaner separation between their runtime code and their schema lifecycle, a dedicated tool like Migrata is worth a serious look.
It works alongside your ORM. You keep SQLAlchemy or Django for what they do best. Migrata handles the rest.