Home Java Spring Boot + MyBatis: XML vs Annotations – Which to Pick?
Intermediate 5 min · July 14, 2026

Spring Boot + MyBatis: XML vs Annotations – Which to Pick?

Learn how to integrate MyBatis with Spring Boot using XML mappings and annotations.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 15-20 min read
  • Java 17+
  • Spring Boot 3.x
  • Basic knowledge of SQL and JDBC
  • Familiarity with Maven or Gradle
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Use MyBatis when you need fine-grained SQL control and complex mappings that JPA struggles with.
  • Annotations are great for simple CRUD; XML mappings shine for dynamic SQL and legacy databases.
  • Always define Mapper interfaces as Spring beans and use @MapperScan or @Mapper.
  • For dynamic SQL, MyBatis's and in XML beat JPA's criteria API hands down.
  • Beware of N+1 queries: MyBatis doesn't auto-fetch lazy associations like Hibernate.
✦ Definition~90s read
What is Integrating MyBatis with Spring Boot?

MyBatis is a SQL mapping framework that lets you write your own SQL and map results to Java objects with full control, unlike JPA which automates persistence.

Think of MyBatis as a smart assistant that helps you write SQL and map results to Java objects.
Plain-English First

Think of MyBatis as a smart assistant that helps you write SQL and map results to Java objects. Unlike JPA which tries to hide SQL, MyBatis puts you in control. XML mappings are like detailed blueprints for complex queries; annotations are sticky notes for simple ones.

If you've ever fought with Hibernate's lazy loading exceptions or watched in horror as it generated 10+ SQL queries for a simple join, you're not alone. JPA is powerful, but sometimes you need to take the wheel. That's where MyBatis comes in.

MyBatis is a persistence framework that gives you full control over SQL. You write the queries, you decide the mappings. No magic, no surprises. It's the go-to choice for teams working with legacy databases, complex reporting, or performance-critical systems where every millisecond counts.

In this article, I'll show you how to integrate MyBatis with Spring Boot 3.2, covering both XML mappings and annotations. You'll learn when to use each, how to avoid common pitfalls, and what the official documentation conveniently leaves out. By the end, you'll know exactly which approach fits your project and how to debug it when things go sideways.

Why MyBatis Still Matters in 2025

Let's get one thing straight: JPA is not the answer to everything. I've seen teams spend weeks trying to tune Hibernate's second-level cache, only to rip it out and replace it with MyBatis. MyBatis gives you raw SQL power without the overhead of an ORM. You write the SQL, you control the mapping.

Here's the hard truth: most teams get this wrong. They start with JPA because it's the 'standard,' then hit a wall with complex queries or legacy schemas. MyBatis shines when: - You have a legacy database with weird column names and no control over schema. - You need to write dynamic SQL with conditional WHERE clauses, pagination, and batch updates. - Performance is critical and you can't afford Hibernate's query generation overhead. - You're working with stored procedures or database-specific features.

Spring Boot 3.2 makes integration trivial. Add the starter, configure a datasource, define mappers, and you're off. But the devil is in the details—especially when mixing XML and annotations.

pom.xmlJAVA
1
2
3
4
5
6
7
8
9
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>3.0.3</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
🔥Version Compatibility
📊 Production Insight
I once saw a team mix MyBatis and JPA in the same transaction. Hibernate flushed before MyBatis, causing inconsistent reads. Stick to one per transaction or use separate transaction managers.
🎯 Key Takeaway
MyBatis is not a replacement for JPA—it's a tool for when you need fine-grained SQL control.

Getting Started: Dependencies and Configuration

First, add the MyBatis Spring Boot Starter. I recommend version 3.0.3 with Spring Boot 3.2. If you're still on Boot 2.x, use 2.3.x. Don't mix versions—I've seen ClassNotFoundException because of incompatible MyBatis core versions.

In your application.properties (or YAML), configure the datasource and MyBatis settings:

```properties spring.datasource.url=jdbc:mysql://localhost:3306/mydb?useSSL=false&allowPublicKeyRetrieval=true spring.datasource.username=root spring.datasource.password=secret spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

mybatis.mapper-locations=classpath:mapper/*/.xml mybatis.configuration.map-underscore-to-camel-case=true mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl ```

