Let's get started with a Microservice Architecture with Spring Cloud:
Why Use the Returned Instance of Spring Data JPA Repository’s save() Call?
Last updated: January 6, 2026
1. Overview
When we use Spring Data JPA, calling the repository.save(entity) method may seem deceptively simple. Many of us tend to continue using the original entity instance after saving, assuming it accurately represents the persisted data. However, this assumption is not always correct.
In this tutorial, we explain why it is important to always use the instance returned by the save() method, using specific unit tests to show what happens during both persisting and merging scenarios. By the end, we will clearly illustrate how ignoring the returned value can cause subtle and difficult-to-diagnose bugs.
2. Introduction to the Problem
The CrudRepository.save() method in Spring Data JPA has the following signature:
<S extends T> S save(S entity);
This signature already implies that save() returns an entity instance, but it doesn’t guarantee that the instance is the same one we passed in.
Further, the JavaDoc of this method suggests:
Saves a given entity. Use the returned instance for further operations as the save operation might have changed the entity instance completely.
However, when working with Spring Data JPA, it is very common to see code like this:
repository.save(article);
// ... continue using the article object
At first glance, this looks perfectly reasonable. However, this approach can lead to subtle bugs and misunderstandings. Thus, sometimes this works, and sometimes it does not.
To understand why, we need to distinguish between two fundamentally different scenarios: persisting a new entity and merging an entity.
As usual, we’ll address it through examples. We’ll create a simple Spring Boot application and use unit tests to demonstrate it.
So next, let’s prepare an entity class:
@Entity
@Table(name = "baeldung_articles")
public class BaeldungArticle {
@Id
@GeneratedValue
private Long id;
private String title;
private String content;
private String author;
// getters and setters are omitted
}
Then, let’s create a BaeldungArticleRepo interface to extend JpaRepository:
@Repository
interface BaeldungArticleRepo extends JpaRepository<BaeldungArticle, Long> {
}
For simplicity, we’ll use an in-memory H2 database as the datastore for our simple Spring Boot application. However, we’ll skip the datasource configuration in this tutorial.
Since we’ll use unit tests to demonstrate the behaviors of the save() method, let’s create a unit test class:
//... Spring Boot test related annotations omitted
public class ReturnedValueOfSaveIntegrationTest {
@Autowired
private BaeldungArticleRepo repo;
@PersistenceContext
private EntityManager entityManager;
// ...
}
As we can see, we injected BaeldungArticleRepo and the entityManger into this test class, which we’ll use in later test methods.
Next, let’s create test methods to demonstrate save()’s behaviors in two scenarios.
3. The Persisting Scenario
Let’s start with the simplest case: saving a brand-new entity:
@Test
void whenNewArticleIsSaved_thenOriginalAndSavedResultsAreTheSame() {
BaeldungArticle article = new BaeldungArticle();
article.setTitle("Learning about Spring Data JPA");
article.setContent(" ... the content ...");
article.setAuthor("Kai Yuan");
assertNull(article.getId());
BaeldungArticle savedArticle = repo.save(article);
assertNotNull(article.getId());
assertSame(article, savedArticle);
}
In this example, we first create a new BaeldungArticle instance (article) and set its properties. However, we don’t assign an ID to it. After calling repo.save(article), JPA persists it in the database and returns the persisted entity.
Additionally, the assertSame() assertion demonstrates that when we create a new object, article, and call repo.save(article), the returned object, savedArticle, and article are actually the same object.
To understand why it works like this, let’s quickly look at the save() method implementation in Spring Data JPA’s SimpleJpaRepository class:
@Transactional
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null");
if (this.entityInformation.isNew(entity)) {
this.entityManager.persist(entity);
return entity;
} else {
return (S)this.entityManager.merge(entity);
}
}
As we observe, passing a new entity to the save() method results in calling EntityManager.persist(entity), which persists the entity and returns the same entity object we passed to it.
In our test, we did not assign an ID to the article object. Therefore, JPA considers it a new entity and internally calls EntityManager.persist(). As a result, the original object and the returned object are the same reference.
This behavior often leads us to believe that reassigning the result of save() is unnecessary. However, this assumption fails once we encounter a merging scenario.
4. The Merging Scenario
Now, let’s examine what happens when we save an entity that already has an ID.
Let’s first look at a new test method:
@Test
@Transactional
void whenArticleIsMerged_thenOriginalAndSavedResultsAreNotTheSame() {
// prepare an existing theArticle
BaeldungArticle theArticle = new BaeldungArticle();
theArticle.setTitle("Learning about Spring Boot");
theArticle.setContent(" ... the content ...");
theArticle.setAuthor("Kai Yuan");
BaeldungArticle existingOne = repo.save(theArticle);
Long id = existingOne.getId();
// create a detached theArticle with the same id
BaeldungArticle articleWithId = new BaeldungArticle();
articleWithId.setTitle("Learning Kotlin");
articleWithId.setContent(" ... the content ...");
articleWithId.setAuthor("Eric");
articleWithId.setId(id); //set the same id
BaeldungArticle savedArticle = repo.save(articleWithId);
assertEquals("Learning Kotlin", savedArticle.getTitle());
assertEquals("Eric", savedArticle.getAuthor());
assertEquals(id, savedArticle.getId());
assertNotSame(articleWithId, savedArticle);
assertFalse(entityManager.contains(articleWithId));
assertTrue(entityManager.contains(savedArticle));
}
Let’s first quickly understand why we need the @Transactional annotation on this test method. This is because we want to check if an entity is managed by JPA in this test method. The default PersistenceContextType for @PersistenceContext is TRANSACTION, meaning we use a transaction-scoped persistence context.
As we have seen, the save() implementation has the @Transactional annotation. Therefore, if we don’t annotate our test method with @Transactional, the persistence context is closed after each save() call. Thus, outside a transaction scope, all entities are detached.
If we give the test a run, it passes. Next, let’s have a look at what the test does and what it tells us.
First, we persisted a new BaeldungArticle entity (theArticle) by calling the repo.save() method and obtained the ID generated by the H2 database.
Then, we created a new BaeldungArticle object named articleWithId, assigned the ID of the previously persisted entity to articleWithId.id, and set different values in the other fields.
Subsequently, we called repo.save(articleWithId) and assigned the returned value to savedArticle. At this point, JPA recognized articleWithId as a detached entity. Internally, JPA uses EntityManager.merge() to first create a new managed instance and then merge the detached instance’s state into it. Finally, the managed entity is returned.
Therefore, articleWithId and savedArticle are not the same object, even though, as the assertions show, their fields, including their IDs, hold the same values.
Some of us may think, “ok, although they are not the same object, as long as their properties have the same values, there is no difference; we can use whichever one in further processing.”
The last two assertions with entityManager.contains() checks told us that articleWithId is a detached entity. On the other hand, savedArticle is a managed entity.
This is the critical reason why we must use the returned instance. Next, let’s understand why using a managed instance is essential.
5. Detached vs. Managed
There are many differences between detached and managed instances in JPA, for example:
- Transaction participation – Managed instances participate in the current transaction, but detached instances don’t.
- Automatic SQL execution – Managed instances automatically trigger INSERT/UPDATE/DELETE statements on flush. Detached instances don’t.
- Lazy loading – Managed instances can initialize lazy associations. But detached instances can’t.
- Cascade behavior -Managed instances participate in cascade persist/update operations while detached instances don’t.
- Lifecycle callbacks – Managed instances trigger callbacks such as @PreUpdate, @PostUpdate, etc., but detached instances don’t.
- More differences, such as refresh capability, rollback behavior, reattachment, identity guarantee, etc.
We won’t demonstrate each item on the list above in this tutorial. Instead, let’s take “lifecycle callbacks” as one example to show why it’s crucial to work with managed instances.
Let’s add a new @Transient field and a markAsSaved() method to our BaeldungArticle class:
@Entity
@Table(name = "baeldung_articles")
public class BaeldungArticle {
//... existing fields omitted
@Transient
private boolean alreadySaved = false;
@PostPersist
@PostUpdate
private void markAsSaved() {
this.alreadySaved = true;
}
// ... getters and setters
}
We annotated markAsSaved() with @PostPersist and @PostUpdate to automatically set the alreadySaved flag when the entity is saved.
Now, let’s create a test to show why using the managed instance returned by save() in further processing is important:
@Test
void whenArticleIsMerged_thenDetachedObjectCanHaveDifferentValuesFromTheManagedOne() {
// prepare an existing theArticle
BaeldungArticle theArticle = new BaeldungArticle();
theArticle.setTitle("Learning about Java Classes");
theArticle.setContent(" ... the content ...");
theArticle.setAuthor("Kai Yuan");
BaeldungArticle existingOne = repo.save(theArticle);
Long id = existingOne.getId();
// create a detached theArticle with the same id
BaeldungArticle articleWithId = new BaeldungArticle();
theArticle.setTitle("Learning Kotlin Classes");
theArticle.setContent(" ... the content ...");
theArticle.setAuthor("Eric");
articleWithId.setId(id); //set the same id
BaeldungArticle savedArticle = repo.save(articleWithId);
assertNotSame(articleWithId, savedArticle);
assertFalse(articleWithId.isAlreadySaved());
assertTrue(savedArticle.isAlreadySaved());
}
In this test, similarly, we first prepared an existing database entry and obtained its ID. Then we created a detached object, articleWithId, and assigned the ID to it.
After calling repo.save(articleWithId), the returned value savedArticle and articleWithId have different values in the alreadySaved field. This is because lifecycle callbacks (@PostPersist, @PostUpdate) are executed only on managed entities. The detached instance doesn’t receive these updates.
At this point, continuing to use the detached instance is incorrect and dangerous.
6. Conclusion
In this article, we’ve examined the return value of the JPA repository’s save() method through examples. When we persist a new entity, the returned instance is often the same as the original. However, when we merge a detached entity, the returned instance is always a different object.
For this reason, we should always use the returned instance because only that instance is:
- Managed by JPA
- Tracked for changes
- Updated by lifecycle callbacks
- Synchronized with the database properly
Using the returned instance ensures correct behavior and avoids subtle persistence-related bugs.
As always, the complete source code for the examples is available over on GitHub.















