A Hibernate association mapping tells Hibernate how two entities refer to each other and which database column stores that link. In Java, a gym member holds a reference to a membership card or a list of workouts, whereas in the database the same link is a foreign key column or a join table. Jakarta Persistence describes these links with four annotations, @OneToOne, @ManyToOne, @OneToMany and @ManyToMany, plus the mappedBy attribute that marks the side that does not hold the foreign key.
We map associations whenever one table refers to another, for example to load a member together with the membership card or to list the workouts of one member.
The following example puts the four annotations on a gym Member and its Workout entity.
// Member
@OneToOne(mappedBy = "member", cascade = CascadeType.ALL)
private MembershipCard card;
@OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Workout> workouts = new ArrayList<>();
@ManyToMany
@JoinTable(name = "member_class",
joinColumns = @JoinColumn(name = "member_id"),
inverseJoinColumns = @JoinColumn(name = "class_id"))
private Set<FitnessClass> classes = new HashSet<>();
// Workout
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
Member lokesh = new Member("Lokesh");
lokesh.setCard(new MembershipCard("Gold"));
lokesh.addWorkout(new Workout("Running", 30));
lokesh.joinClass(yoga);
em.persist(lokesh);
// insert into Member (name,id) values (?,?)
// insert into MembershipCard (member_id,plan,id) values (?,?,?)
// insert into Workout (activity,member_id,minutes,id) values (?,?,?,?)
// insert into member_class (member_id,class_id) values (?,?)
Notice that one persist() call writes all four rows, because cascade covers the card and the workouts, and Member owns the member_class join table.
Next, we look at the owning side of each association and build the gym example step by step.
1. Association Types and the Owning Side
Every association has a cardinality, which says how many entities can be on each side of the link. @ManyToOne and @OneToMany describe the same link seen from its two ends, so there are three kinds of relationships but four annotations.
| Annotation | Meaning | Gym example | Where the link is stored | Default fetch |
|---|---|---|---|---|
| @OneToOne | One A has one B | Member and MembershipCard | Foreign key in one of the two tables | EAGER |
| @ManyToOne | Many A belong to one B | Workout to Member | Foreign key in the A table | EAGER |
| @OneToMany | One A has many B | Member to Workout | Foreign key in the B table (with mappedBy) | LAZY |
| @ManyToMany | Many A relate to many B | Member and FitnessClass | Separate join table | LAZY |
The fetch type decides when Hibernate reads the associated rows, because EAGER loads them together with the entity, whereas LAZY loads them on first access. We set fetch = FetchType.LAZY on @ManyToOne and on the owning side of @OneToOne, because eager loading runs extra queries that we often do not need, as FAQ 3.5 shows.
1.1. Unidirectional and Bidirectional Associations
The direction of an association says which entity has a field that points to the other one.
- A unidirectional association has a field in one entity only. Workout has a member field, and Member knows nothing about workouts.
- A bidirectional association has a field in both entities. Workout.member and Member.workouts describe the same foreign key.
// Unidirectional: only the Workout side
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
// Bidirectional: add the other side to Member
@OneToMany(mappedBy = "member")
private List<Workout> workouts = new ArrayList<>();
Adding the workouts field changes only the Java model, so the Member table gets no new column, and the member_id column in the Workout table stores the link in both cases.
1.2. Owning Side, Inverse Side and mappedBy
A bidirectional association has two fields but only one foreign key, so Hibernate needs to know which field to read when it writes that key. The owning side is the field Hibernate uses to write the foreign key or the join table row, whereas the inverse side carries mappedBy and Hibernate ignores it for writes. The value of mappedBy is the name of the field on the owning side.
Jakarta Persistence fixes the owning side for some annotations.
| Association | Owning side | Inverse side |
|---|---|---|
| One-to-one | The entity whose table has the foreign key (@JoinColumn) | The other entity, with mappedBy |
| One-to-many / many-to-one | Always the @ManyToOne side | The @OneToMany side, with mappedBy |
| Many-to-many | Either entity; we pick it and put @JoinTable there | The other entity, with mappedBy |
@ManyToOne has no mappedBy attribute at all, because the “many” table always holds the foreign key.
1.3. Cascade and orphanRemoval on Associations
Two more attributes on the association annotations control what happens to the associated entities.
- The cascade attribute passes an operation such as persist() or remove() from the parent to the associated entities.
- The attribute orphanRemoval = true deletes a child row when the child is removed from the parent’s collection or reference.
Both attributes are independent of mappedBy. The owning side decides which field writes the foreign key, whereas cascade decides which operations Hibernate repeats on the associated entities.
1.4. Which Mapping Do I Need?
We pick the annotation by asking two questions about the data. The first is how many B one A can have, and the second is whether one B can belong to many A.

