JPA ElementCollection Example with Spring Boot
Published Updated Spring Boot 11 min read
Map a collection of values that has no identity of its own — tags, phone numbers, embedded addresses — and understand the delete-and-reinsert behaviour that makes a List of elements expensive to update.
Some collections on an entity are not relationships. A user’s tags, a product’s dimensions, a person’s phone numbers. These are values the owner has, not things that exist independently. They have no id, nothing else refers to them, and when the owner is deleted they go with it.
JPA maps that with @ElementCollection. It is less code than a @OneToMany and it behaves quite
differently underneath, in ways worth knowing before a table gets large.
Written against Spring Boot 3.2, Hibernate 6.4, Java 17 and MySQL 8.
Deciding between @ElementCollection and @OneToMany
One question settles it: does the thing need an identity of its own?
If some other part of the system will ever refer to a tag, update one in isolation, or attach data
to it. It is an entity and wants @OneToMany. If it is only ever read and written as part of its
owner. It is an element.
The practical consequences of choosing element:
- no
@Id, no repository, no separate entity class for basic types - no
cascadeconfiguration, the lifecycle is the owner’s by definition - deleting the owner deletes the rows, always
- the rows cannot be loaded without their owner
And the cost, which the rest of this article is mostly about, because the rows have no identity, Hibernate cannot always work out which one changed.
The entity
package com.example.notes.model;
import jakarta.persistence.*;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@ElementCollection
@CollectionTable(
name = "user_tags",
joinColumns = @JoinColumn(name = "user_id")
)
@Column(name = "tag", length = 40)
private Set<String> tags = new HashSet<>();
// getters and setters
}
Three annotations doing three jobs. @ElementCollection says these are values rather than
entities. @CollectionTable names the table and the foreign key back to the owner. @Column names
the single column holding the value. That one is easy to forget, and without it you get a column
called tags.
The generated table:
CREATE TABLE user_tags (
user_id BIGINT NOT NULL,
tag VARCHAR(40),
FOREIGN KEY (user_id) REFERENCES users (id)
);
No primary key and no id column. That is the point, and it is also the reason for the behaviour further down.
An embeddable element
Elements do not have to be single values. An @Embeddable gives several columns per row:
package com.example.notes.model;
import jakarta.persistence.Embeddable;
import java.util.Objects;
@Embeddable
public class Address {
private String street;
private String city;
private String postcode;
private String country;
protected Address() { } // required by JPA
public Address(String street, String city, String postcode, String country) {
this.street = street;
this.city = city;
this.postcode = postcode;
this.country = country;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Address other)) return false;
return Objects.equals(street, other.street)
&& Objects.equals(city, other.city)
&& Objects.equals(postcode, other.postcode)
&& Objects.equals(country, other.country);
}
@Override
public int hashCode() {
return Objects.hash(street, city, postcode, country);
}
}
equals and hashCode are not optional here. A Set needs them to decide what a duplicate is,
and Hibernate uses them when reconciling a loaded collection with a modified one. An embeddable
without them will produce duplicate rows and confusing update behaviour, and nothing will warn you.
On the owner:
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "user_addresses", joinColumns = @JoinColumn(name = "user_id"))
@AttributeOverrides({
@AttributeOverride(name = "street", column = @Column(name = "street_line")),
@AttributeOverride(name = "country", column = @Column(name = "country_code", length = 2))
})
private Set<Address> addresses = new HashSet<>();
@AttributeOverride renames a column without touching the embeddable, which matters when the same
Address is embedded in several entities with different column conventions.
Watch the SQL, once
Turn on statement logging while developing this. It is the only way to see the behaviour that matters:
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.orm.jdbc.bind=trace
Now add one tag to a user who already has five, and read the statements:
delete from user_tags where user_id=1
insert into user_tags (user_id, tag) values (1, 'java')
insert into user_tags (user_id, tag) values (1, 'spring')
-- ...four more inserts
Six rows deleted and six inserted, to add one. Hibernate has no identifier for an individual element row, so for many mappings its only safe strategy is to remove the owner’s rows and write the collection back out. With five tags that is invisible. With five hundred it is a real cost on every save.
Two things reduce it. A Set of embeddables with correct equals/hashCode gives Hibernate enough
information to issue targeted deletes in many cases. An @OrderColumn on a List gives each row a
position, which is an identifier of sorts:
@ElementCollection
@CollectionTable(name = "user_phones", joinColumns = @JoinColumn(name = "user_id"))
@OrderColumn(name = "phone_order")
@Column(name = "phone")
private List<String> phones = new ArrayList<>();
That buys ordering and cheaper appends, and it has its own edge: removing an element from the middle of the list rewrites the index of everything after it. Appending is cheap, inserting at the front is not.
If a collection is large and updated often. That is the signal to promote it to an entity with
@OneToMany, where each row has an id and can be updated on its own.
Querying elements
Element collections are reachable from JPQL through a join, so they are not write-only:
public interface UserRepository extends JpaRepository<User, Long> {
@Query("select distinct u from User u join u.tags t where t in :tags")
List<User> findByAnyTag(@Param("tags") Collection<String> tags);
@Query("select u from User u left join fetch u.tags where u.id = :id")
Optional<User> findByIdWithTags(@Param("id") Long id);
}
distinct matters in the first query: a user with three matching tags produces three rows, and
without it you get the same user three times.
The second query exists because of the default fetch. @ElementCollection is LAZY, so loading a
hundred users and then reading getTags() on each is a hundred and one queries. Either fetch it
explicitly, or set @BatchSize(size = 25) on the collection so Hibernate loads them in batches
instead of one at a time.
Maps
The same mechanism maps a keyed collection:
@ElementCollection
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
@MapKeyColumn(name = "setting_key")
@Column(name = "setting_value")
private Map<String, String> settings = new HashMap<>();
@MapKeyColumn names the key column, @Column the value. This is a reasonable way to store a
bounded set of per-user flags, and a poor way to store arbitrary user-supplied keys, the table
grows per key, and none of it is queryable in a typed way.
Frequently asked questions
When should I use @ElementCollection instead of @OneToMany?
When the values have no identity of their own and are never referenced, queried or updated independently of their owner. If anything in the system needs to point at one of them. It is an entity.
Why is Hibernate deleting every row just to add one?
Element rows have no identifier, so
Hibernate’s safe strategy for many mappings is to delete the owner’s rows and reinsert the
collection. A Set of embeddables with correct equals/hashCode, or an @OrderColumn on a
List, lets it do better.
Do I need equals and hashCode on my @Embeddable?
For a Set, yes. It defines what a duplicate
is, and without it you get duplicate rows and unpredictable updates. Implement both over all
persistent fields.
Is the collection table given a primary key?
Not by default. It holds a foreign key to the owner and the value columns, with no identifier of its own. That is what distinguishes it from an entity table.
What is the default fetch type?
LAZY. Loading N owners and touching the collection on each
produces N+1 queries; use join fetch, an entity graph, or @BatchSize on the collection.
Can I query an element collection in JPQL?
Yes: join u.tags t works, and left join fetch
loads them eagerly for one query. Remember distinct when several elements can match per owner.
Do I need cascade = REMOVE?
No, the lifecycle belongs to the owner by definition, so deleting the owner always removes the rows. Adding cascade configuration to an element collection has no effect.
Why did my List end up in a different order?
Without @OrderColumn the database has no notion
of order and none is preserved. Add @OrderColumn to store a position, accepting that a removal from
the middle rewrites the indices after it.
Can two entities share one collection table?
No, the table’s rows belong to one owner through its foreign key. If two entities need the same values, those values are an entity.
How do I rename the embeddable’s columns per entity?
@AttributeOverride on the collection
field, one per attribute you want to move. The embeddable itself stays unchanged.
Where should I go next?
The Spring Boot REST API guide covers the entity, repository and controller layers this mapping sits inside, and the rest of the Spring Boot guides build on the same schema.