Introduction

This article is the fourth in a series about data access with Spring Data.

This time, we’ll look at how to improve write throughput when an application needs to insert, update, or delete large numbers of records.

Persisting a single entity with Spring Data JPA is straightforward. Persisting thousands of entities efficiently requires more care.

The application may spend considerable time issuing JDBC statements, synchronizing the persistence context, generating identifiers, and waiting for database round trips. These costs can become significant even when individual SQL statements execute quickly.

The goal is not simply to execute fewer statements. It is to reduce unnecessary overhead while preserving the transaction semantics and consistency guarantees required by the application.

The examples use PostgreSQL, Spring Data JPA, and Hibernate 6.

1. Understanding Write Throughput

Consider an application that needs to insert 10,000 records.

A straightforward implementation might look like this:

@Transactional
public void createEvents(List<Event> events) {
    for (Event event : events) {
        eventRepository.save(event);
    }
}

Assuming each entity is new, Spring Data JPA delegates persistence to Hibernate. Hibernate tracks the entities in its persistence context and schedules the necessary SQL operations. Without JDBC batching, the resulting database interaction may involve executing a separate INSERT statement for each entity.

Conceptually:

INSERT INTO event (id, type, payload) VALUES (?, ?, ?);
INSERT INTO event (id, type, payload) VALUES (?, ?, ?);
INSERT INTO event (id, type, payload) VALUES (?, ?, ?);

Repeated thousands of times. Even when the database can execute each insert efficiently, the cumulative cost of JDBC calls, network communication, and statement execution can limit throughput.

There are several distinct sources of overhead:

  • Database round trips: Communication between the application and PostgreSQL.
  • Statement execution: Parsing, planning, and executing SQL statements.
  • Persistence context management: Tracking managed entities and their state.
  • Identifier generation: Obtaining identifiers for newly persisted entities.
  • Transaction processing: Maintaining indexes, constraints, and transactional state.

Optimizing one of these costs does not necessarily eliminate the others.

2. JDBC Batching

JDBC provides a batching mechanism that allows an application to group multiple statement executions. Rather than executing each statement independently, the application can add parameter sets to a batch and execute them together.

Hibernate supports this mechanism for entity inserts, updates, and deletes.

2.1 Enabling Hibernate batching

JDBC batching can be configured through Spring Boot:

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 50

This configures Hibernate to group compatible SQL statements into batches of up to 50 executions.

Consider an entity:

@Entity
@Table(name = "event")
public class Event {

    @Id
    private UUID id;

    private String type;

    private String payload;
}

And a service:

@Transactional
public void createEvents(List<Event> events) {
    for (Event event : events) {
        entityManager.persist(event);
    }
}

Assuming the identifiers have already been assigned, Hibernate can batch the inserts when the persistence context is flushed.

The SQL remains conceptually equivalent to:

INSERT INTO event (id, type, payload)
VALUES (?, ?, ?);

The difference is how Hibernate submits repeated executions through JDBC. With a batch size of 50, inserting 10,000 entities may require approximately 200 JDBC batch executions rather than 10,000 individual executions.

A JDBC batch execution is not necessarily a single network round trip, nor does it imply that PostgreSQL processes one SQL statement. The number of round trips and server-side statements depends on the JDBC driver and its configuration.

The PostgreSQL driver’s optional batch rewriting is discussed in section 6.2.

2.2 Batching is not the same as saveAll()

Spring Data JPA provides saveAll():

eventRepository.saveAll(events);

Despite its name, saveAll() does not inherently perform a database bulk insert. Spring Data JPA processes the supplied entities individually, delegating each operation to the underlying persistence provider. Whether Hibernate batches the resulting SQL depends on its configuration, identifier generation strategy, and the SQL statements being executed.

For example:

@Transactional
public void createEvents(List<Event> events) {
    eventRepository.saveAll(events);
}

