@OneToOne
A one-to-one relationship links exactly one row to one row — a User has a single UserProfile. JPA supports two physical layouts: a shared primary key where the child reuses the parent’s id (@MapsId), or a separate foreign key column (@JoinColumn). They look similar in Java but differ in schema, performance, and the famous lazy one-to-one trap. Start from Primary Keys if PK strategies are new to you.
Approach 1: shared primary key with @MapsId
Here the UserProfile row uses the same id as its User. @MapsId tells Hibernate the child’s @Id is supplied by the association, so there is no separate FK column — the id column is both the primary key and the foreign key.
import jakarta.persistence.*;
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private UserProfile profile;
public void setProfile(UserProfile profile) { // helper keeps both sides synced
profile.setUser(this);
this.profile = profile;
}
protected User() { }
public User(String email) { this.email = email; }
// getters and setters
}
import jakarta.persistence.*;
@Entity
public class UserProfile {
@Id
private Long id; // shares User's id, no @GeneratedValue
private String bio;
@OneToOne(fetch = FetchType.LAZY)
@MapsId // id comes from the associated User
@JoinColumn(name = "id")
private User user;
protected UserProfile() { }
public UserProfile(String bio) { this.bio = bio; }
public void setUser(User user) { this.user = user; }
// getters and setters
}
User user = new User("[email protected]");
UserProfile profile = new UserProfile("Mathematician");
user.setProfile(profile);
userRepository.save(user);
Generated SQL (shared PK)
create table users (
id bigint generated by default as identity,
email varchar(255),
primary key (id)
);
create table user_profile (
id bigint not null, -- both PK and FK
bio varchar(255),
primary key (id),
constraint fk_profile_user foreign key (id) references users (id)
);
insert into users (email) values ('[email protected]');
insert into user_profile (bio, id) values ('Mathematician', 1);
The profile reuses id 1 — no second key is generated. This is the most space-efficient mapping and guarantees the two are joined on the primary key.
Approach 2: foreign key with @JoinColumn
If you want the child to have its own independent id and a dedicated FK column, drop @MapsId and let the owning side carry a @JoinColumn.
@Entity
public class UserProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // its own key
private String bio;
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", unique = true)
private User user;
// ...
}
The inverse User.profile keeps mappedBy = "user" exactly as before.
Generated SQL (FK join column)
create table user_profile (
id bigint generated by default as identity,
bio varchar(255),
user_id bigint not null,
primary key (id),
constraint uk_profile_user unique (user_id),
constraint fk_profile_user foreign key (user_id) references users (id)
);
Note: The
uniqueconstraint onuser_idis what makes it one-to-one rather than many-to-one. Without it the database would allow two profiles per user.
The lazy one-to-one caveat
fetch = FetchType.LAZY works reliably only on the owning side (the one with the FK or @MapsId). On the inverse side (User.profile above), a nullable, optional one-to-one cannot be lazily proxied: Hibernate must run a query just to discover whether a profile exists so it can decide between a real object and null. A proxy cannot represent “maybe null”, so Hibernate eager-loads it anyway even when you wrote LAZY.
Mitigations:
@OneToOne(optional = false)— promise the association always exists, so Hibernate can use a proxy and honorLAZY.- Bytecode enhancement — enable Hibernate’s
enableLazyInitializationso it can intercept the field and load it on access. - Model it as
@ManyToOnewith auniqueconstraint —@ManyToOneis always lazily proxyable, and the unique key enforces one-to-one. - Put the FK on the side you read most, so it is the lazy-friendly owning side.
// always-present inverse side can stay lazy
@OneToOne(mappedBy = "user", fetch = FetchType.LAZY, optional = false)
private UserProfile profile;
Warning: A nullable inverse
@OneToOneis a silent N+1 source — each parent load fires an extra select. Confirm with SQL logging and prefer the owning-side FK or a@ManyToOnemodel. See N+1 Query Problem.
Shared PK vs FK join column
| Aspect | Shared PK (@MapsId) | FK join column (@JoinColumn) |
|---|---|---|
| Extra column | None — id is PK and FK | Dedicated user_id column |
| Child id source | Copied from parent | Independently generated |
| Storage | Most compact | One extra indexed column |
| Insert order | Parent before child (id needed) | Either, FK set on owning side |
| Best for | True 1:1 lifecycle (profile, detail row) | Optional/loosely coupled relation |
| Uniqueness | Guaranteed by shared PK | Needs unique = true on FK |
Common pitfalls
- Expecting LAZY on the inverse side — it eager-loads unless
optional = falseor bytecode enhancement is on. - Forgetting
uniqueon the FK turns a one-to-one into an accidental many-to-one. - Missing helper methods leaves
profile.usernull, so@MapsIdcannot derive the id and the save fails. - Cascade misuse —
orphanRemoval/CascadeType.ALLis right for owned detail rows but dangerous for shared references.