The map-underscore-to-camel-case setting is a lifesaver. It automatically maps user_name to userName in your Java POJO. Without it, you'll spend hours writing explicit mappings.

Now create a Mapper interface. You can use either XML or annotations—or both. I'll show you both approaches.

application.propertiesJAVA
1
2
3
4
5
6
7
8
spring.datasource.url=jdbc:mysql://localhost:3306/mydb?useSSL=false
spring.datasource.username=root
spring.datasource.password=secret
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

mybatis.mapper-locations=classpath:mapper/**/*.xml
mybatis.configuration.map-underscore-to-camel-case=true
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
📊 Production Insight
In production, never use StdOutImpl logging. Use Log4j2 or SLF4J with a proper appender. StdOutImpl can cause thread contention under load.
🎯 Key Takeaway
Always enable map-underscore-to-camel-case unless you enjoy pain.

Annotation-Based Mappings: Simple and Direct

For straightforward CRUD operations, annotations are perfect. You define SQL directly on the method using @Select, @Insert, @Update, @Delete. No XML files needed.

```java @Mapper public interface PaymentMapper { @Select("SELECT * FROM payments WHERE id = #{id}") Payment findById(Long id);

@Insert("INSERT INTO payments(amount, currency, status) VALUES(#{amount}, #{currency}, #{status})") @Options(useGeneratedKeys = true, keyProperty = "id") int insert(Payment payment);

@Update("UPDATE payments SET status = #{status} WHERE id = #{id}") int update(Payment payment);

@Delete("DELETE FROM payments WHERE id = #{id}") int delete(Long id); } ```

Notice @Options(useGeneratedKeys = true). This is critical for auto-increment IDs. If you forget it, the generated key won't be populated in your object.

For complex queries with joins, annotations can get messy. You end up with long strings and no syntax highlighting. That's when XML shines.

PaymentMapper.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Mapper
public interface PaymentMapper {
    @Select("SELECT * FROM payments WHERE id = #{id}")
    Payment findById(Long id);