With JDBC batching enabled, this can be an effective approach for moderate workloads. Without batching, however, it may still result in thousands of separate JDBC statement executions. The important distinction is that saveAll() is a repository API for saving multiple entities, while JDBC batching is an execution optimization.

There is another consideration when identifiers are assigned by the application.

Spring Data JPA must determine whether an entity is new to decide whether to persist or merge it. By default, it checks a nullable, non-primitive version property first, if one exists, and otherwise checks whether the identifier is null.

An entity with an assigned UUID and no version property may therefore be treated as existing, causing save() to call merge() rather than persist(). This can introduce extra database reads and entity-copying overhead.

For insert-only operations where the application knows that the entities are new, explicit entityManager.persist() avoids this ambiguity. Alternatively, entities with assigned identifiers can implement Spring Data’s Persistable interface to provide their own new-entity detection. Neither approach changes the need to configure JDBC batching.

2.3 Ordering inserts and updates

Hibernate can also reorder certain pending statements to improve batching efficiency.

For example:

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 50
        order_inserts: true
        order_updates: true

Suppose an application persists entities of different types:

entityManager.persist(customer1);
entityManager.persist(order1);
entityManager.persist(customer2);
entityManager.persist(order2);

Without ordering, the generated statements may alternate between tables.

Because JDBC batching generally groups executions of the same SQL statement, alternating statement types can reduce batching opportunities. With insert ordering enabled, Hibernate can group compatible inserts more effectively.

However, ordering introduces additional work within Hibernate and can affect the order in which statements are executed. It should be evaluated against the application’s mappings and workload rather than enabled without measurement.

2.4 Batching entity updates

JDBC batching is not limited to inserts. Hibernate can also batch compatible UPDATE statements when several managed entities are modified in one transaction.

@Transactional
public void changeStatus(List<UUID> orderIds) {
    List<Order> orders = orderRepository.findAllById(orderIds);

    for (Order order : orders) {
        order.setStatus(OrderStatus.PROCESSED);
    }
}

Hibernate detects the changes and issues updates when the persistence context is flushed. With batching enabled, compatible statements can be grouped into JDBC batches. The actual grouping depends on the generated SQL, including which columns are updated and whether optimistic locking is used.

For versioned entities, Hibernate normally includes the version in the UPDATE predicate and increments it. Batched execution must still detect optimistic-lock failures, which requires reliable update counts from the JDBC driver. If the driver reports SUCCESS_NO_INFO instead of individual update counts, Hibernate cannot reliably determine whether each versioned update affected the expected row. This behavior should be tested with the PostgreSQL driver and the application’s mappings.

If every matching row needs the same change, a bulk JPQL or SQL update may be simpler and more efficient than loading entities. Bulk updates have different persistence-context and optimistic-locking semantics, as discussed in section 5.

3. Identifier Generation and Batching

Identifier generation has a significant influence on insert performance. Different generation strategies interact with Hibernate batching in different ways.

3.1 Identity columns

Consider an entity using an identity column:

@Entity
public class Event {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String type;
}

With PostgreSQL, this commonly maps to an identity-backed column. The database generates the identifier when the row is inserted. Hibernate generally needs to execute the insert to obtain the generated identifier. As a result, Hibernate disables JDBC insert batching for entities using identity identifier generation.

Even if the application configures:

hibernate:
  jdbc:
    batch_size: 50

Inserts for identity-generated entities will not benefit from Hibernate’s normal JDBC insert batching.

3.2 Sequence-based identifiers

A sequence-based strategy allows Hibernate to obtain identifiers before executing the corresponding inserts.

For example:

@Entity
public class Event {

    @Id
    @GeneratedValue(
            strategy = GenerationType.SEQUENCE,
            generator = "event_seq"
    )
    @SequenceGenerator(
            name = "event_seq",
            sequenceName = "event_seq",
            allocationSize = 50
    )
    private Long id;

    private String type;
}

PostgreSQL sequences provide identifiers independently of row insertion. Hibernate can obtain identifiers and defer the inserts until the persistence context is flushed. This makes JDBC insert batching possible.

