When starting a new Spring Boot project, choosing an ORM can be surprisingly tricky. JPA or MyBatis — which should you pick? Many developers have been put on the spot when a teammate asks “which one is better?”
This article compares the characteristics of JPA and MyBatis, then walks through selection criteria and patterns for using both together based on your project’s requirements.
Fundamental Differences Between JPA and MyBatis
Let’s start by understanding the core design philosophy behind each.
JPA takes an object-oriented approach. You design around Java entity classes, and mapping to the database is handled automatically. JPA is a specification, with implementations provided by frameworks like Hibernate.
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
}
MyBatis takes a SQL-centric approach. You write SQL explicitly 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 has a significant impact on day-to-day usability and each tool’s strengths.
Learning Curve and Ramp-Up Cost
Choosing based on your team’s technical skill level is an important perspective.
MyBatis is usable as soon as you can write SQL. Just write SQL in Mapper XML and bind it to a Java interface — the barrier to entry is low.
JPA, on the other hand, requires understanding its own concepts: entity design, lazy loading, and the persistence context. The initial learning cost is higher, but once you’re comfortable with it, CRUD operations become remarkably simple.
If your team is strong at SQL, MyBatis will feel natural. If they’re used to object-oriented design, JPA will be the easier fit.
Comparing Simple CRUD Operations
Let’s look at how different basic data access looks between the two.
With JPA
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByStatus(String status);
}
Simply extending the Repository interface gives you basic CRUD methods automatically. Search methods are also auto-generated from method name 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>
You need both a Mapper interface and an XML file. Writing SQL explicitly means more code.
For projects with lots of boilerplate CRUD operations, JPA’s code reduction benefits are substantial.
Comparing Complex Queries
What about screens with heavy JOINs or aggregation?
Complex Queries with 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 this in JPQL, but readability suffers as queries grow complex. Using the Criteria API makes it even more verbose.
Complex Queries with 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>
Writing raw SQL keeps even complex queries readable. Dynamic SQL is also a strong suit 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>
Conditional branching is intuitive with <if> and <choose> tags. Using <trim> and <foreach> enables even more flexible dynamic SQL construction.
For reporting screens, analytics features, or anything requiring complex SQL, MyBatis is easier to work with.
Performance Characteristics
JPA is convenient, but you need to watch out for the N+1 problem.
List<User> users = userRepository.findAll();
for (User user : users) {
user.getOrders().size(); // An additional query is issued here
}
This can be avoided with proper fetch strategy configuration. Performance optimization covers this in detail.
@Query("SELECT u FROM User u LEFT JOIN FETCH u.orders")
List<User> findAllWithOrders();
Because MyBatis gives you complete control over the SQL being issued, unexpected queries never run. That said, improper resultMap configuration (particularly nested collection mappings) can still produce N+1-like behavior.
JPA offers advanced caching features such as second-level cache, but configuration can get complex. MyBatis’s cache is simpler, but is only effective within the same SqlSession by default, which limits its use in distributed environments.
Maintainability Considerations
For long-term operation, maintainability matters too.
With JPA, when the table structure changes, updating the entity class causes many queries to follow automatically. Adding a column requires minimal code changes.
With MyBatis, SQL changes are required, but the scope of impact is clear. It’s immediately obvious which Mapper to modify, making large-scale changes easy to track.
For unit testing, mocking is simpler with MyBatis. JPA involves managing the persistence context, which makes test configuration somewhat more involved.
Practical Selection Criteria
So how do you choose? Base your decision on your project’s characteristics.
- CRUD-heavy business applications → JPA recommended. Productivity scales with the volume of routine operations.
- Complex reporting and analytics screens → MyBatis recommended. Flexible SQL control shines here.
- Team has strong SQL proficiency → MyBatis will feel natural.
- Frequent schema changes → JPA’s automatic adaptation is convenient.
- Retrofitting onto an existing database → MyBatis’s flexibility is an asset.
There is no absolute winner. The right answer is to choose based on your requirements and team characteristics.
Using JPA and MyBatis Together
In fact, you can use both at the same time. With Spring Boot, the configuration is straightforward.
Splitting by use case is an effective approach:
- Routine CRUD operations → JPA
- Complex searches and aggregations → MyBatis
Transaction management can be unified under a common PlatformTransactionManager, so @Transactional provides consistent control across both.
Making the separation explicit with package naming keeps things clear:
com.example.repository (JPA)
com.example.mapper (MyBatis)
In the service layer, you can inject JpaRepository for CRUD operations and Mapper for report generation.
Configuration Example for Combined Use
Let’s look at a concrete setup.
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 value should vary by environment:
- create — Recreates tables on startup (early development)
- update — Automatically updates the schema (development environment)
- validate — Schema validation only (staging and production)
- none — Does nothing (when managed by a separate tool like Flyway)
validate or none is recommended for production environments.
Configuration Class
@Configuration
@EnableJpaRepositories(basePackages = "com.example.repository")
@MapperScan("com.example.mapper")
public class DataAccessConfig {
// DataSource and TransactionManager are auto-configured
}
Specifying basePackages explicitly clarifies the scan scope for JPA and MyBatis, preventing conflicts.
Migrating Between MyBatis and JPA
Sometimes you need to change the technology stack in an existing project.
A gradual migration is the practical approach:
- Start implementing new features with the target technology
- Run both in parallel (a coexistence period)
- Replace existing features incrementally, starting with lower-risk areas
- Proceed while ensuring sufficient test coverage
MyBatis → JPA Migration
Entity design and relationship mapping are the key challenges. JOINs that were written explicitly in MyBatis are expressed through JPA relationships (@OneToMany, @ManyToOne, etc.).
JPA → MyBatis Migration
Implicit queries need to be made explicit. The bulk of the work involves writing out the queries that JPA was auto-generating into Mapper XML. Be aware that N+1 problems hidden behind lazy loading may surface during migration.
In either direction, it is safest to first establish an environment where both can run in parallel, then migrate incrementally.
Summary
Whether to choose JPA or MyBatis depends on your requirements.
If CRUD operations are your primary workload, JPA’s productivity advantages are clear. If you need complex SQL, MyBatis’s fine-grained control is the right tool.
Using both together is also a realistic option. Leveraging each where it excels lets you get the best of both worlds.
The recommendation is to try each out on a small feature first to see how well it fits your team. For example, implement user CRUD with JPA and a sales aggregation report with MyBatis.
There is no single correct answer for technology selection. Make the choice that fits your project and your team.