Skip to content
Spring Boot sb data-jpa 4 min read

@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 unique constraint on user_id is 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 honor LAZY.
  • Bytecode enhancement — enable Hibernate’s enableLazyInitialization so it can intercept the field and load it on access.
  • Model it as @ManyToOne with a unique constraint — @ManyToOne is 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 @OneToOne is a silent N+1 source — each parent load fires an extra select. Confirm with SQL logging and prefer the owning-side FK or a @ManyToOne model. See N+1 Query Problem.

Shared PK vs FK join column

AspectShared PK (@MapsId)FK join column (@JoinColumn)
Extra columnNone — id is PK and FKDedicated user_id column
Child id sourceCopied from parentIndependently generated
StorageMost compactOne extra indexed column
Insert orderParent before child (id needed)Either, FK set on owning side
Best forTrue 1:1 lifecycle (profile, detail row)Optional/loosely coupled relation
UniquenessGuaranteed by shared PKNeeds unique = true on FK

Common pitfalls

  • Expecting LAZY on the inverse side — it eager-loads unless optional = false or bytecode enhancement is on.
  • Forgetting unique on the FK turns a one-to-one into an accidental many-to-one.
  • Missing helper methods leaves profile.user null, so @MapsId cannot derive the id and the save fails.
  • Cascade misuseorphanRemoval/CascadeType.ALL is right for owned detail rows but dangerous for shared references.
Last updated June 13, 2026
Was this helpful?