However, obtaining one sequence value for every inserted entity can introduce additional database communication. The allocationSize setting allows Hibernate to allocate identifiers in groups using its configured sequence optimizer. With an appropriate optimizer, Hibernate can reduce the frequency of sequence access.

For example, with an allocation size of 50 and a compatible pooled optimizer, Hibernate can reserve identifiers for approximately 50 entities with one sequence access, instead of fetching a sequence value for each entity. The exact allocation behavior depends on the optimizer.

The sequence definition and Hibernate allocation settings must be compatible. In particular, an existing PostgreSQL sequence may need an increment that matches the allocation size used by the selected optimizer. Hibernate can validate this relationship at startup.

allocationSize controls identifier allocation. It does not control the number of JDBC statements in a batch; hibernate.jdbc.batch_size is a separate setting.

For workloads inserting large numbers of entities, sequence-based identifiers with pooled allocation can be substantially more efficient than identity generation.

3.3 UUID identifiers

UUID identifiers are another option:

@Entity
public class Event {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    private String type;
}

Hibernate can generate UUID identifiers without inserting the row first. Consequently, UUID generation does not impose the same JDBC batching limitation as identity columns.

An application can also assign identifiers explicitly:

Event event = new Event();
event.setId(UUID.randomUUID());

entityManager.persist(event);

The tradeoffs differ from sequence-based identifiers. Random UUIDs can affect index locality and storage behavior, particularly when used as primary keys in large PostgreSQL tables.

Hibernate 6’s default UUID generator typically produces random UUIDv4 values. Time-ordered identifiers such as UUIDv7 can improve index insertion locality, although their suitability depends on the application’s requirements and the UUID generator being used. GenerationType.UUID should not be assumed to produce UUIDv7.

Identifier generation should therefore be considered part of the write-performance design rather than merely an entity-mapping detail.

4. Managing the Persistence Context

JDBC batching reduces statement execution overhead, but it does not eliminate the cost of managing entities.

Consider:

@Transactional
public void importEvents(List<Event> events) {
    for (Event event : events) {
        entityManager.persist(event);
    }
}

If the method persists 100,000 entities, Hibernate may retain all of them in the persistence context until the transaction completes.

The persistence context maintains references to managed entities and coordinates pending database operations.

As the number of managed entities grows, memory consumption and persistence-context processing overhead can become significant.

4.1 Flushing and clearing in batches

For large imports, a common approach is to flush and clear the persistence context periodically.

@Transactional
public void importEvents(List<Event> events) {
    int batchSize = 50;

    for (int i = 0; i < events.size(); i++) {
        entityManager.persist(events.get(i));

        if ((i + 1) % batchSize == 0) {
            entityManager.flush();
            entityManager.clear();
        }
    }
}

flush() synchronizes pending changes with the database, while clear() detaches all entities from the persistence context. Together, these operations prevent the persistence context from retaining every imported entity throughout the transaction. The final incomplete batch is flushed when the transaction commits, assuming the normal Hibernate flush behavior remains enabled.

There are two important distinctions. First, flushing does not commit the transaction. All inserts still belong to the surrounding transaction and can be rolled back together. Second, clearing the persistence context detaches every managed entity, not just those inserted by the loop. If the transaction contains other managed entities that the application still needs, calling clear() may have unintended consequences. This technique is therefore best suited to dedicated import operations with a well-defined persistence-context scope.

Notice that events is still a List<Event> holding references to every input entity. Clearing the persistence context reduces Hibernate’s retained managed state, but does not free the input list or the objects it references. For very large imports, read and process records incrementally in chunks rather than materializing the entire input in memory.

4.2 Batch size and transaction size

The JDBC batch size and transaction size are different concepts. A JDBC batch groups statement executions. A transaction determines the atomic unit of work. An application can insert 100,000 rows in a single transaction while flushing JDBC batches of 50 statements.

