skip to content

A developer maps a one-to-many collection in JPA with only @OneToMany and no mappedBy or @JoinColumn. What schema does the provider generate, why is that surprising, and what are the alternatives?

level: middleimportance: should knowfreq 47%

answer

  1. Bare @OneToMany → join table, not a child FK
  2. Unique constraint on child column keeps it one-to-many
  3. @JoinColumn variant: insert null FK then UPDATE
  4. mappedBy + @ManyToOne = one INSERT, not-null FK
  5. Unbounded children → drop the collection, query instead

basics

~20 s

The provider creates a join table instead of a foreign key in the child table — the default for a unidirectional one-to-many. Alternatives: add @JoinColumn to keep the FK in the child (costs extra UPDATEs), or make it bidirectional with mappedBy, which is cleanest.

solid answer

~50 s

With a bare `@OneToMany` and no `mappedBy`, JPA defaults to a **join table** — `parent_child` with `parent_id` and `child_id`, plus a unique constraint on the child column to keep it one-to-many. People expect a `parent_id` FK in the child table, so this is a genuine surprise, and it costs an extra table, extra joins, and extra writes. Three ways out: 1. **`@OneToMany @JoinColumn(name = "parent_id")`** — unidirectional with the FK in the child table. Correct schema, but Hibernate inserts children with a null FK and issues a separate `UPDATE` to set it, so the column must be nullable and you pay N extra statements. 2. **Bidirectional: `@OneToMany(mappedBy = "parent")` + `@ManyToOne @JoinColumn` on the child.** The child owns the FK, one INSERT per child, `nullable = false` enforceable. This is the recommended mapping. 3. **Drop the collection** and query children by parent when the collection is large or unbounded. Hibernate can avoid the extra UPDATE for case 1 with `@OneToMany(mappedBy=...)`-style ownership only; the join-column variant keeps the penalty.

code

java · 12 lines
java
// 1. default: join table parent_children
@OneToMany(cascade = CascadeType.ALL)
private List<Child> a = new ArrayList<>();

// 2. FK in child, parent owns it -> extra UPDATE per child
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "parent_id")
private List<Child> b = new ArrayList<>();

// 3. bidirectional, child owns the FK -> one INSERT per child
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> c = new ArrayList<>();

go deeper

for a junior

Know that a @OneToMany without mappedBy generates a join table and that adding mappedBy with a @ManyToOne on the child is the usual fix.

for a middle

Compare all three mappings with the SQL each emits, and explain why the @JoinColumn variant needs a nullable column.

for a senior

Discuss integrity and write-amplification consequences, how the mismatch surfaces when migrations own the schema, and when to drop the parent-side collection entirely.

for a principal

Frame it as aggregate design: which side owns the relationship, whether an unbounded collection belongs on the aggregate root at all, and how mapping choices constrain the schema the migration tool must maintain.

## The surprise ```java @Entity class Parent { @Id @GeneratedValue private Long id; @OneToMany(cascade = CascadeType.ALL) private List<Child> children = new ArrayList<>(); } ``` Nothing here says `mappedBy` and nothing says `@JoinColumn`. Most developers assume the generated schema is `child(parent_id)`. It is not. JPA's default for a unidirectional to-many association is a **join table**: ```sql create table parent_children ( parent_id bigint not null references parent(id), children_id bigint not null references child(id), primary key (parent_id, children_id), unique (children_id) -- keeps it one-to-many, not many-to-many ); ``` The unique constraint on the child column is what distinguishes this from a many-to-many: a child may appear under at most one parent. ## Why it is a bad default in practice - **An extra table** nobody designed, usually with a provider-generated name that leaks the Java field name (`parent_children`, `children_id`). - **An extra join** on every read that navigates the association, and two indexes to maintain instead of one. - **Extra writes**: inserting a child costs an insert into `child` *and* an insert into `parent_children`. - **Weaker integrity**: the parent link is enforced in a side table, so you cannot express "a child must have a parent" with a simple `not null` on the child row. - **Worse deletes**: removing an element deletes a link row and leaves an orphan child unless `orphanRemoval` is on. The spec chose it because a unidirectional one-to-many has no field on the child to hold the FK, and a join table is the only mapping that requires no knowledge on the child side. It is principled; it is just rarely what you want. ## Option 1 — @OneToMany with @JoinColumn ```java @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "parent_id") private List<Child> children = new ArrayList<>(); ``` This produces the schema you expected: `child.parent_id`. But the *parent* owns the association while the FK lives in the *child* row, and Hibernate resolves that by writing in two passes: ```sql insert into child (id, name, parent_id) values (?, ?, null); update child set parent_id = ? where id = ?; ``` One extra `UPDATE` per child, and `parent_id` must be nullable, so the database cannot enforce that every child has a parent. Removing an element issues `update child set parent_id = null` (or a delete with `orphanRemoval`). For small collections this is tolerable; for bulk inserts it doubles the statement count. Adding `@JoinColumn(nullable = false)` does not fix it — the null-then-update sequence would violate the constraint. ## Option 2 — bidirectional (recommended) ```java @Entity class Child { @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "parent_id", nullable = false) private Parent parent; } @Entity class Parent { @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true) private List<Child> children = new ArrayList<>(); void addChild(Child c) { children.add(c); c.setParent(this); } void removeChild(Child c) { children.remove(c); c.setParent(null); } } ``` The child owns the FK, so each child is written with one INSERT carrying `parent_id`. The column can be `not null`, giving the database real integrity. The cost is the discipline of keeping both sides in sync — hence the helper methods, which are not optional decoration but the mechanism that prevents the classic "FK stayed null" bug. ## Option 3 — no collection at all If the child count is unbounded (audit rows, events, messages), mapping a collection at all is a liability: it invites accidental full loads, `orphanRemoval` semantics you do not want, and cascade behaviour on delete that locks huge row sets. Map only `Child.parent` and query `select c from Child c where c.parent = :p` with pagination. Dropping the parent-side collection is a legitimate and frequently correct answer, and mentioning it signals design maturity. ## How to notice this in a real codebase The join table usually appears when someone lets the provider generate DDL in development. If migrations own the schema (Liquibase/Flyway) the mapping and the schema silently disagree, and you get a runtime failure — "table parent_children does not exist" — rather than a bad schema. Either way, the diagnosis is the same: a `@OneToMany` with neither `mappedBy` nor `@JoinColumn`.

  • Why can't you simply declare the join column not-null in the @OneToMany + @JoinColumn mapping?
    Because the parent owns the association while the FK lives in the child row, so Hibernate inserts each child with a null foreign key and then issues an UPDATE to set it. A not-null constraint would fail on that first INSERT. Getting a not-null FK requires the child to own the association through a @ManyToOne, i.e. the bidirectional mapping.
  • How does the generated join table for a unidirectional @OneToMany differ from a @ManyToMany join table?
    Structurally they are the same two-FK table, but the one-to-many version adds a unique constraint on the child's foreign-key column so a child can belong to at most one parent. Without that constraint the same child row could be linked from several parents, which is exactly the many-to-many semantics.

The join-table default is filing every parent-child link in a separate ledger instead of writing the parent's name on the child's own record.

saying these in an interview costs you the question

  • Assuming a bare @OneToMany produces a foreign key in the child table
  • Adding @JoinColumn(nullable = false) to a parent-owned @OneToMany and expecting it to work
  • Not knowing the @JoinColumn-on-@OneToMany variant emits an extra UPDATE per child
  • Mapping an unbounded child collection on the parent just because the relationship exists
  • Believing the join-table default and a @ManyToMany join table are indistinguishable

context