The query looked harmless. We were fetching a list of orders. The API returned the expected data, the response time looked fine in development, and nothing seemed unusual.
Then production happened. A single API request was suddenly responsible for executing hundreds of SQL queries. The application wasn’t running a complicated algorithm, and there wasn’t an obvious infinite loop.

The problem was much simpler: we were accessing a relationship inside a loop. And Hibernate was doing exactly what we had configured it to do. We just hadn’t realized how many database calls that configuration could produce.
This is one of the reasons I’ve started thinking about Hibernate and MyBatis less as competing persistence frameworks and more as two different philosophies:
Hibernate gives you more abstraction and automation. MyBatis gives you more visibility and control.
And that difference becomes especially interesting when you’re debugging database performance.
The Real Difference Isn’t “ORM vs SQL”
At first glance, Hibernate and MyBatis can look like two tools solving the same problem—both help Java applications talk to relational databases. But they approach it very differently.
With Hibernate, you generally work with entities and relationships: You don’t necessarily care about the SQL being executed at every step. Hibernate manages much of the persistence behavior for you.
With MyBatis, you’re much closer to the database, and the SQL is something you explicitly define:
Neither approach is inherently better. The real question is, how much control do you want the framework to have over your database interactions?
Hibernate: Let the Framework Manage Persistence
Hibernate is an ORM—an object-relational mapping framework. Instead of thinking primarily in terms of tables and SQL queries, you work with Java objects:
@Entity
public class Order {
@Id
private Long id;
private BigDecimal amount;
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
}
Then your repository might look like this:
public interface OrderRepository
extends JpaRepository<Order, Long> {
}
Fetching an order is straightforward, and accessing its customer feels natural:
Order order = orderRepository.findById(orderId)
.orElseThrow();
String customerName = order.getCustomer().getName();
That’s the beauty of ORM. You think about objects, Hibernate thinks about persistence, and the application code becomes cleaner with a lot of repetitive SQL disappearing. But that abstraction has a cost—sometimes the database is doing more work than the Java code makes obvious.
When Hibernate’s Magic Bites Back
Let’s look at a common example. Suppose we fetch 100 orders, then iterate through them:
List<Order> orders = orderRepository.findAll();
for (Order order : orders) {
System.out.println(
order.getCustomer().getName()
);
}
At first glance, this looks like one database operation. But with lazy-loaded relationships, one query fetches the 100 orders, and then every single order.getCustomer() call inside that loop fires its own separate query—100 more, one per order. That's 101 queries for what reads like a two-line loop.