Alternatively, it can divide the import into multiple transactions. These approaches have different tradeoffs. A single transaction provides all-or-nothing semantics but may hold database resources for longer. Multiple transactions reduce the amount of work associated with each commit but allow partial progress if a later transaction fails.

The appropriate choice depends on whether the application requires the entire import to be atomic. For example, processing independent events may allow transaction boundaries between chunks. Transferring financial balances between accounts generally requires stronger atomicity guarantees. Increasing throughput should not come at the expense of required consistency properties.

For 10,000 rows, consider three different execution models:

Execution modelTransaction boundaryFailure behavior
One transaction, JDBC batches of 50All 10,000 rowsThe transaction can roll back all rows
100 transactions of 100 rowsOne chunk at a timeEarlier committed chunks remain if a later chunk fails
One COPY operation in one transactionAll rows in the transactionThe transaction can roll back the entire load

These models may have very different performance characteristics, but batching and COPY do not inherently imply separate transactions. When splitting work into chunks, the application must also decide how to retry failed chunks without duplicating data.

5. Bulk Operations with Spring Data JPA

JDBC batching still involves individual entity operations. For some workloads, it is more efficient to execute a single SQL statement that affects many rows. JPA supports bulk updates and deletes through JPQL.

5.1 Bulk updates

Suppose an application needs to mark all expired orders as cancelled. One approach would be to load the matching entities and update them individually.

@Transactional
public void cancelExpiredOrders(Instant cutoff) {
    List<Order> orders = orderRepository.findExpired(cutoff);

    for (Order order : orders) {
        order.setStatus(OrderStatus.CANCELLED);
    }
}

Hibernate must load the matching entities and manage their state. For large result sets, this introduces unnecessary overhead when the application only needs to update a column.

A bulk update can express the operation directly:

@Transactional
@Modifying
@Query("""
    UPDATE Order o
    SET o.status = :status
    WHERE o.status = :currentStatus
      AND o.createdAt < :cutoff
    """)
int cancelExpiredOrders(
        @Param("currentStatus") OrderStatus currentStatus,
        @Param("status") OrderStatus status,
        @Param("cutoff") Instant cutoff
);

Assuming the Order entity maps to the orders table, the resulting SQL is conceptually:

UPDATE orders
SET status = 'CANCELLED'
WHERE status = 'PENDING'
  AND created_at < ?;

This avoids loading and modifying each entity individually. The returned integer indicates the number of affected entities reported by the bulk operation.

5.2 Bulk operations bypass the persistence context

Bulk JPQL updates and deletes have different semantics from ordinary entity operations. They execute directly against the database rather than modifying each managed entity. Consequently, they bypass normal entity dirty checking and do not synchronize already-managed entity instances with the updated database state.

Consider:

@Transactional
public void example(UUID orderId) {
    Order order = orderRepository.findById(orderId)
            .orElseThrow();

    orderRepository.cancelExpiredOrders(
            OrderStatus.PENDING,
            OrderStatus.CANCELLED,
            Instant.now()
    );

    // The managed order may still contain its previous status.
}

Even if the bulk update modifies the row represented by order, the managed Java object is not automatically refreshed. This can lead to stale application state.

Spring Data JPA supports clearing the persistence context after a modifying query:

@Modifying(clearAutomatically = true)

It also supports flushing before executing the query:

@Modifying(
    flushAutomatically = true,
    clearAutomatically = true
)

These options can be useful, but clearing the persistence context detaches all managed entities. Pending changes must be handled carefully to avoid losing work that has not been flushed.

Bulk operations also bypass ordinary entity lifecycle callbacks. Applications relying on callbacks, auditing listeners, or entity-level version checks need to account for these differences.

For bulk updates involving optimistic locking, version handling must be considered explicitly.

Suppose Order has a version property:

@Version
private Long version;

An ordinary managed-entity update normally includes the expected version in its SQL predicate. A JPQL bulk update does not automatically perform that per-entity version check or increment the version.

