In a relational database, tables are connected through foreign keys. In JPA the same thing is expressed with annotations on entity fields. Here we'll look at how it works, what to pay attention to, and where it's easy to slip up.
Why associations are more than just annotations
When a developer first sees @OneToMany, it looks simple: add the annotation and you're done. In reality every association has an owning side — the one that manages the foreign key in the database. If you don't specify it correctly, Hibernate will either create extra columns or simply won't save the association.
The second difficulty: by default some associations load immediately (EAGER), others load lazily (LAZY). Getting this expectation wrong is a straight path to LazyInitializationException and the N+1 query problem.
@ManyToOne — the simplest side
@ManyToOne is the owning side by default. This is exactly where the foreign key lives in the table.
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY) // default is EAGER, better to set LAZY explicitly
@JoinColumn(name = "customer_id") // name of the FK column in the orders table
private Customer customer;
}
@JoinColumn specifies which column of the orders table holds the reference to customers. Without this annotation Hibernate will make up a name of its own by convention (<field>_<id>), which usually matches, but it's better to be explicit.
Important: for @ManyToOne the default strategy is EAGER. This means that when loading Order, Hibernate will immediately pull in Customer — even if it isn't needed. An explicit fetch = FetchType.LAZY fixes that.
@OneToMany — the inverse side and mappedBy
@OneToMany describes "one to many" from the "one" side. But on its own this annotation does not create a foreign key — it merely tells Hibernate where to look for the reverse link.
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "customer") // "customer" — the field name in Order
private List<Order> orders = new ArrayList<>();
}
mappedBy = "customer" is an instruction: "the owning side is the customer field in the Order class; don't create any additional table or column, just read through it."
Without mappedBy Hibernate will create an intermediate join table (customer_orders) — just like with @ManyToMany. This is a common mistake.
The short rule:
mappedBygoes on the side that does not own the foreign key.
Unidirectional vs bidirectional association
Unidirectional — there is only one annotation on a single entity:
// Only in Order — Customer knows nothing about orders
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;
Bidirectional — annotations on both sides, mappedBy on the inverse one:
// In Order:
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;
// In Customer:
@OneToMany(mappedBy = "customer")
private List<Order> orders = new ArrayList<>();
A bidirectional association is more convenient for navigation, but it requires care: Hibernate saves only what it sees on the owning side. If you add an order to the customer.getOrders() list but don't call order.setCustomer(customer) — the change won't be saved.
Helper methods for synchronization
The solution is to add and remove through helper methods that update both sides at once:
@Entity
public class Customer {
@OneToMany(mappedBy = "customer")
private List<Order> orders = new ArrayList<>();
public void addOrder(Order order) {
orders.add(order);
order.setCustomer(this); // synchronize the owning side
}
public void removeOrder(Order order) {
orders.remove(order);
order.setCustomer(null);
}
}
The calling code works only through customer.addOrder(order) — and both sides are always consistent.
Default FetchType
Hibernate picks a loading strategy based on the type of association:
| Annotation | Default strategy |
|---|---|
@ManyToOne | EAGER |
@OneToOne | EAGER |
@OneToMany | LAZY |
@ManyToMany | LAZY |
ToOne associations load immediately — this often catches people off guard. If an entity has several @ManyToOne fields, every SELECT will drag in additional queries. Practical rule: always set FetchType.LAZY explicitly on @ManyToOne and @OneToOne, and load the data you need via JOIN FETCH in the query.
For more on loading strategies, see the article Lazy and eager loading.
@ManyToMany
@ManyToMany creates an intermediate table. You choose the owning side yourself — put @JoinTable on it and mappedBy on the inverse side.
@Entity
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToMany
@JoinTable(
name = "student_course", // name of the intermediate table
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
private Set<Course> courses = new HashSet<>();
}
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToMany(mappedBy = "courses") // the inverse side
private Set<Student> students = new HashSet<>();
}
@ManyToMany is handy for simple cases, but as soon as the intermediate table gains additional columns (enrollment date, status) — you need to turn it into a separate entity with two @ManyToOne fields.
cascade and orphanRemoval
cascade determines which operations on the parent are automatically applied to the child entities. The most commonly used are:
CascadeType.PERSIST— children are saved together with the parent.CascadeType.MERGE— children are updated together with the parent.CascadeType.REMOVE— children are deleted when the parent is deleted.CascadeType.ALL— all operations.
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL)
private List<Order> orders = new ArrayList<>();
orphanRemoval = true complements cascade: if a child entity is removed from the collection — it is deleted from the database automatically. Useful when a child entity has no meaning without its parent.
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Order> orders = new ArrayList<>();
Typical cascading traps are covered in common Hibernate pitfalls.
In short
@ManyToOne— the owning side; this is where the FK is stored and where@JoinColumngoes.@OneToMany(mappedBy = "...")— the inverse side;mappedBypoints to the field on the owning side.- Without
mappedByHibernate will create an intermediate table — just like with@ManyToMany. - For bidirectional associations you need to update both sides — use helper methods.
@ManyToOneand@OneToOneareEAGERby default — switch them toLAZYand load viaJOIN FETCH.cascade = CascadeType.ALL+orphanRemoval = true— a convenient duo for aggregates where child entities live only inside the parent.
What to read next
- Entity mapping — the
@Column,@Tableannotations, types, and converters. - Lazy and eager loading — when and how related objects are loaded.
- The N+1 query problem — how associations spawn an avalanche of queries and how to stop it.
- Spring Data JPA — repositories and derived queries on top of Hibernate.