One-to-One Relationships: @OneToOne in Depth

Introduction

A one-to-one relationship means one record in table A corresponds to exactly one record in table B. In JPA this is modelled with @OneToOne. Understanding where the foreign key lives, which side “owns” the relationship, and how cascade operations work is essential before moving to the more complex @OneToMany and @ManyToMany.


The Domain Example

In the e-commerce system, a Customer has one CustomerProfile containing their preferences and biography. A profile belongs to exactly one customer.

customers ──── customer_profiles
(1)             (1)

Unidirectional @OneToOne

The simplest form: only one side knows about the relationship.

@Entity
@Table(name = "customer_profiles")
@Getter @Setter @NoArgsConstructor
public class CustomerProfile {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(columnDefinition = "TEXT")
    private String bio;

    @Column(name = "newsletter_opt_in")
    private boolean newsletterOptIn = false;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;
}
@Entity
@Table(name = "customers")
@Getter @Setter @NoArgsConstructor
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String name;

    @Column(nullable = false, length = 150, unique = true)
    private String email;

    // Unidirectional @OneToOne — Customer knows about Profile, but not vice versa
    @OneToOne(cascade = CascadeType.ALL)
    @JoinColumn(name = "profile_id", unique = true)
    private CustomerProfile profile;
}

The @JoinColumn(name = "profile_id") places a foreign key column profile_id in the customers table pointing to customer_profiles.id.

customers table:
id | name  | email         | profile_id
1  | Alice | alice@..      | 1

Creating a customer with profile

@Transactional
public Customer createCustomer(String name, String email, String bio) {
    CustomerProfile profile = new CustomerProfile();
    profile.setBio(bio);
    profile.setCreatedAt(LocalDateTime.now());

    Customer customer = new Customer();
    customer.setName(name);
    customer.setEmail(email);
    customer.setProfile(profile);  // associate
    customer.setCreatedAt(LocalDateTime.now());
    customer.setUpdatedAt(LocalDateTime.now());

    // CascadeType.ALL on profile → save(customer) also saves profile
    return customerRepository.save(customer);
}

Hibernate generates:

INSERT INTO customer_profiles (bio, newsletter_opt_in, created_at) VALUES (?, ?, ?)
INSERT INTO customers (name, email, profile_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)

Bidirectional @OneToOne

In a bidirectional relationship, both sides have a reference to the other. One side owns the relationship (holds the foreign key); the other uses mappedBy.

@Entity
@Table(name = "customer_profiles")
public class CustomerProfile {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String bio;
    private boolean newsletterOptIn;

    // Inverse side — mappedBy points to the field in Customer that owns the FK
    @OneToOne(mappedBy = "profile")
    private Customer customer;
}
@Entity
@Table(name = "customers")
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String email;

    // Owner side — this side has the @JoinColumn (the FK)
    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "profile_id", unique = true)
    private CustomerProfile profile;
}

The ownership rule

  • The side with @JoinColumn is the owner — it holds the foreign key column
  • The side with mappedBy is the inverse — it references the owner’s field

Only changes on the owner side are persisted. Setting profile.setCustomer(customer) alone will not update the database — you must also set customer.setProfile(profile).

Helper methods for bidirectional consistency

Always maintain both sides of a bidirectional relationship:

@Entity
public class Customer {

    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "profile_id", unique = true)
    private CustomerProfile profile;

    public void setProfile(CustomerProfile profile) {
        this.profile = profile;
        if (profile != null) {
            profile.setCustomer(this);  // keep both sides in sync
        }
    }
}

Shared Primary Key: @PrimaryKeyJoinColumn

An alternative to a foreign key column is sharing the primary key. The CustomerProfile row uses the same id as its Customer row:

customers:         id=1, name="Alice"
customer_profiles: id=1 (same id  FK to customers.id), bio="..."
@Entity
@Table(name = "customer_profiles")
public class CustomerProfile {

    @Id
    private Long id;  // same value as Customer.id

    @OneToOne(fetch = FetchType.LAZY)
    @PrimaryKeyJoinColumn   // profile.id = customer.id
    @MapsId                 // Hibernate 6 preferred style
    private Customer customer;

    private String bio;
}
@Entity
@Table(name = "customers")
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToOne(mappedBy = "customer", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private CustomerProfile profile;
}

