When starting a new Spring Boot project, choosing an ORM can be a real headache. Should you go with JPA or MyBatis? Many of you have probably been stuck when a teammate asked, “Which one’s better?”

This article compares the characteristics of JPA and MyBatis, and explains selection criteria and combination patterns based on project requirements.

Basic Differences Between JPA and MyBatis

First, let’s cover the difference in design philosophy between the two.

JPA takes an object-oriented approach. It’s designed around Java entity classes, and the mapping to the database is handled automatically. JPA is a specification, and implementations such as Hibernate provide the actual functionality.

@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 the results are mapped to Java objects.

<select id="findById" resultType="User">
    SELECT id, name, email FROM users WHERE id = #{id}
</select>

This fundamental difference greatly affects usability and the areas each tool excels at.

For those encountering MyBatis for the first time, let’s go over the basics.

MyBatis is a persistence framework for writing SQL directly and mapping the results to Java objects. It was originally called iBATIS, and was renamed to MyBatis in 2010 when it moved from Apache to Google Code. Unlike JPA, it doesn’t hide SQL. You write SQL in Mapper XML or annotations and map it to methods on a Java interface.

In Spring Boot, you can get started right away just by adding the mybatis-spring-boot-starter dependency.

implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'

Add @MapperScan to your main class or a configuration class to specify the package where your Mapper interfaces live.

@SpringBootApplication
@MapperScan("com.example.mapper")
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

After that, all you need to do is write Mapper interfaces, and the Spring DI container automatically generates and injects the proxy implementations.

The Difference Between MyBatis’s #{} and ${}

One thing you absolutely need to understand when using MyBatis is the difference between the parameter notations #{} and ${}. They behave very differently in terms of both execution and security.

NotationInternal behaviorSQL injection resistanceMain use
#{value}Converted to a PreparedStatement placeholder (?), with the value boundYes (safe)General WHERE/INSERT parameters
${value}Directly substituted into the SQL stringNo (dangerous)Dynamic identifiers like table names, column names, ORDER BY clauses
<!-- Safe: the value is bound -->
<select id="findByName" resultType="User">
    SELECT * FROM users WHERE name = #{name}
</select>

<!-- Dangerous: string substitution, so SQL injection is possible -->
<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 you do use ${}, be sure to 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 distinction prevents nearly all MyBatis-specific security incidents.

Learning Curve

Choosing based on your team’s technical skill level is also an important perspective.

With MyBatis, if you can write SQL, you can get started right away. You just write SQL in a Mapper XML file and tie it to a Java interface. The learning bar is low.

JPA, on the other hand, requires understanding concepts unique to it, such as entity design, lazy loading, and the persistence context. The initial learning cost is higher, but once you get used to it, CRUD operations become remarkably simple.

If your team is strong in SQL, MyBatis will feel natural; if they’re comfortable with object-oriented design, JPA will be easier to work with.

Comparing Simple CRUD Operations

Let’s see how much of a difference there is for basic data access.

With JPA

public interface UserRepository extends JpaRepository<User, Long> {
    List<User> findByStatus(String status);
}

Just by extending the Repository interface, basic CRUD methods become available automatically. Query methods are also auto-generated through 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. Since you write SQL explicitly, the amount of code increases.

For projects with a lot of routine CRUD operations, JPA’s code reduction effect is significant.

Comparing Complex Queries

What about screens with a lot of joins or aggregation?

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 this in JPQL, but as it gets more complex, readability drops. 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, you can keep even complex queries readable. Dynamic SQL is also one of MyBatis’s strengths.

<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>

The <if> and <choose> tags let you write conditional branches intuitively. Using <trim> and <foreach> enables even more flexible dynamic SQL construction.

For areas that need complex SQL, such as reporting screens or analytics features, MyBatis is easier to work with.

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(); // An additional query is issued here
}

You can avoid this by setting an appropriate fetch strategy. This is covered 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 that’s issued, so unexpected queries never run. However, an improperly configured resultMap (nested collection mapping) can also cause N+1-like problems.

JPA has advanced caching features like a second-level cache, but the configuration tends to get complicated. MyBatis’s cache is simple, but by default it’s only valid within the same SqlSession, which is a limitation in distributed environments.

Maintainability Considerations

For long-term operation, maintainability matters too.

With JPA, when the table structure changes, modifying the entity class causes many queries to follow along automatically. For something like adding a column, the code changes needed are minimal.

With MyBatis, you need to change the SQL, but the scope of impact is clear. It’s obvious at a glance which Mapper needs to be modified, making it easy to track even large-scale changes.

As for unit testing, mocking is simpler with MyBatis. JPA requires managing the persistence context, which makes test configuration somewhat more complex.

Here’s a summary table of everything covered so far. Use it as a reference when weighing your project’s requirements.