That’s the classic N+1 query problem—I actually wrote a full deep dive on exactly this bug here, if you want the mechanics in more depth. The important point for this article is that Hibernate isn’t necessarily doing something wrong—lazy loading exists for a reason. The problem is that an innocent-looking line like this order.getCustomer() can trigger a database query, and the database interaction is no longer completely obvious from the code. That's where the abstraction can become dangerous.
But Does MyBatis Solve N+1?
Not automatically, and this distinction matters. It’s tempting to say, “MyBatis doesn’t have the N+1 problem"—that's not true. You can absolutely write N+1 queries using MyBatis:
List<User> users = userMapper.findAllUsers();
for (User user : users) {
userMapper.findOrdersByUserId(user.getId());
}
That still results in 1 query to fetch users plus N queries to fetch orders—N+1, the same as before. The difference is where the behavior comes from. MyBatis doesn’t automatically lazy-load a relationship because you accessed a Java property; you explicitly call another mapper method. The database interaction is therefore more visible in your application code. Subtle difference, but an important one.
MyBatis: You Own the SQL
MyBatis takes a different approach. Instead of saying, "Here's my object model; figure out how to persist it,” you can say, "Here's the SQL I want to execute; map the result to this Java object."
<select id="findOrderWithCustomer"
resultMap="orderResultMap">
SELECT
o.id,
o.amount,
c.id AS customer_id,
c.name AS customer_name
FROM orders o
JOIN customers c
ON o.customer_id = c.id
WHERE o.id = #{orderId}
</select>
The Java mapper might look like this:
@Mapper
public interface OrderMapper {
Order findOrderWithCustomer(Long orderId);
}
Now the relationship is resolved through a single SQL statement, and there’s much less guessing involved. You decide which tables to join, which columns to select, which indexes matter, how the filtering works, how pagination works, whether a subquery makes sense, and whether a database-specific feature should be used. That control can be extremely valuable, especially when you’re working with complex queries.
The Trade-Off: Control Comes With Responsibility
There’s a catch. MyBatis doesn’t magically make database access easier—it moves more responsibility toward the developer.
With Hibernate, you can write orderRepository.findById(orderId) and let the framework handle a lot of persistence details. With MyBatis, you may have to maintain handwritten SQL-like:
SELECT
o.id,
o.amount,
o.created_at,
c.id,
c.name
FROM orders o
JOIN customers c
ON o.customer_id = c.id
WHERE o.id = #{orderId};
And when the database schema changes, your SQL may need to change too. When you have dozens or hundreds of queries, SQL maintenance becomes a real concern. This leads to an important observation: Hibernate hides complexity. MyBatis exposes complexity. Neither eliminates it. That’s really the trade-off.
Hibernate’s Strength: Productivity
Hibernate shines when your application maps naturally to an object-oriented domain model—think a User with Orders, each with, andItems, and plus Addresses. Managing all of those relationships manually can become tedious, and Hibernate provides abstractions for entity relationships, lazy loading, dirty checking, cascading, transactions, first-level caching, query generation, and object state management. For a typical CRUD-heavy business application, that can save a lot of development time.
You can modify an entity with, order.setStatus(OrderStatus.PAID); and Hibernate detects the change and generates the appropriate one UPDATE for you. You don't necessarily have to write UPDATE orders SET status = ? WHERE id = ?; yourself. That's a huge productivity advantage.
MyBatis’s Strength: Predictability
Now imagine a different type of application—reporting queries involving orders, customers, payments, transactions, aggregations, date ranges, and multiple filters, where the SQL itself might be the most important part of the application. You may need complex joins, window functions, common table expressions, database-specific features, carefully optimized indexes, custom pagination, and precise execution plans.
In these scenarios, having direct control over SQL can be very useful. Instead of trying to convince an ORM to generate exactly the query you want, you can write the query:
WITH customer_totals AS (
SELECT
customer_id,
SUM(amount) AS total_amount
FROM orders
WHERE created_at >= #{startDate}
GROUP BY customer_id
)
SELECT
c.id,
c.name,
ct.total_amount
FROM customers c
JOIN customer_totals ct
ON c.id = ct.customer_id
ORDER BY ct.total_amount DESC;
That’s where MyBatis can feel much more natural.
**
Hibernate vs MyBatis: A Different Kind of Abstraction**
Here’s how I think about the difference. With Hibernate, you’re primarily working at the object level—Java objects flow through Hibernate, which generates the SQL, which hits the database. With MyBatis, you’re much closer to the SQL level—your Java code flows through MyBatis, straight into the SQL you wrote yourself, straight to the database.
Neither is automatically faster. Neither automatically prevents bad queries. And neither eliminates the need to understand databases. That’s an important point: choosing MyBatis doesn’t replace database knowledge. Choosing Hibernate doesn’t remove the need for database knowledge.
What About Performance?
This is probably the first question people ask: which one is faster, Hibernate or MyBatis? The honest answer is it depends—there isn’t a universal winner. A well-designed Hibernate application can perform extremely well, and a poorly designed MyBatis application can perform terribly. For example, this MyBatis code can still produce N+1 queries:
On the other hand, Hibernate can be configured to fetch data efficiently using JOIN FETCHentity graphs, batch fetching, and projections:
Now the relationship fetches in a single query. The framework doesn’t determine performance by itself—the way we use the framework does.
The Database Still Matters
This is where ORM discussions sometimes go wrong. Developers can spend hours debating Hibernate vs. MyBatis while ignoring the more important questions: is the query indexed, what does EXPLAIN it say, and how many rows are actually being scanned? I went deep on exactly this in my indexing article—the short version is that an index is what turns "scan every row" into "jump straight to the match," and that's true no matter which persistence framework sits on top of it. Suppose your query is
SELECT *
FROM orders
WHERE customer_id = ?
AND status = ?
ORDER BY created_at DESC;
Whether you’re using Hibernate or MyBatis, the database still has to execute that query. If the appropriate index doesn’t exist, changing the Java persistence framework isn’t going to fix the underlying database problem magically. The abstraction layer matters, but the database still matters more.
When Should You Use Hibernate?
Hibernate is a strong choice when your application has a rich domain model, CRUD operations dominate, entity relationships are important, you want automatic dirty checking, you want to reduce SQL boilerplate, your team is comfortable with JPA/Hibernate, productivity matters more than having SQL everywhere, and most queries are relatively straightforward. A typical business application can benefit significantly from this approach.
When Should You Consider MyBatis?
MyBatis becomes attractive when SQL is complex, you need precise query control, reporting is a major part of the application, database-specific features are important, you’re working with an existing or legacy database, query performance requires frequent SQL-level tuning, your team is strong in SQL, and you don’t need a heavy ORM abstraction. In these situations, controlling the SQL directly can simplify rather than complicate development.
And You Don’t Always Have to Choose One
This often gets missed in these comparisons. A real application doesn’t have to be Hibernate or MyBatis—it can be both, split by responsibility. Hibernate can handle your normal entity lifecycle: creating an order, updating a payment, and finding a customer. MyBatis can handle complex reports, analytics queries, database-specific SQL, and performance-sensitive reads. The right abstraction can even vary within the same application.
Top comments (0)