10 Common MyBatis-Plus Pitfalls and How to Avoid Them in Java Projects

MyBatis-Plus can significantly improve Java single-table CRUD productivity, but it often introduces hidden errors in scenarios such as paginated joins, logical delete, and automatic field filling. This article distills 10 high-frequency pitfalls and proven fixes to help teams build a more robust data access layer. Keywords: MyBatis-Plus, pagination plugin, logical delete.

Technical Specification Snapshot

Parameter Details
Core Technology MyBatis-Plus
Language Java
Typical Protocols JDBC / SQL
Article Focus Pitfall avoidance in an enhanced ORM framework
Community Adoption Pattern Widely used in Java projects
Core Dependencies mybatis-plus-boot-starter, MyBatis, database driver

MyBatis-Plus delivers high productivity, but its real complexity is easy to overlook

The value of MyBatis-Plus lies in reducing boilerplate SQL, standardizing CRUD patterns, and providing built-in pagination and logical delete capabilities. It is especially well suited for single-table operations in small and medium-sized business systems, and it can quickly support early-stage microservice development.

But the same strength also creates problems: “convention over configuration.” Many features only work with built-in methods. Once you move into custom SQL, complex joins, batch processing, or special field mappings, you must understand its interceptor behavior, parameter recognition rules, and SQL generation boundaries again.

A minimal paginated interface example

public interface UserMapper extends BaseMapper
<User> {
    // Page must be the first parameter so the pagination plugin can detect it correctly
    Page
<User> selectByAge(Page<User> page, @Param("age") Integer age);
}

This code shows the key prerequisite for the pagination plugin to work: the Page parameter must be placed correctly.

Pagination and join queries are the easiest places to get burned

The first category of problems is incorrect total counts. A one-to-many join expands one primary-table row into multiple result rows. When the pagination plugin executes COUNT, it counts the expanded result set, which leads to misleading results such as “5 rows in the list, but 300 rows in total.”

The fix is not to keep stacking joins. Instead, query the primary table first and then fetch child records separately, or use a subquery to aggregate child items so the primary-table row count does not get inflated.

<select id="selectOrderPage" resultMap="OrderWithItemMap">
  SELECT o.*,
         (SELECT JSON_ARRAYAGG(item_name) FROM order_item WHERE order_id = o.id) AS item_names
  FROM orders o
  WHERE o.user_id = #{userId}
  ORDER BY o.create_time DESC
</select>

This SQL uses a subquery to aggregate child-table fields and avoids incorrect total counts in one-to-many pagination.

The second category is pagination plugin failure. The root cause is usually that Page is wrapped by @Param, placed in the wrong argument position, or used with handwritten SQL under the false assumption that pagination will always be appended automatically. The practical rule is simple: in custom paginated methods, always put Page in the first parameter position.

Logical delete and auto-fill do not work unconditionally

@TableLogic only works along MyBatis-Plus convention-based method paths. If you write a DELETE statement manually, the framework will not convert it into a logical delete for you. A physical delete will happen directly.

Likewise, automatic field filling depends on the interaction between MetaObjectHandler and field strategies. If the entity field strategy does not allow updates, or if the filler is not implemented correctly, updateTime will not be updated even if you configured FieldFill.INSERT_UPDATE.

The correct way to implement logical delete

@Delete("update user set deleted = 1 where age > #{age} and deleted = 0")
int logicDeleteByAge(@Param("age") Integer age);

This code demonstrates that a custom delete must be explicitly rewritten as an update to the delete marker.

An example auto-fill handler

@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        // Fill the creation time during insert
        this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
        // Fill the update time during insert
        this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        // Refresh the update time during update
        this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
    }
}

This handler automatically maintains timestamp fields during insert and update operations.

Optimistic locking, null conditions, and parenthesis precedence determine data correctness

When optimistic locking fails, the problem usually is not that the plugin is missing. The problem is the update path. You must first query the entity with its version number and then perform the update. Only then can MyBatis-Plus generate the version = oldVersion check and increment the version.

null conditions in QueryWrapper are also frequently misunderstood. eq("name", null) does not mean IS NULL; in most cases, it is ignored. To query null values, you must explicitly use isNull.

if (name == null) {
    // Explicitly generate an is null condition
    wrapper.isNull("name");
} else {
    // Generate an equality match when the value is not null
    wrapper.eq("name", name);
}

This code avoids missing rows caused by incorrect null-handling behavior.

Another common pitfall is chained or() conditions. If you do not add parentheses manually in complex conditions, SQL operator precedence can drift away from your intent and distort the final filter result.

Batch writes, enum mapping, and JSON field handling require extra design work

Loop-based insert calls are a classic performance trap. Writing 10,000 rows one by one amplifies network round trips and transaction overhead. A safer approach is to generate a multi-value batch insert with `

`, or combine it with JDBC batch parameter optimization. “`xml insert into user (name, age) values (#{item.name}, #{item.age}) “` This XML significantly reduces batch-write overhead by using a single multi-value insert. Enum mapping and JSON fields belong to the category of issues that “seem to work locally but fail in production.” If you do not annotate enums with `@EnumValue`, the value stored in the database may drift away from the intended Java semantics. If JSON fields are used without enabling `autoResultMap`, or if the field type does not match, the type handler will not work as expected. ### A JSON field mapping example “`java @TableName(value = “user”, autoResultMap = true) public class User { // Specify a JSON type handler to convert between the object and the JSON column @TableField(typeHandler = JacksonTypeHandler.class) private Address address; } “` This entity definition ensures that complex objects can be mapped correctly to JSON columns through the type handler. ## MyBatis-Plus works best as a single-table enhancement layer, not a universal solution for complex queries Its strengths are clear: fast development, less boilerplate, a mature ecosystem, and a low learning curve for teams. But complex multi-table queries, reporting workloads, and deep SQL optimization are not where it performs best. A safer practice is to use it in layers: use MyBatis-Plus for single-table CRUD, generic pagination, and basic updates; fall back to native MyBatis XML for complex joins, performance-sensitive SQL, and reporting queries. ### A practical usage guideline “`java public class MpGuideline { public static final String RULE = “Use MP for single-table operations, and fall back to MyBatis for complex SQL”; // Core principle } “` This guideline helps teams balance productivity and control. ## FAQ ### 1. Why are total counts often incorrect in MyBatis-Plus pagination queries? Because one-to-many joins inflate the number of primary-table rows, and the pagination plugin counts the expanded result set. Prefer primary-table pagination with child-table enrichment or subquery aggregation. ### 2. Why did real data get deleted even though `@TableLogic` was configured? Because logical delete only applies to built-in methods. Handwritten `DELETE` SQL is not rewritten automatically. You must manually update the delete marker field and add a condition for non-deleted rows. ### 3. Why does `JacksonTypeHandler` still fail even after I configure it? Three common causes are: `autoResultMap` is not enabled on the entity, the database column is not a JSON type, or the type handler is not registered correctly. Any one of these can cause the mapping to fail. **AI Readability Summary:** This article systematically breaks down 10 common MyBatis-Plus pitfalls across pagination, logical delete, auto-fill, optimistic locking, batch writes, enum mapping, and JSON field handling. It explains root causes, correct implementations, and usage boundaries so Java backend teams can reduce hidden SQL risks.