Shared primary key saves a foreign key column and ensures a profile can never exist without its customer (no orphan profile_id column).


Optional vs. Required @OneToOne

By default, @OneToOne is optional (optional = true) — the foreign key column can be NULL, and the associated entity may not exist.

// Optional (default) — profile_id can be NULL
@OneToOne(cascade = CascadeType.ALL, optional = true)
@JoinColumn(name = "profile_id")
private CustomerProfile profile;

Set optional = false when the association is mandatory. Hibernate optimises the JOIN to an INNER JOIN instead of LEFT OUTER JOIN:

// Required — every customer MUST have a profile
@OneToOne(cascade = CascadeType.ALL, optional = false)
@JoinColumn(name = "profile_id", nullable = false)
private CustomerProfile profile;

Fetch Type: Lazy vs Eager

By default, @OneToOne is EAGER — when you load a Customer, Hibernate immediately loads the CustomerProfile too.

// EAGER (default for @OneToOne) — always loads profile with customer
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "profile_id")
private CustomerProfile profile;

This means loading 1000 customers runs 1000 + 1000 = 2000 queries (one per customer plus one per profile). Use LAZY:

// LAZY — profile loaded only when you call customer.getProfile()
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id")
private CustomerProfile profile;

Always use FetchType.LAZY on @OneToOne unless you always need both entities and have a single load pattern.


Cascade Types

cascade propagates persistence operations from the owning entity to the associated entity.

@OneToOne(cascade = CascadeType.ALL)

Common options:

CascadeEffect
CascadeType.PERSISTsave(customer) also saves profile
CascadeType.MERGEmerge(customer) also merges profile
CascadeType.REMOVEdelete(customer) also deletes profile
CascadeType.ALLAll of the above (+ REFRESH, DETACH)

Use CascadeType.ALL for owned entities (profile belongs entirely to customer — no meaning without it). Be careful with CascadeType.REMOVE — accidentally deleting a shared entity is a common bug.


Complete Working Example

Flyway migration:

-- V2__add_customer_profiles.sql
CREATE TABLE customer_profiles (
    id                 BIGINT    NOT NULL AUTO_INCREMENT,
    bio                TEXT,
    newsletter_opt_in  TINYINT(1) NOT NULL DEFAULT 0,
    created_at         DATETIME(6) NOT NULL,
    PRIMARY KEY (id)
) ENGINE=InnoDB;

ALTER TABLE customers
    ADD COLUMN profile_id BIGINT UNIQUE,
    ADD CONSTRAINT fk_customer_profile
        FOREIGN KEY (profile_id) REFERENCES customer_profiles (id);

Service:

@Service
@RequiredArgsConstructor
public class CustomerService {

    private final CustomerRepository customerRepository;

    @Transactional
    public Customer register(String name, String email) {
        CustomerProfile profile = new CustomerProfile();
        profile.setBio("");
        profile.setNewsletterOptIn(false);
        profile.setCreatedAt(LocalDateTime.now());

        Customer customer = new Customer();
        customer.setName(name);
        customer.setEmail(email);
        customer.setProfile(profile);
        customer.setCreatedAt(LocalDateTime.now());
        customer.setUpdatedAt(LocalDateTime.now());

        return customerRepository.save(customer);
    }

    @Transactional
    public void updateBio(Long customerId, String bio) {
        Customer customer = customerRepository.findById(customerId).orElseThrow();
        customer.getProfile().setBio(bio);  // loads profile (lazy), sets bio
        // dirty checking picks up the profile change — no explicit save needed
    }
}

Key Takeaways

  • @OneToOne maps a one-to-one relationship; the side with @JoinColumn owns the foreign key
  • mappedBy on the inverse side points to the field on the owner side — it does not create a column
  • Always set both sides of a bidirectional relationship, or use a helper method
  • Use FetchType.LAZY — the default EAGER causes unnecessary queries when loading collections
  • optional = false generates an INNER JOIN instead of LEFT OUTER JOIN — use it when the relationship is mandatory
  • CascadeType.ALL on owned entities means save/delete operations cascade automatically

What’s Next

Article 10 covers @OneToMany and @ManyToOne — the most common relationship type in every domain model, with bidirectional mapping, the parent-child pattern, and cascade/orphanRemoval.