Each case of the decision grid has its own detailed guide.
| What we need | Mapping | Foreign key | Detailed guide |
|---|---|---|---|
| A member has exactly one card | @OneToOne + @JoinColumn on the card, mappedBy on the member | MembershipCard.member_id | One-to-one mapping |
| A workout belongs to one member | @ManyToOne on Workout | Workout.member_id | One-to-many mapping |
| A member lists their workouts | Add @OneToMany(mappedBy = “member”) on Member | Workout.member_id | One-to-many mapping |
| Members join many classes, classes have many members | @ManyToMany + @JoinTable on one side, mappedBy on the other | Join table member_class | Many-to-many mapping |
2. Association Mapping Example
The following example is a gym app that runs on Hibernate 7.4, Java 25 and an in-memory H2 database. The complete project on GitHub maps all three relationships, prints the SQL for each step and includes 15 JUnit tests (mvn -q compile exec:java, mvn test).
2.1. The Gym Domain Model
A member has one membership card (“Gold”), logs many workouts (“Running”, 30 minutes) and joins fitness classes (“Yoga”, “Spinning”) that other members join too.

Hibernate created these tables from the mappings.
create table Member (id bigint not null, name varchar(255), primary key (id))
create table MembershipCard (id bigint not null, member_id bigint unique, plan varchar(255), primary key (id))
create table Workout (minutes integer not null, id bigint not null, member_id bigint, activity varchar(255), primary key (id))
create table FitnessClass (id bigint not null, name varchar(255), primary key (id))
create table member_class (class_id bigint not null, member_id bigint not null, primary key (class_id, member_id))
2.2. One-to-One Mapping
The card table holds member_id, so MembershipCard is the owning side of the one-to-one mapping. Member points back with mappedBy = “member”, and a helper method sets both fields.
@OneToOne // fetch defaults to EAGER
@JoinColumn(name = "member_id")
private Member member;
@OneToOne(mappedBy = "member", cascade = CascadeType.ALL)
private MembershipCard card;
public void setCard(MembershipCard card) {
this.card = card;
card.setMember(this);
}
Hibernate adds a unique constraint to member_id, so the database itself allows only one card per member.
2.3. One-to-Many and Many-to-One Mapping
In the one-to-many mapping, Workout holds the member_id foreign key with @ManyToOne, and Member lists the workouts with @OneToMany(mappedBy = “member”).
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Workout> workouts = new ArrayList<>();
public void addWorkout(Workout workout) {
workouts.add(workout);
workout.setMember(this);
}
public void removeWorkout(Workout workout) {
workouts.remove(workout);
workout.setMember(null);
}
A workout cannot exist without its member, so the collection uses orphanRemoval. Removing one workout deletes its row.
member.removeWorkout(rowing); // delete from Workout where id=?
2.4. Many-to-Many Mapping
A class has many members and a member joins many classes, so neither table can hold a single foreign key. We make Member the owning side and describe the join table there.
@ManyToMany
@JoinTable(name = "member_class",
joinColumns = @JoinColumn(name = "member_id"),
inverseJoinColumns = @JoinColumn(name = "class_id"))
private Set<FitnessClass> classes = new HashSet<>();
public void joinClass(FitnessClass fitnessClass) {
classes.add(fitnessClass);
fitnessClass.getMembers().add(this);
}
@ManyToMany(mappedBy = "classes")
private Set<Member> members = new HashSet<>();
Joining and leaving a class only inserts or deletes a row in the join table, and the FitnessClass rows stay unchanged.
member.joinClass(spinning); // insert into member_class (member_id,class_id) values (?,?)
member.leaveClass(yoga); // delete from member_class where member_id=? and class_id=?
The classes field has no cascade, because a fitness class is shared by many members, so deleting a member must remove only the member’s rows in member_class, never the classes. A many-to-many link with extra columns needs a different mapping.
2.5. Loading a Member and Its Associations
The default fetch types decide what one em.find() reads. We load a member and ask the PersistenceUnitUtil which fields are already loaded.
Member member = em.find(Member.class, memberId);
// select m1_0.id,c1_0.id,c1_0.plan,m1_0.name from Member m1_0
// left join MembershipCard c1_0 on m1_0.id=c1_0.member_id where m1_0.id=?
boolean cardLoaded = util.isLoaded(member, "card"); // true (@OneToOne, EAGER)
boolean workoutsLoaded = util.isLoaded(member, "workouts"); // false (@OneToMany, LAZY)
boolean classesLoaded = util.isLoaded(member, "classes"); // false (@ManyToMany, LAZY)
int workoutCount = member.getWorkouts().size(); // 2
// select w1_0.member_id,w1_0.id,w1_0.activity,w1_0.minutes from Workout w1_0 where w1_0.member_id=?
Hibernate reads the lazy collections with their own select on first access, inside the same transaction.
2.6. Deleting a Member
The call em.remove(member) follows the cascades, so Hibernate deletes the card and the workouts with the member and removes the join table rows, while the classes stay.
delete from member_class where member_id=?
delete from MembershipCard where id=?
delete from Workout where id=? -- one per workout
delete from Member where id=?
3. Association Mapping FAQs
3.1. What Is the Difference Between @JoinColumn and mappedBy?
The two go on opposite sides of the same association. @JoinColumn names the foreign key column on the owning side, and mappedBy tells Hibernate that the other side already maps it.
| @JoinColumn | mappedBy | |
|---|---|---|
| Placed on | Owning side (Workout.member) | Inverse side (Member.workouts) |
| Creates a column | Yes, member_id | No |
| Used for writes | Yes | No |
| Value | Column name | Field name on the owning side |
3.2. Why Is the Foreign Key Null After Saving the Child?
Because we updated only the inverse side. Hibernate ignores the mappedBy field when it writes, so it saves the new workout without its member.
member.getWorkouts().add(new Workout("Cycling", 45));
em.persist(member.getWorkouts().getLast());
// insert into Workout (activity,member_id,minutes,id) values (?,?,?,?)
// member_id = NULL
yoga.getMembers().add(member); // no SQL, no member_class row
Always set the owning side, and keep both sides in sync with helper methods such as addWorkout() and joinClass(). Setting workout.setMember(member) alone is enough for the database, because after a reload, member.getWorkouts() contains the new workout.
3.3. Why Does @OneToMany Create an Extra Join Table?
Because a @OneToMany without mappedBy and without @JoinColumn is unidirectional, and its default mapping is a join table. Our Trainer entity maps its classes this way.
@OneToMany
private List<FitnessClass> classes = new ArrayList<>();
create table Trainer_FitnessClass (Trainer_id bigint not null, classes_id bigint not null unique)
insert into Trainer_FitnessClass (Trainer_id,classes_id) values (?,?)
The unique constraint on classes_id keeps it one-to-many, but every link costs one more table and one more insert. To store the key in the child table, we map it from the child with @ManyToOne, as Workout does, and add @OneToMany(mappedBy = …) on the parent only if we need the collection.
3.4. Should Every Association Be Bidirectional?
No. We add the inverse side only when the code reads the association from that entity, because each bidirectional association needs helper methods to keep both sides in sync, and a forgotten one leads to the null foreign key from FAQ 3.2.
The owning side alone can list the workouts of a member with a query.
List<String> activities = em.createQuery(
"select w.activity from Workout w where w.member.id = :id order by w.activity", String.class)
.setParameter("id", memberId)
.getResultList(); // activities = [Rowing, Running]
// select w1_0.activity from Workout w1_0 where w1_0.member_id=? order by w1_0.activity
Member.workouts is worth adding when we load a member and change its workouts through it, as with orphanRemoval in section 2.3.
3.5. Should I Use FetchType.EAGER?
No, in most cases. Hibernate loads an EAGER association every time it loads the entity, including through a query. MembershipCard.member keeps the default EAGER fetch, so a query for three cards runs four statements.
List<MembershipCard> cards = em.createQuery("from MembershipCard", MembershipCard.class).getResultList();
// select mc1_0.id,mc1_0.member_id,mc1_0.plan from MembershipCard mc1_0
// select m1_0.id,c1_0.id,c1_0.plan,m1_0.name from Member m1_0 ... where m1_0.id=? (card 1)
// select m1_0.id,c1_0.id,c1_0.plan,m1_0.name from Member m1_0 ... where m1_0.id=? (card 2)
// select m1_0.id,c1_0.id,c1_0.plan,m1_0.name from Member m1_0 ... where m1_0.id=? (card 3)
The extra statements are the N+1 select problem, which means one query for the list plus one per row. We map @ManyToOne and @OneToOne with fetch = FetchType.LAZY and load what a use case needs with join fetch, which reads the cards and their members in one statement.
List<MembershipCard> cards = em.createQuery("from MembershipCard c join fetch c.member", MembershipCard.class).getResultList();
// select mc1_0.id,m1_0.id,m1_0.name,mc1_0.plan from MembershipCard mc1_0
// join Member m1_0 on m1_0.id=mc1_0.member_id
4. Conclusion
Hibernate association mappings cover three relationships with four annotations, and they follow one rule. The owning side writes the foreign key, and the inverse side only points to it with mappedBy. We let the “many” side own one-to-many links, put @JoinTable on one side of a many-to-many, make single-valued associations LAZY and keep both sides in sync with helper methods.
5. References
- Jakarta Persistence 3.2 specification: Entity Relationships
- Jakarta Persistence 3.2 specification: Relationship Mapping Defaults
- OneToOne JavaDoc (Jakarta Persistence 3.2)
- ManyToOne JavaDoc (Jakarta Persistence 3.2)
- OneToMany JavaDoc (Jakarta Persistence 3.2)
- ManyToMany JavaDoc (Jakarta Persistence 3.2)
- Hibernate ORM 7.4 User Guide: Associations
Happy Learning !!
You are doing great job for java developer.Need more advanced
Thanks, Lokesh sir, You cleared my confusion about owner and owned entity with respect to mappedBy attribute. I googled, read the book and visited jboss official site and tired my eyes but did not get satisfactory answer. These sites just say formal jargon and typical stereotype definition that left me bewildered. Thanks man you explain it. You gave one example consist of EmployeEntity and AccountEntity and said that EmployeEntity is owner and AccountEntity is managedBy(mappedBy). You made the point clear further by explicitly show as follows
Let’s say association is managed by EmployeeEntity then the annotations in both entity will look like below.
//Inside EmployeeEntity.java
@OneToOne
AccountEntity account;
//Inside AccountEntity.java
@OneToOne (mappedBy = “account”)
EmployeeEntity employee;
Thanks again. Keep it up. We need your help
Lokesh,
First thanks to your great work. I am new to hibernate, but I can understand your article quite easily. I understand that both are bidirectional and scenario 1 has circular dependency which is bad, and second scenario does the right thing.
However I am a little confused by your statement at the following lines,
“Also observe the insert queries above. Both tables have foreign key association with column names employee_ID and account_ID respectively. It’s example of circular dependency in data and can easily bring down your application any time.”
Are these two foreign keys specified in database schema during table creations or they are just the artifact of circular dependency?
In my understanding, artifacts mirror the schema in database.
In second scenario, account table is not having any column to connect with employee ?
How is it getting account.getEmployee() ?
Secondly if one way mapping is able to get and insert data in both table as shown above ?
Why do we need bi-directional ?
In my opinion as i tried the same with JPA, we can not do account.getEmployee() in unidirectional
I am getting a null pointer.
Second scenario is not example of unidirectional mapping. It’s also bidirectional. It differs from scenario 1 only from point – who owns the association. In first case, both sides can manage the relationship; in second case,
EmployeeEntitymanages the association.The example code for scenario 2) above, is not in sync with the explanation given.
Currrent:—————————————————–
//Inside EmployeeEntity.java
@OneToOne
AccountEntity account;
//Inside AccountEntity.java
@OneToOne (mappedBy = “account”)
EmployeeEntity employee;
Corrected as per explanation:——————————
//Inside EmployeeEntity.java
@OneToOne(mappedBy = “employee”)
AccountEntity account;
//Inside AccountEntity.java
@OneToOne
EmployeeEntity employee;
Ignore my previous comment. i understood that wrong.
Glad you got it.