In modern application development, Object-Relational Mappers (ORMs) have been used for working with relational databases to increase the development speed. They offer powerful abstractions that map database tables to in-memory objects, making it “easier” to perform data operations without writing raw SQL. However, ORMs come with trade-offs. When misunderstood or overused (or just used), they can lead to hidden complexity, architecture pitfalls, and fragile systems.
This article explores those trade-offs—especially how ORMs can lead to tight coupling and reinforce the XY problem, where developers end up asking the wrong questions and solving the wrong problems.
The Value of ORMs
Let’s start with the sales pitch. ORMs offer good benefits, particularly during the early stages of a project or when dealing with standard use cases:
1. Streamlined Data Access
They allow developers to create, read, update, and delete data through familiar object-oriented syntax instead of SQL queries.
This reduces boilerplate and helps focus on application logic instead of repetitive database code.
2. Abstraction Across Database Engines
Many ORMs are designed to be database-agnostic. Switching from one database engine to another often requires minimal changes to application code.. This can be especially helpful in applications targeting multiple environments.
3. Automatic Relationship Management
Most ORMs support relationships such as one-to-many or many-to-many and handle joins, cascading deletes, and lazy loading.
This can make traversing complex data models much simpler in code, without requiring detailed knowledge of SQL joins.
4. Built-in Safeguards
Transaction handling, input sanitization, and schema migration tools built into many ORMs reduce the risk of bugs and security flaws.
Where ORMs Begin to Strain
ORMs can shine in standard CRUD cases; however they often become a problem when the application’s needs grow beyond the scope of what the abstraction was designed for.
1. The XY Problem
Let’s define this. The XY problem arises when someone tries to solve problem Y, which is only a symptom or assumed step toward solving the actual problem X. They get stuck trying to make Y work, and often ask for help with that, not realizing X needs a different (often simpler) solution entirely.
This problem becomes especially visible when using ORMs, where developers try to force the ORM into solving a complex query instead of stepping back to ask if the ORM is even the right tool for the job.
Let’s walk through a more complete example.
“Problem X: I need to generate a report showing the top 10 products by revenue for the last quarter, with totals grouped by month.”
This is a reporting problem. It involves:
- Aggregation (
SUM
) - Grouping by time periods (
GROUP BY
) - Sorting and limiting (
ORDER BY
) - Possibly joining multiple tables (
products
,orders
,order_items
)
“Problem Y: How do I build this query using the ORM so it loads the product objects with monthly revenue totals and all their related fields?”
Now the developer is trying to:
- Use the ORM’s object model
- Load all product details and relationships
- Perform time-based grouping and summing
- Apply ordering and limiting
They spend hours writing (guessing and debugging) something like this:
This might:
- Break in subtle ways on different databases
- Be difficult to test and debug
- Not return full Product objects (so now they need to refetch them)
- Be incredibly hard to change if the report logic evolves
At this point, the developer is quite focused in building Y. They might not question it because they think “this is just how we do things” in the ORM.
Let’s reflect on that.
Why did they want the ORM objects in the first place? To show product names and totals in a report. Not to edit them, persist them, or reuse domain logic.
So why are we bending over backwards to build a relational aggregation problem using object queries?
Once we identify the actual problem (X), we see the solution doesn’t even need the ORM.
Here’s what a better solution might look like:
Now:
- The SQL is readable and efficient
- The report is decoupled from the ORM and domain model
- You can optimize, index, or test it independently
- You solve X without wasting time on Y
The core insight of the XY problem is this:
If you find yourself spending a lot of time “convincing” a tool to do something complex, it’s worth stepping back to ask: “Am I solving the right problem?”
In this case:
- The ORM is great for working with entities and business logic.
- But reports are a different kind of problem — they are better served by queries optimized for read-only analytics, often using SQL directly.
2. The Problem of Coupling
At the very beginning, when working with ORMs, developers often get caught up in the convenience: mapping tables to classes, automatically generating joins, and saving objects without writing SQL. That’s why many teams end up building their architecture around the ORM. As the application grows, things start to get wild.
This is where coupling begins. Driven by convenience, the ORM becomes tightly entangled with business logic, application flow, and even the API layer.
Let’s unpack how this happens, what forms it takes, and why it’s problematic.
1. “It’s just a small method…”
It starts when business logic ends up inside your data models because, well, the data is already there:
This seems more helpful than harmless. The invoice knows how to calculate its total, right?
But there’s a hidden trap here: this logic depends on self.items
, which may be a lazily-loaded relationship. In some environments (especially async or detached sessions), this breaks. In others, it silently hits the database, making it hard to predict performance.
Business logic (e.g., total_due
) is now entangled with persistence concerns (like lazy-loading), all through the ORM layer.
This logic can’t run:
- In a unit test without a DB
- In a background task using a plain dict
- On a DTO parsed from an API response
2. “We can just return the model…”
Next, you pass ORM objects directly to your API responses, templates, or serializers:
This approach makes it difficult to evolve the data model, test endpoints in isolation, or cache responses efficiently.
Now your web layer depends directly on the database layer:
- You can’t cache this easily because it expects a live ORM session.
- You can’t change your schema without risking every view breaking.
- You can’t test it without populating the database.
The ORM has leaked upward into the presentation layer.
3. “We’ll just reuse the model everywhere…”
Eventually, the ORM model becomes the universal interface:
- Used in views
- Used in background jobs
- Used in business logic
- Passed to forms, serializers, reports, even frontend code (via
.to_dict()
)
It becomes the application, instead of just a persistence layer. This coupling makes the code brittle. You can’t evolve the database schema, or migrate off the ORM, without unraveling everything.
4. “Ok, We can create our own models…”
When you choose not to pass ORM models around and instead create your own domain entities (good for decoupling) you introduce a new layer of representation. But now you’re responsible for translating between the ORM and the domain, and this often leads to duplicated code.
For example:
- You define the same fields (like
name
,email
,total_due
) in both the ORM model and the domain entity. - You may need to write conversion logic (mappers or factories) to move data between the two.
- Any time a field or relationship changes in the database model, you have to remember to update the domain entity and its mapping logic — or risk subtle bugs.
This isn’t inherently bad. In fact, it’s often a sign of better architecture. But it adds complexity and overhead, especially if your team isn’t disciplined about maintaining the boundary.
That’s why many developers take the shortcut and just use the ORM model everywhere. At the cost of tight coupling.
Other Problems
1. Leaky Abstractions
ORMs promise to abstract away SQL, letting developers “think in objects” instead of tables and joins. But in practice, the abstraction often leaks, which means the developer ends up needing to understand how the ORM translates code into SQL to avoid incorrect behavior or logic bugs.
This isn’t just a performance issue but a semantic mismatch that causes real confusion.
Example: Filters that don’t do what you think
Let’s say you want to fetch all users whose email is not None
.
A developer might write:
But this doesn’t always do what they expect. Why?
In SQL, comparing something to NULL
using !=
doesn’t work. The correct SQL is:
Now the developer is left wondering:
- Why didn’t
!= None
work? - When do I need
.isnot()
vs!=
? - What other “normal Python” things will silently break in SQL?
This is abstraction leakage. Even though the ORM gives you Python-like syntax, it’s not really Python but SQL masked as Python. And unless you understand the rules of SQL, you’ll write code that looks correct but behaves incorrectly.
2. Performance Misconceptions
ORMs are optimized for developer convenience, not for query performance. If you’re not extremely careful, it’s easy to:
- Fetch thousands of rows unintentionally
- Perform queries in loops
- Do expensive joins accidentally
Example:
- You think
.all()
fetches all records efficiently. Under the hood, it might generate a suboptimal query or load more than you need. - You use a relationship with
lazy='select'
and suddenly a loop over results triggers N+1 queries.
Another example:
OR:
ORMs give the illusion of simplicity. However, performance optimization becomes much harder than writing the correct SQL directly.
3. Testing Friction
Code written around ORMs often requires a real or simulated database even for basic tests. Why?
Because the ORM:
- Relies on session or context
- Lazily loads data
- Requires actual schema setup to work
This makes unit testing difficult. You have to:
- Mock or fake sessions (complex)
- Use in-memory databases (slow, imperfect)
- Or skip unit tests and only write integration tests (risky, slower CI)
Decoupled domain models and data mappers test cleanly. ORM-bound logic doesn’t.
4. Inflexibility for Non-Relational Needs
Your domain logic may evolve to need:
- Denormalized data (e.g. reporting, search)
- External data sources (APIs, event logs)
- Asynchronous or distributed queries
ORMs are tightly tied to the relational model. You can’t:
- Easily fetch a JSON blob from S3 and combine it with a database query
- Hydrate part of a model from an API
- Perform complex batch joins across systems
This shows up painfully in reporting, analytics, and microservice contexts — where you suddenly realize you’re bending your entire system just to make it ORM-compatible.
5. Loss of Intent / Domain Obscurity
When all your models are database tables, and all logic is attached to them, your domain tends to vanish into the infrastructure.
You lose semantic clarity:
- Business rules hide inside ORM models
- Queries reflect storage shape, not conceptual meaning
- No clear separation between core logic and data plumbing
Over time, developers can’t “see” the business domain. They see a pile of tables and queries instead of a meaningful model.
Best Practices to Avoid Pitfalls
1. Separate Domain from Persistence
Define application-level models or data transfer objects that are independent of your ORM entities.
Populate these from ORM queries but keep them clean of database-specific behavior.
2. Isolate Data Access
Encapsulate ORM usage in dedicated repository or DAO classes. This reduces duplication and creates seams for testing or substitution.
3. Use Raw SQL Where Appropriate
Don’t hesitate to drop down to SQL when performance, clarity, or complexity demands it. ORMs are a convenience, not a constraint.
4. Think in Terms of Problems, Not Tools
If you find yourself wrestling with an ORM, step back and ask:
“What am I actually trying to accomplish here?”
Let the problem drive the architecture, not the limitations of your current tool.
Final Thoughts
The speed and convenience ORMs offer makes them risky especially when you’re moving fast. In the rush to ship features, it’s easy to let ORM models infiltrate into every layer: business logic, APIs, even tests. You save time in the short term, but you’re quietly coupling your entire system to a persistence layer meant to be an implementation detail.
This makes your app:
- Harder to test
- Slower to change
- More fragile when requirements evolve
And because ORMs leak behavior and performance, you still end up needing to understand SQL. That abstraction doesn’t hold under pressure.
The real danger isn’t using an ORM. It’s building around it.
When you’re moving fast, keep a mental checklist:
- Am I mixing logic with data access?
- Am I solving a data problem or a modeling one?
- Would I still design it this way if I weren’t using an ORM?
Used thoughtfully, ORMs are helpful (especially for prototyping). Used blindly, they become invisible technical debt.
Leave a Reply