If the intent is to invalidate previously loaded versions after a bulk change, the query can explicitly increment a numeric version field:

@Transactional
@Modifying
@Query("""
    UPDATE Order o
    SET o.status = :status,
        o.version = o.version + 1
    WHERE o.status = :currentStatus
    """)
int updateStatus(
        @Param("currentStatus") OrderStatus currentStatus,
        @Param("status") OrderStatus status
);

This advances the version for affected rows, but it is not equivalent to checking an expected version for each entity. If the operation requires compare-and-swap semantics, the WHERE clause must include the expected version or another concurrency condition. The application must check the affected-row count against the expected number of updates and handle mismatches appropriately.

5.3 Bulk deletes

The same principle applies to deletes.

For example:

@Transactional
@Modifying
@Query("""
    DELETE FROM Event e
    WHERE e.createdAt < :cutoff
    """)
int deleteOldEvents(@Param("cutoff") Instant cutoff);

This avoids loading every entity before deletion. However, bulk deletes do not invoke entity removal callbacks or automatically apply ORM-level cascading operations. Database foreign-key constraints and database-defined cascading rules still apply.

For large deletions, a single statement may also create substantial database work. Deleting millions of rows in one transaction can generate significant WAL activity, hold locks, and leave dead tuples that PostgreSQL must subsequently reclaim.

Large cleanup operations may therefore benefit from processing rows in smaller chunks or using partitioning when the data model permits it.

6. Bulk Inserts with PostgreSQL

JPQL supports bulk updates and deletes, but it does not provide a portable bulk INSERT ... VALUES mechanism equivalent to PostgreSQL’s native SQL capabilities.

For high-volume inserts, native SQL or JDBC may be more appropriate.

6.1 Multi-row inserts

PostgreSQL supports inserting multiple rows with a single statement:

INSERT INTO event (id, type, payload)
VALUES
    (?, ?, ?),
    (?, ?, ?),
    (?, ?, ?);

This differs from JDBC batching. A multi-row insert is one SQL statement containing multiple rows. JDBC batching groups multiple executions of a parameterized statement. Both approaches can reduce overhead, but they operate at different levels.

Multi-row inserts can be particularly effective when the application does not require JPA entity lifecycle behavior. However, very large statements have their own costs, including parameter handling, memory consumption, and statement construction.

The appropriate number of rows per statement should be measured.

6.2 PostgreSQL JDBC batch rewriting

The PostgreSQL JDBC driver supports the reWriteBatchedInserts connection property.

The layers involved are distinct:

MechanismResponsibility
Hibernate batchingCollects compatible entity DML operations for JDBC execution
JDBC batchingSubmits repeated parameter sets through the driver
PostgreSQL JDBC batch rewritingMay rewrite compatible batched inserts into multi-row SQL
Native multi-row INSERTApplication sends a single SQL statement containing multiple rows
PostgreSQL COPYUses PostgreSQL’s bulk data-loading mechanism

These mechanisms are not all mutually exclusive. For example, Hibernate can submit a JDBC batch that the PostgreSQL driver subsequently rewrites.

For example:

jdbc:postgresql://localhost:5432/app?reWriteBatchedInserts=true

When applicable, the driver can rewrite compatible batched inserts into multi-row insert statements. This can reduce protocol and execution overhead beyond ordinary JDBC batching. However, rewriting depends on the SQL statement and driver behavior.

It should not be assumed that every batched insert will be rewritten. The generated database workload should be measured with the setting enabled and disabled.

6.3 Using JdbcTemplate

Spring’s JdbcTemplate provides a direct way to execute JDBC batches without creating managed entities.

For example:

@Transactional
public void insertEvents(List<EventData> events) {
    jdbcTemplate.batchUpdate(
            """
            INSERT INTO event (id, type, payload)
            VALUES (?, ?, ?)
            """,
            events,
            500,
            (ps, event) -> {
                ps.setObject(1, event.id());
                ps.setString(2, event.type());
                ps.setString(3, event.payload());
            }
    );
}

