When starting a new Spring Boot project, choosing an OR mapper can be a tough decision. JPA or MyBatis—which should you pick? Many of you have probably been put on the spot when team members ask, “Which one is better?”
This article compares the characteristics of JPA and MyBatis, explaining the selection criteria and combined usage patterns based on project requirements.
Fundamental Differences Between JPA and MyBatis
What is MyBatis - Basic Concepts and Its Position in Spring Boot
For those new to MyBatis, let’s start with the fundamentals.
MyBatis is a persistence framework that allows you to write SQL directly and map it to Java objects. It was originally named iBATIS and was renamed to MyBatis when it moved from Apache to Google Code in 2010. Unlike JPA, it does not hide SQL. You write SQL in Mapper XML or annotations and map it to methods in Java interfaces.
In Spring Boot, you can start using it immediately by simply adding mybatis-spring-boot-starter to your dependencies.
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
Add @MapperScan to your main class or configuration class to specify the package where Mapper interfaces are located.
@SpringBootApplication
@MapperScan("com.example.mapper")
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
After that, just write Mapper interfaces, and the Spring DI container will automatically generate and inject proxy implementations.
The Difference Between MyBatis #{} and ${}
One thing you must understand when using MyBatis is the difference between the parameter notations #{} and ${}. They behave very differently in terms of operation and security.
| Notation | Internal Behavior | SQL Injection Resistance | Main Use |
|---|---|---|---|
#{value} | Converted to a PreparedStatement placeholder (?), with the value bound | Yes (safe) | General WHERE/INSERT parameters |
${value} | Directly substituted into the SQL string | No (dangerous) | Dynamic identifiers like table names, column names, ORDER BY clauses |
<!-- Safe: value is bound -->
<select id="findByName" resultType="User">
SELECT * FROM users WHERE name = #{name}
</select>
<!-- Dangerous: string substitution, vulnerable to SQL injection -->
<select id="findOrdered" resultType="User">
SELECT * FROM users ORDER BY ${columnName}
</select>
The basic rule is: “Always use #{} for user input, and only use ${} for identifiers (table names, column names, ASC/DESC).” When using ${}, always validate the value against a whitelist beforehand.
private static final Set<String> ALLOWED_COLUMNS = Set.of("id", "name", "created_at");
public List<User> findOrdered(String columnName) {
if (!ALLOWED_COLUMNS.contains(columnName)) {
throw new IllegalArgumentException("invalid column: " + columnName);
}
return userMapper.findOrdered(columnName);
}
Understanding this difference will prevent most MyBatis-specific security incidents.
First, let’s understand the difference in design philosophy between the two.
JPA takes an object-oriented approach. You design around Java entity classes, and mapping to the database is done automatically. JPA is a specification, with implementations provided by libraries like Hibernate.
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
}
MyBatis takes an SQL-centric approach. You explicitly write SQL and map the results to Java objects.
<select id="findById" resultType="User">
SELECT id, name, email FROM users WHERE id = #{id}
</select>
This fundamental difference significantly affects usability and the areas where each excels.
Learning Cost and Proficiency Curve
It’s also important to choose based on your team’s technical level.
MyBatis can be used right away if you can write SQL. Just write SQL in a Mapper XML and link it to a Java interface. The barrier to entry is low.
On the other hand, JPA requires you to understand its unique concepts like entity design, lazy loading, and the persistence context. The initial learning cost is high, but once you get used to it, CRUD operations become surprisingly easy.
If your team is strong in SQL, MyBatis is easier; if they’re familiar with object-oriented design, JPA will be more manageable.
Comparison with Simple CRUD Operations
Let’s see how different they are for basic data access.
With JPA
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByStatus(String status);
}
By simply extending the Repository interface, basic CRUD methods become available automatically. Search methods are also auto-generated based on method naming conventions like findByXxx.
With MyBatis
@Mapper
public interface UserMapper {
List<User> findByStatus(@Param("status") String status);
void insert(User user);
void update(User user);
void delete(Long id);
}
<mapper namespace="com.example.mapper.UserMapper">
<select id="findByStatus" resultType="com.example.model.User">
SELECT * FROM users WHERE status = #{status}
</select>
<insert id="insert">
INSERT INTO users (name, email, status)
VALUES (#{name}, #{email}, #{status})
</insert>
<update id="update">
UPDATE users
SET name = #{name}, email = #{email}, status = #{status}
WHERE id = #{id}
</update>
<delete id="delete">
DELETE FROM users WHERE id = #{id}
</delete>
</mapper>
Both the Mapper interface and the XML file are required. Since you have to write SQL explicitly, the amount of code increases.
For projects with many routine CRUD operations, JPA’s code-reduction effect is significant.
Comparison with Complex Queries
What about screens with many JOINs or aggregation operations?
Complex Queries in JPA
@Query("""
SELECT u FROM User u JOIN u.orders o
WHERE o.orderDate BETWEEN :start AND :end
GROUP BY u.id
HAVING SUM(o.amount) > :threshold
""")
List<User> findHighValueCustomers(
@Param("start") LocalDate start,
@Param("end") LocalDate end,
@Param("threshold") BigDecimal threshold
);
You can write it in JPQL, but readability suffers as complexity increases. Using the Criteria API makes it even more verbose.
Complex Queries in MyBatis
<select id="findHighValueCustomers" resultType="User">
SELECT u.*, SUM(o.amount) as total_amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.order_date BETWEEN #{start} AND #{end}
GROUP BY u.id
HAVING SUM(o.amount) > #{threshold}
</select>
Since you can write raw SQL, complex queries remain readable. Dynamic SQL is also a strong point of MyBatis.
<select id="searchUsers" resultType="User">
SELECT * FROM users
<where>
<if test="name != null">
AND name LIKE #{name}
</if>
<if test="status != null">
AND status = #{status}
</if>
</where>
</select>
You can write conditional branches intuitively with <if> and <choose> tags. Using <trim> or <foreach> enables even more flexible dynamic SQL construction.
For parts requiring complex SQL, like reporting screens or analysis features, MyBatis is easier to work with.
Differences in Performance Characteristics
While JPA is convenient, you need to watch out for the N+1 problem.
List<User> users = userRepository.findAll();
for (User user : users) {
user.getOrders().size(); // additional queries are issued here
}
You can avoid this by setting an appropriate fetch strategy. This is explained in detail in performance optimization.
@Query("SELECT u FROM User u LEFT JOIN FETCH u.orders")
List<User> findAllWithOrders();
With MyBatis, you have complete control over the SQL being issued, so unexpected queries won’t run. However, with inappropriate resultMap settings (nested collection mapping), N+1-like problems can still occur.
JPA has advanced caching features like second-level cache, but configuration tends to be complex. MyBatis caching is simple, but by default it’s only effective within the same SqlSession, with constraints when used in distributed environments.
Maintainability Perspective
Comparison Table of JPA and MyBatis Characteristics
Here’s a summary of the content so far for easy reference. Use it as a basis for decisions when matching against your project requirements.
| Comparison Axis | JPA (Spring Data JPA) | MyBatis |
|---|---|---|
| Design Philosophy | Object-oriented (entity-centric) | SQL-centric (explicit SQL) |
| Learning Cost | High (persistence context, lazy loading, etc.) | Low (start if you can write SQL) |
| Routine CRUD Code Volume | Very low (method naming conventions) | High (Mapper + XML required) |
| Complex SQL/JOIN | Possible with JPQL/Criteria, but readability suffers | Naturally expressed with raw SQL |
| Dynamic SQL | Specification/@Query + JPQL | Intuitive with <if> <choose> <foreach> |
| Performance Control | Fetch strategy needed for N+1 mitigation | Complete control over issued SQL |
| Caching | First/second-level cache mechanisms (complex config) | SqlSession-level (simple) |
| Schema Change Adaptation | Wide-ranging follow-through by modifying entities | Modify individual Mappers |
| Testability | Persistence context configuration somewhat required | Mocking is relatively simple |
| Retrofitting to Existing DB | Need to adjust gaps between schema and entities | Can adapt directly to existing schema |
| Suitable Screens | Business CRUD, master data management | Reports, analysis, complex search |
As shown in the table, the two have a relationship of division of labor by strengths rather than a simple superiority comparison.
Maintainability is important when considering long-term operation.
When the table structure changes in JPA, many queries automatically adapt by modifying the entity class. For something like adding a column, code changes are minimal.
MyBatis requires SQL changes, but the scope of impact is clear. You can see at a glance which Mapper needs to be modified, making large-scale changes easier to track.
For unit testing, MyBatis is simpler to mock. Since JPA requires management of the persistence context, the test configuration becomes somewhat more complex.
Practical Selection Criteria
So how should you choose? Decide based on your project’s characteristics.
- CRUD-centric business applications → JPA recommended. Productivity increases with more routine operations
- Complex report/analysis screens → MyBatis recommended. Flexible SQL control shines here
- Team with high SQL proficiency → MyBatis can be handled naturally
- Frequent schema changes → JPA’s automatic adaptation is convenient
- Retrofitting onto an existing DB → MyBatis’s flexibility is helpful
There’s no absolute superiority. The right answer is to choose based on requirements and team characteristics.
Combined Usage Patterns of JPA and MyBatis
Actually, you can use both at the same time. With Spring Boot, the configuration is also simple.
Dividing usage by purpose is effective.
- Normal CRUD operations → JPA
- Complex search/aggregation → MyBatis
Since transaction management can be unified under a common PlatformTransactionManager, you can control everything consistently with the @Transactional annotation.
Explicitly distinguishing through package separation makes things clearer.
com.example.repository (JPA)
com.example.mapper (MyBatis)
In the Service layer, you can inject JpaRepository for CRUD operations and Mapper for report generation as needed.
Configuration Example for Combined Use
Let’s look at a concrete configuration.
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
runtimeOnly 'com.h2database:h2'
}
application.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: validate
show-sql: true
mybatis:
mapper-locations: classpath:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
The ddl-auto setting value should be chosen based on the environment.
- create - Recreates tables at startup (early development)
- update - Automatically updates the schema (development environment)
- validate - Schema validation only (staging/production)
- none - Does nothing (when managing with separate tools like Flyway)
For production, validate or none is recommended.
Configuration Class
@Configuration
@EnableJpaRepositories(basePackages = "com.example.repository")
@MapperScan("com.example.mapper")
public class DataAccessConfig {
// DataSource and TransactionManager are auto-configured
}
By explicitly specifying basePackages, the scan ranges for JPA and MyBatis become clear, preventing conflicts.
Migrating from MyBatis to JPA, and JPA to MyBatis
There are also cases where you change the technology stack in existing projects.
A phased migration is realistic.
- Start implementing new features with the target technology
- Run existing features in parallel (transition period)
- Replace low-risk areas in order
- Proceed while ensuring sufficient test coverage
Migration from MyBatis to JPA
Entity design and relationship mapping are the key points. JOINs that were explicitly written in MyBatis are expressed through JPA relationships (@OneToMany, @ManyToOne, etc.).
Migration from JPA to MyBatis
Making implicit queries explicit is necessary. The work mainly involves writing out queries that JPA auto-generated into Mapper XML. N+1 problems hidden by lazy loading may also surface during migration, so caution is needed.
In either case, it’s safer to first prepare an environment where parallel operation is possible, then migrate in stages.
Summary
Whether to choose JPA or MyBatis depends on the requirements.
If CRUD operations are central, JPA’s productivity shines. If complex SQL is needed, MyBatis’s controllability is helpful.
Combined use is also a realistic option. By dividing usage based on each one’s strengths, you can enjoy the benefits of both.
I recommend trying it on a small-scale feature first to confirm compatibility with your team. For example, implement user CRUD with JPA and sales aggregation reports with MyBatis.
There’s no absolute correct answer in technology selection. Make the choice that fits your project and team.