Comparison axisJPA (Spring Data JPA)MyBatis
Design philosophyObject-oriented (entity-centric)SQL-centric (explicit SQL)
Learning costHigher (persistence context, lazy loading, etc.)Lower (can start if you can write SQL)
Amount of code for routine CRUDVery little (method name conventions)More (Mapper + XML required)
Complex SQL/JOINsPossible with JPQL/Criteria, but readability dropsNaturally expressible with raw SQL
Dynamic SQLSpecification/@Query + JPQLIntuitive with <if> <choose> <foreach>
Performance controlFetch strategy needed for N+1 mitigationFull control over issued SQL
CachingHas first/second-level cache mechanisms (complex config)Per SqlSession (simple)
Following schema changesEntity changes propagate broadlyModify the relevant Mapper individually
TestabilityPersistence context setup somewhat neededMocking relatively simple
Retrofitting to an existing DBNeed to reconcile schema/entity gapsFollows the existing schema as-is
Suited screensBusiness CRUD, master data managementReports, analytics, complex searches

As the table shows, the two aren’t simply better or worse than each other — they have a relationship where each has its own strong suit.

Practical Selection Criteria

So how should you choose? Judge based on the project’s characteristics.

  • CRUD-centric business applications → JPA recommended. The more routine operations there are, the more productivity improves
  • Complex reporting/analytics screens → MyBatis recommended. Flexible SQL control shines here
  • Team is highly proficient in SQL → MyBatis will feel natural to use
  • Schema changes are frequent → JPA’s automatic propagation is convenient
  • Retrofitting onto an existing DB → MyBatis’s flexibility is helpful

There’s no absolute right or wrong answer. Choosing based on requirements and team characteristics is the correct approach.

Combining JPA and MyBatis

Actually, you can use both at the same time. With Spring Boot, the setup is simple too.

Dividing by use case is effective.

  • Regular CRUD operations → JPA
  • Complex searches/aggregations → MyBatis

Transaction management can be unified through a common PlatformTransactionManager, so you can control everything consistently with the @Transactional annotation.

Separating by package makes the distinction clear.

com.example.repository (JPA)
com.example.mapper (MyBatis)

In the Service layer, you can use JpaRepository for CRUD operations and inject a Mapper for report generation, dividing things up 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 differ depending on the environment.

  • create - Recreates tables at startup (early development)
  • update - Auto-updates the schema (development environment)
  • validate - Schema validation only (staging/production)
  • none - Does nothing (when managing the schema with a separate tool like Flyway)

In 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
}

Explicitly specifying basePackages makes the scanning scope for JPA and MyBatis clear and prevents conflicts.

Migrating from MyBatis to JPA, or JPA to MyBatis

There are also cases where you need to change tech stacks within an existing project.

A phased migration is realistic.

  1. Start implementing new features with the target technology
  2. Keep existing features running in parallel (the combined-use period)
  3. Replace lower-risk areas first
  4. Proceed while ensuring sufficient test coverage

Migrating from MyBatis to JPA

Entity design and relationship mapping are the key points. JOINs that were explicitly written in MyBatis get expressed through JPA relationships (@OneToMany, @ManyToOne, etc.).

Migrating from JPA to MyBatis

You need to make implicit queries explicit. The main work involves writing out queries that JPA was auto-generating into Mapper XML. Be careful, because N+1 problems that were hidden behind lazy loading can surface during migration.

In either case, it’s safest to first set up an environment where both can run in parallel, then migrate in stages.

Summary

Whether to choose JPA or MyBatis depends on your requirements.

If CRUD operations are central, JPA’s productivity shines. If you need complex SQL, MyBatis’s control is valuable.

Using both together is also a realistic option. By dividing the work according to each tool’s strengths, you can enjoy the benefits of both.

It’s a good idea to first try it out on a small-scale feature and check how well it fits your team. For example, you might implement user CRUD with JPA and a sales aggregation report with MyBatis.

There’s no absolute right answer in technology selection. Choose what fits your project and team.

The Difference Between iBATIS and MyBatis

For those who arrived here searching for “ibatis mybatis difference,” let’s clarify the relationship. iBATIS is the predecessor to MyBatis, and it was renamed to MyBatis when the Apache iBATIS project moved to Google Code in 2010. It wasn’t just a name change — annotation support and integration with Java Config advanced beyond the verbose XML-based style, and integration with Spring Boot has also become easy through mybatis-spring-boot-starter. There are no new projects adopting iBATIS these days, and in Spring Boot projects, using MyBatis as described in this article is the standard approach.

The idea of explicitly separating JPA and MyBatis by package also pairs well with a design that declaratively enforces module boundaries. If you want to systematize boundary management itself, the package boundary verification techniques explained in How to Achieve a Modular Monolith with Spring Modulith are a useful reference.

Note that using JPA and MyBatis together also increases connection requests against the same DataSource, so for pool size design when using a primary/replica configuration or multiple data sources, see HikariCP Pool Size Design When Using Multiple Data Sources in Spring Boot for a detailed explanation.

To continuously track how connections are being used in production, it’s also a good idea to introduce the metrics monitoring described in How to Monitor HikariCP Connection Pools in Spring Boot with Micrometer and Actuator.