This avoids the overhead of entity materialization, dirty checking, and persistence-context management. The application also gains direct control over the SQL being executed. The tradeoff is that it no longer benefits from JPA entity lifecycle management for these operations.

For straightforward bulk inserts, this may be desirable. For operations involving complex entity relationships, cascades, or domain behavior, JPA may still provide a more convenient programming model.

6.4 PostgreSQL COPY

For very large data imports, PostgreSQL provides the COPY command.

For example:

COPY event (id, type, payload)
FROM STDIN
WITH (FORMAT CSV);

COPY is designed for transferring large amounts of data between a client and PostgreSQL.

It can be substantially more efficient than executing individual inserts, even when JDBC batching is enabled. The PostgreSQL JDBC driver exposes COPY functionality through its CopyManager API. However, COPY requires a different integration approach from ordinary Spring Data repositories. The application must manage the input format, error handling, and database interaction explicitly.

COPY also does not invoke JPA entity callbacks or manage inserted rows as entities. It does not bypass PostgreSQL’s normal transactional guarantees. Constraints, indexes, and triggers still apply, and loading into ordinary logged tables generates WAL. The database work may remain substantial even when client-side insertion overhead is greatly reduced.

It is particularly useful for bulk imports, data migrations, and ingestion pipelines where the application can process records without ORM lifecycle management.

7. Measuring Write Performance

Write optimizations should be evaluated using representative workloads. A larger JDBC batch size does not automatically translate into proportionally higher throughput. Performance depends on the database schema, indexes, constraints, network latency, transaction size, and the amount of work performed by PostgreSQL.

7.1 Measuring throughput

A basic throughput metric is:

Throughput (rows/sec) = Rows processed / Elapsed time (seconds)

For example, inserting 100,000 rows in 10 seconds corresponds to 10,000 rows per second. However, throughput alone does not describe the complete workload.

A useful benchmark should also consider:

  • Total transaction duration.
  • Commit latency.
  • Application memory consumption.
  • CPU utilization.
  • Database I/O and WAL generation.
  • Lock contention.
  • Error and retry behavior.

It is also important to distinguish the time required to queue entity operations from the time required to flush and commit them. Measuring only the execution time of a persist() loop can produce misleading results because Hibernate may defer the actual SQL execution.

7.2 Comparing write strategies

For a representative insert workload, useful comparisons include:

StrategyPersistence contextJDBC batchingMain consideration
Individual save() callsYesConfiguration-dependentConvenient but potentially expensive
saveAll()YesConfiguration-dependentNot inherently a bulk insert
JPA with periodic flush/clearYes, boundedYes, when supportedControls persistence-context growth
JdbcTemplate.batchUpdate()NoYesLower ORM overhead
Native multi-row INSERTNoNot requiredSingle SQL statement for multiple rows
PostgreSQL COPYNoNot applicableOptimized for large imports

The relative performance of these approaches depends on the workload. For small batches, the convenience of JPA may outweigh any measurable overhead. For large imports, avoiding entity management entirely can provide substantial benefits. The objective is to choose the simplest approach that meets the application’s throughput and consistency requirements.

Conclusion

Spring Data JPA provides a convenient way to persist application state, but efficient bulk writes require an understanding of how Hibernate interacts with JDBC and PostgreSQL.

JDBC batching can reduce the overhead of executing repeated statements. Identifier generation determines whether Hibernate can batch inserts efficiently. Persistence-context management becomes increasingly important as the number of entities grows.

For workloads that do not require entity lifecycle management, JDBC batching, native SQL, and PostgreSQL COPY provide alternatives that can offer better throughput.

The goal is not to replace JPA with SQL everywhere. It is to understand where the abstraction adds value, where it introduces overhead, and when a more direct database operation is appropriate. Measure the full operation, including flushing, committing, and database-side work, and preserve the transaction boundaries that correctness requires.