    @Insert("INSERT INTO payments(amount, currency, status) VALUES(#{amount}, #{currency}, #{status})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    int insert(Payment payment);

    @Update("UPDATE payments SET status = #{status} WHERE id = #{id}")
    int update(Payment payment);

    @Delete("DELETE FROM payments WHERE id = #{id}")
    int delete(Long id);

    @Select("SELECT * FROM payments WHERE status = #{status} ORDER BY created_at DESC")
    List<Payment> findByStatus(@Param("status") String status);
}
💡Use @Param for Method Parameters
📊 Production Insight
I've debugged production issues where a missing @Param caused cryptic errors. Always add it—it's cheap insurance.
🎯 Key Takeaway
Annotations are great for simple queries but become unwieldy for complex SQL.

XML Mappings: Power and Flexibility

XML mappings give you the full power of MyBatis: dynamic SQL, result maps, and reusable fragments. They're essential for complex queries that would be a nightmare in annotations.

Create an XML file in src/main/resources/mapper/PaymentMapper.xml:

```xml <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.mapper.PaymentMapper">

<resultMap id="PaymentResultMap" type="com.example.model.Payment"> <id property="id" column="id" /> <result property="amount" column="amount" /> <result property="currency" column="currency" /> <result property="status" column="status" /> <result property="createdAt" column="created_at" /> </resultMap>

<select id="findById" resultMap="PaymentResultMap"> SELECT * FROM payments WHERE id = #{id} </select>

<select id="findByStatus" resultMap="PaymentResultMap"> SELECT * FROM payments <where> <if test="status != null and status != ''"> AND status = #{status} </if> </where> ORDER BY created_at DESC </select>

<insert id="insert" useGeneratedKeys="true" keyProperty="id"> INSERT INTO payments(amount, currency, status) VALUES(#{amount}, #{currency}, #{status}) </insert>

<update id="update"> UPDATE payments SET status = #{status} WHERE id = #{id} </update>

<delete id="delete"> DELETE FROM payments WHERE id = #{id} </delete> </mapper> ```

Notice the <where> and <if> tags. This is dynamic SQL—one of MyBatis's strongest features. You can build queries conditionally without string concatenation. The <where> tag automatically handles the first AND.

Also note the resultMap. This gives you explicit control over column-to-field mapping. It's verbose but bulletproof. For simple cases, you can use resultType instead, but I always prefer resultMap for clarity.

PaymentMapper.xmlJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.PaymentMapper">
    <resultMap id="PaymentResultMap" type="com.example.model.Payment">
        <id property="id" column="id" />
        <result property="amount" column="amount" />
        <result property="currency" column="currency" />
        <result property="status" column="status" />
        <result property="createdAt" column="created_at" />
    </resultMap>
    <select id="findById" resultMap="PaymentResultMap">
        SELECT * FROM payments WHERE id = #{id}
    </select>
    <select id="findByStatus" resultMap="PaymentResultMap">
        SELECT * FROM payments
        <where>
            <if test="status != null and status != ''">
                AND status = #{status}
            </if>
        </where>
        ORDER BY created_at DESC
    </select>
    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO payments(amount, currency, status)
        VALUES(#{amount}, #{currency}, #{status})
    </insert>
    <update id="update">
        UPDATE payments SET status = #{status} WHERE id = #{id}
    </update>
    <delete id="delete">
        DELETE FROM payments WHERE id = #{id}
    </delete>
</mapper>
⚠ Namespace Must Match Fully Qualified Interface
📊 Production Insight
Dynamic SQL with <foreach> is a common source of SQL injection if you concatenate strings. Always use the OGNL expression syntax and let MyBatis handle parameter substitution.
🎯 Key Takeaway
XML mappings are more verbose but offer dynamic SQL and explicit result mapping.

When to Use Annotations vs XML: A Practical Guide

  • Annotations: Use for simple CRUD, single-table operations, and when you have fewer than 10 queries per mapper. The code is self-contained and easy to read.
  • XML: Use for complex queries with joins, dynamic SQL, stored procedures, and when you need result mappings that differ from column names. Also use XML when you want to keep SQL separate from Java code for DBA review.

But here's the twist: you can mix both in the same mapper. MyBatis allows it. I often define simple queries with annotations and complex ones in XML. Just be consistent within your team.

One thing the docs don't tell you: if you have both an annotation and an XML entry for the same method, the XML takes precedence. I've seen confusion when someone adds an annotation and wonders why it's not being used because an old XML file is still on the classpath.

HybridMapper.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
@Mapper
public interface PaymentMapper {
    // Annotation-based simple query
    @Select("SELECT * FROM payments WHERE id = #{id}")
    Payment findById(Long id);

    // XML-based complex query (defined in PaymentMapper.xml)
    List<Payment> searchPayments(@Param("status") String status,
                                  @Param("minAmount") BigDecimal minAmount,
                                  @Param("maxAmount") BigDecimal maxAmount);
}
💡Consistent Naming
📊 Production Insight
I've seen teams split: Java devs prefer annotations, DBAs prefer XML. Find a compromise. I recommend XML for anything that touches multiple tables or uses dynamic SQL.
🎯 Key Takeaway
Mix annotations and XML when it makes sense, but be aware of precedence rules.

What the Official Docs Won't Tell You

Let me save you hours of debugging with these hard-earned lessons:

1. MyBatis Does Not Manage Transactions The official docs assume you know this, but many don't. MyBatis does not start or commit transactions. It delegates to the underlying JDBC connection. If you're using Spring Boot, you must use @Transactional or a TransactionTemplate. Without it, each statement auto-commits. The production incident earlier is a perfect example.

2. The 'Invalid Bound Statement' Error Is Usually a Classpath Issue You've added the XML file, but MyBatis can't find it. Check: - The namespace matches the interface FQN. - The XML file is in the path specified by mybatis.mapper-locations. - The XML file is not filtered by Maven resource plugin. Add <include>*/.xml</include> in your POM if needed.

3. Lazy Loading in MyBatis Is Not Like Hibernate MyBatis supports lazy loading for associations, but it's not enabled by default. You need to configure lazyLoadingEnabled=true and aggressiveLazyLoading=false. Even then, lazy loading requires a proxy and can lead to N+1 queries if you're not careful. I recommend using join queries instead.

4. Type Handlers Are Your Friend for Custom Types Need to map a PostgreSQL jsonb column to a Java Map? Write a custom TypeHandler. The docs cover it, but the real gotcha is that you must register it in MyBatis configuration. Use @MappedTypes and @MappedJdbcTypes annotations, or register globally via mybatis.type-handlers-package.

5. XML Mapper Files Are Not Hot-Reloaded Unlike JPA's automatic DDL, MyBatis XML files are loaded at startup. If you change an XML file, you must restart the application. In development, you can use Spring Boot DevTools with mybatis.configuration.cache-enabled=false to force reload, but it's not perfect. For production, treat XML files like code—they should be version-controlled and deployed with the app.

CustomTypeHandler.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@MappedTypes(Map.class)
@MappedJdbcTypes(JdbcType.VARCHAR)
public class JsonbTypeHandler extends BaseTypeHandler<Map<String, Object>> {
    private static final ObjectMapper mapper = new ObjectMapper();

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, Map<String, Object> parameter, JdbcType jdbcType) throws SQLException {
        try {
            ps.setString(i, mapper.writeValueAsString(parameter));
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public Map<String, Object> getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String json = rs.getString(columnName);
        return parseJson(json);
    }

    @Override
    public Map<String, Object> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String json = rs.getString(columnIndex);
        return parseJson(json);
    }

    @Override
    public Map<String, Object> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String json = cs.getString(columnIndex);
        return parseJson(json);
    }

    private Map<String, Object> parseJson(String json) {
        try {
            return mapper.readValue(json, Map.class);
        } catch (IOException e) {
            return null;
        }
    }
}
⚠ Type Handler Registration
📊 Production Insight
I once spent a day debugging a 'TypeException' because the TypeHandler was not registered. Always check the startup logs for 'Registered type handler' messages.
🎯 Key Takeaway
Know the hidden pitfalls: transaction management, classpath issues, lazy loading, and type handlers.

Advanced: Dynamic SQL with
FeatureAnnotationsXML Mappings
SetupSimple, no extra filesRequires XML file per mapper
ReadabilityGood for simple queriesBetter for complex queries
Dynamic SQLPossible with <script> (ugly)Native support with <if>, <where>
Result MappingVia @ResultsVia <resultMap> (more explicit)
SQL SeparationSQL in Java codeSQL in separate XML files
Hot ReloadYes (with DevTools)No (requires restart)
Type SafetyLow (string SQL)Low (string SQL)
⚙ Quick Reference
8 commands from this guide
FileCommand / CodePurpose
pom.xmlWhy MyBatis Still Matters in 2025
application.propertiesspring.datasource.url=jdbc:mysql://localhost:3306/mydb?useSSL=falseGetting Started
PaymentMapper.java@MapperAnnotation-Based Mappings
PaymentMapper.xmlXML Mappings
HybridMapper.java@MapperWhen to Use Annotations vs XML
CustomTypeHandler.java@MappedTypes(Map.class)What the Official Docs Won't Tell You
AdvancedMapper.java@MapperAdvanced
PaymentMapperTest.java@MybatisTestTesting MyBatis Mappers

Key takeaways

1
MyBatis gives you full SQL control and is ideal for complex queries and legacy databases.
2
Use annotations for simple CRUD, XML for dynamic SQL and complex mappings.
3
Always manage transactions explicitly with @Transactional.
4
Enable camelCase mapping and use @Param for clarity.
5
Test mappers with @MybatisTest and ensure XML files are on the classpath.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between MyBatis and JPA/Hibernate. When would you...
Q02JUNIOR
How do you configure MyBatis to use camelCase mapping from underscore co...
Q03SENIOR
What is the purpose of @Param annotation in MyBatis mapper methods?
Q01 of 03SENIOR

Explain the difference between MyBatis and JPA/Hibernate. When would you choose MyBatis over JPA?

ANSWER
MyBatis is a SQL mapper that gives you full control over SQL, while JPA is an ORM that abstracts SQL. Choose MyBatis when you have legacy databases, complex queries, or need performance optimization that JPA's automatic query generation hinders. Also choose MyBatis when your team prefers writing SQL.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
Can I use both MyBatis and JPA in the same Spring Boot application?
02
How do I handle pagination with MyBatis?
03
What is the difference between #{} and ${} in MyBatis?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Hibernate & JPA. Mark it forged?

5 min read · try the examples if you haven't

Previous
Introduction to Spring Data REST: Exposing JPA Repositories as REST APIs
21 / 28 · Hibernate & JPA
Next
Introduction to Spring Data Redis: CRUD Operations and Caching